Theme
On this page (9)
Component Skill — components/panel/component-skill.md

Native basis

A .card section with a title bar; the tools are Swaps - a hidden <input type="checkbox"> each.

Built from

Card (surface), Swap (minimize / maximize tools), Border Layout (regions).

Web Platform APIs

checkboxwriting-modeinert:has()CustomEvent

Classes

.panel.panel-header.panel-icon.panel-title.panel-tools.panel-minimize.panel-maximize.panel-body.panel-footer

Data attributes

data-minimized, data-maximized, data-title-collapse="false", data-region (set by the runtime); data-flush on the body; data-panel-host on what a maximized panel fills; event panel-change; API df$.shadcn.panel.

§Panel

A title bar inside a card: icon, title and two Swaps - maximize and minimize. Minimize: the card goes, the title bar stays and the space below is released (a double-click on the title does the same). Maximize fills the view; Escape or the tool brings it back.

§In a border layout

Every region's pane is a panel. Minimize north or south and the region shrinks to the title bar; minimize west or east and the title bar flips into a vertical tab - icon, title and the restore tool stay - and the region shrinks to its width. The chevron points where the region folds. The divider rests while its panel is folded; drag sizes come back on restore. Maximize the editor: it fills the whole layout.

§Gap dividers

data-divider='gap' on the layout: the regions float as cards in a gutter, so the panels keep their card look. West and east dominate (data-dominant='we').

§Start minimized

data-minimized on the panel (or a checked minimize swap) folds it from the first paint - here the east inspector starts as a vertical tab.

§More tools, flush body

Extra tools are buttons in .panel-tools next to the swaps; data-flush drops the body padding for a list or a table. Without .panel-maximize there is no maximize - every tool is optional.

§Driven from script

df$.shadcn.panel.minimize / maximize / restore / toggle take a panel, an id or a selector; every change fires panel-change with the state, the previous state and the region.

§States

Named states via the shared State API, driven per panel through the bound api:

  • default - title bar and body at the authored size
  • minimized - the title bar only; in a border layout region the region folds with it (a vertical tab in west / east)
  • maximized - fills its border layout, [data-panel-host] or the viewport

The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/panel-{state}.png.

Machine contract - verified against panel.schema.json by bun run verify:

StateTypeValuesDefaultDescription
minimizedbooleantrue, falsefalseThe title bar only - in a border layout region, the region folds with it.
maximizedbooleantrue, falsefalseFills its border layout, a [data-panel-host] or the viewport.

§CSS view file

/* -- Panel component ----------------------------------------------
   A title bar inside a card - icon, title and tools (minimize / maximize are
   Swaps) above a scrolling body. Minimized it is the title bar only; as the
   pane of a border-layout region it takes the region with it: north / south
   shrink to the bar, west / east turn the bar into a vertical tab (after the
   ExtJS 4 border layout). Maximized it fills its border layout, a
   [data-panel-host] or the viewport. Builds on .card (surface, border,
   radius) and .swap (the tool faces). */
@layer components {
  .panel {
    /* the card's padding scale, a notch tighter for tool-like bodies */
    --_base: 1rem;
    --_bar: 2.5rem;
    --_bar-bg: color-mix(in oklch, var(--muted) 60%, var(--card));
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    min-width: 0;
    min-height: 0;
    /* -- Title bar -------------------------------------------------- */
    & > .panel-header {
      display: flex;
      align-items: center;
      gap: 0.5rem;
      flex: none;
      min-block-size: var(--_bar);
      box-sizing: border-box;
      padding-block: 0.25rem;
      padding-inline: 0.75rem 0.375rem;
      border-block-end: 1px solid var(--border);
      background: var(--_bar-bg);
      color: var(--foreground);
      user-select: none;
    }
    & .panel-icon {
      flex: none;
      width: 1rem;
      height: 1rem;
      color: var(--muted-foreground);
    }
    & .panel-title {
      flex: 1;
      min-inline-size: 0;
      margin: 0;
      overflow: hidden;
      font: inherit;
      font-size: 0.875rem;
      font-weight: 600;
      line-height: 1.25;
      white-space: nowrap;
      text-overflow: ellipsis;
    }
    /* tools: the swaps (and any .btn) at the end of the bar */
    & .panel-tools {
      display: flex;
      align-items: center;
      gap: 0.125rem;
      flex: none;
      margin-inline-start: auto;
    }
    /* small tool buttons - the swap at icon-button size */
    & > .panel-header .panel-tools .swap {
      min-width: 1.75rem;
      min-height: 1.75rem;
      color: var(--muted-foreground);
      & svg:not([class*="size-"]) { width: 1rem; height: 1rem; }
      &:hover:not(:has(> input:disabled)) { color: var(--accent-foreground); }
    }
    /* -- Body ----------------------------------------------------------- */
    & > .panel-body {
      flex: 1;
      min-block-size: 0;
      overflow: auto;
      padding: var(--_pad, 1rem);
    }
    /* a body that brings its own layout (a list, a table, a code view) */
    & > .panel-body[data-flush] { padding: 0; }
    & > .panel-footer {
      display: flex;
      align-items: center;
      gap: 0.5rem;
      flex: none;
      padding: 0.5rem 0.75rem;
      border-block-start: 1px solid var(--border);
      background: var(--_bar-bg);
      font-size: 0.8125rem;
      color: var(--muted-foreground);
    }
    /* -- Minimized: the title bar only ------------------------------------- */
    &[data-minimized] {
      & > :not(.panel-header) { display: none; }
      & > .panel-header { border-block-end-color: transparent; }
    }
    /* the authored height (inline, or the resizer's px) gives way to the bar */
    &[data-minimized]:not([data-region="west"], [data-region="east"]) {
      height: auto !important;
      min-height: 0;
    }
    /* west / east: the bar turns into a vertical tab, the width to the bar's */
    &[data-minimized]:is([data-region="west"], [data-region="east"]) {
      width: auto !important;
      min-width: 0;
      /* the card's inline-size containment would size the tab to zero */
      container-type: normal;
      & > .panel-header {
        flex: 1;
        writing-mode: vertical-rl;
        padding-block: 0.25rem;
        padding-inline: 0.375rem 0.75rem;
        border-block-end: 0;
      }
      /* the restore tool leads, like ExtJS's collapsed placeholder */
      & .panel-tools { order: -1; margin-inline: 0 0.25rem; }
      & .panel-maximize { display: none; }
    }
    /* the minimize chevron points where the panel folds to */
    &[data-region="south"] .panel-minimize { rotate: 180deg; }
    &[data-region="west"] .panel-minimize { rotate: -90deg; }
    &[data-region="east"] .panel-minimize { rotate: 90deg; }
    /* -- Maximized: fills the viewport ... -------------------------------- */
    &[data-maximized] {
      position: fixed;
      inset: 0;
      z-index: 50;
      width: auto !important;
      height: auto !important;
      max-width: none;
      max-height: none;
      margin: 0;
      border-radius: 0;
      box-shadow: var(--shadow-xl);
    }
  }
  /* ... or its border layout / [data-panel-host] */
  :is(.border-layout, [data-panel-host])[data-panel-maximized] {
    position: relative;
    & .panel[data-maximized] {
      position: absolute;
      z-index: 20;
      border-radius: inherit;
    }
  }
  /* the regions step back so the layout is the containing block; their
     dividers rest under the maximized panel */
  .border-layout[data-panel-maximized] > .resizer {
    position: static;
    & > .resizer-handle { visibility: hidden; }
  }
  /* the region holding it rises too: a region may be a stacking context of
     its own (a <main> with a view-transition-name) that would trap the
     panel's z-index - grid items take z-index without being positioned, so
     the layout stays the containing block */
  .border-layout[data-panel-maximized] > :has(.panel[data-maximized]) { z-index: 20; }
  /* -- In a border layout: the region frames the panel ---------------------- */
  :where(.border-layout > .resizer, .border-layout > .border-layout-center, .border-layout) > .panel {
    border: 0;
    border-radius: 0;
  }
  /* the region shrinks with the panel: its pane is the panel */
  .border-layout > .resizer[data-panel-minimized] > .panel { overflow: hidden; }
  /* -- Accessibility -------------------------------------------- */
  @media (prefers-reduced-motion: reduce) {
    .panel .panel-minimize { transition: none; }
  }
  @media (prefers-contrast: more) {
    .panel > .panel-header { border-block-end-color: var(--foreground); }
    .panel .panel-title { font-weight: 700; }
  }
  @media (forced-colors: active) {
    .panel { border: 1px solid CanvasText; }
    .panel > .panel-header { border-block-end: 1px solid CanvasText; background: Canvas; color: CanvasText; }
    .panel .panel-icon { color: CanvasText; }
  }
}

§JS view file

// -- Panel ------------------------------------------------------------------
// A card with a title bar - icon, title, tools - whose minimize and maximize
// buttons are Swaps (a checkbox each: the CSS shows the face). This module
// keeps the panel's state in step with those checkboxes, and makes it a
// border-layout citizen after ExtJS 4: minimized in north / south the whole
// region shrinks to the title bar; in west / east the title bar turns into a
// vertical tab and the region shrinks to its width. Maximized, the panel fills
// its border layout (or a [data-panel-host], else the viewport); Escape
// restores it. The named-state API follows AGENTS.md "State API".
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js -
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals } from '../../shared/state-api.js';
const df$ = defussGlobals();
/** default = title bar + body; minimized = the title bar only (in a border
 *  layout region the region shrinks with it); maximized = fills its host. */
const panelStates = ['default', 'minimized', 'maximized'];
const SIDES = ['north', 'south', 'west', 'east', 'center'];
const resolve = (t) => (typeof t === 'string' ? document.getElementById(t) ?? document.querySelector(t) : t);
const toolInput = (panel, tool) => panel.querySelector(`:scope > .panel-header .panel-${tool} > input[type="checkbox"]`);
/**
 * The border-layout region a panel sits in: the panel itself (a fixed region),
 * the resizer it is the pane of, or the center it fills. Anything deeper is
 * not a region panel.
 */
function regionOf(panel) {
  const parent = panel.parentElement;
  if (!parent) return null;
  if (parent.classList.contains('border-layout')) return panel;
  if (parent.classList.contains('resizer') && parent.parentElement?.classList.contains('border-layout')) return parent;
  if (parent.classList.contains('border-layout-center') && parent.parentElement?.classList.contains('border-layout')) return parent;
  return null;
}
const sideOf = (region) => (region ? SIDES.find((s) => region.classList.contains(`border-layout-${s}`)) ?? null : null);
/** The element a maximized panel fills: its border layout or a [data-panel-host]. */
const hostOf = (panel) => panel.parentElement?.closest('.border-layout, [data-panel-host]') ?? null;
// -- State API ------------------------------------------------------------------
/** The only function that touches the DOM for a state change. */
function triggerStateChange(panel, stateName) {
  const minimized = stateName === 'minimized';
  const maximized = stateName === 'maximized';
  panel.toggleAttribute('data-minimized', minimized);
  panel.toggleAttribute('data-maximized', maximized);
  // the swaps show the state - a checkbox each
  const min = toolInput(panel, 'minimize');
  const max = toolInput(panel, 'maximize');
  if (min) min.checked = minimized;
  if (max) max.checked = maximized;
  // the body leaves the a11y tree with the card
  const body = panel.querySelector(':scope > .panel-body');
  if (body) body.toggleAttribute('inert', minimized);
  // a region shrinks to the title bar; its divider rests until it comes back
  const region = regionOf(panel);
  if (region && region !== panel) {
    region.toggleAttribute('data-panel-minimized', minimized);
    const handle = region.querySelector(':scope > .resizer-handle');
    if (handle) handle.inert = minimized || maximized;
  }
  // maximized: the host becomes the positioning context
  const host = hostOf(panel);
  if (maximized && host) {
    panel._host = host;
    host.setAttribute('data-panel-maximized', '');
  } else if (panel._host) {
    if (!panel._host.querySelector('.panel[data-maximized]')) panel._host.removeAttribute('data-panel-maximized');
    panel._host = null;
  }
}
export const panelApi = {
  setState(panel, stateName, config = {}) {
    if (!panelStates.includes(stateName)) {
      throw new Error(`panel: unknown state "${stateName}" (supported: ${panelStates.join(', ')})`);
    }
    const from = panel.dataset.stateName || 'default';
    triggerStateChange(panel, stateName);
    panel.dataset.stateName = stateName;
    panel._stateConfig = config;
    if (from !== stateName) {
      panel.dispatchEvent(new CustomEvent('panel-change', { bubbles: true, detail: { state: stateName, previous: from, region: sideOf(regionOf(panel)) } }));
    }
  },
  getState(panel) {
    return { name: panel.dataset.stateName || 'default', config: panel._stateConfig ?? {} };
  },
};
df$.panelApi = panelApi;
df$.panelStates = panelStates;
// -- init --------------------------------------------------------------------------
function init() {
  document.querySelectorAll('.panel:not([data-init])').forEach((panel) => {
    panel.dataset.init = '';
    // where it sits: data-region drives the vertical title bar and the
    // direction the minimize chevron points
    const side = sideOf(regionOf(panel));
    if (side) panel.dataset.region = side;
    const header = panel.querySelector(':scope > .panel-header');
    const body = panel.querySelector(':scope > .panel-body');
    if (body) {
      if (!body.id) body.id = `panel-${Math.random().toString(36).slice(2, 8)}-body`;
      toolInput(panel, 'minimize')?.setAttribute('aria-controls', body.id);
    }
    // the swaps: a checked minimize is 'minimized', a checked maximize 'maximized'
    panel.addEventListener('change', (e) => {
      const input = e.target;
      if (!(input instanceof HTMLInputElement) || input.closest('.panel') !== panel) return;
      if (input === toolInput(panel, 'minimize')) panelApi.setState(panel, input.checked ? 'minimized' : 'default');
      else if (input === toolInput(panel, 'maximize')) panelApi.setState(panel, input.checked ? 'maximized' : 'default');
    });
    // a double-click on the title bar (not on a tool) minimizes / restores -
    // data-title-collapse="false" switches it off
    header?.addEventListener('dblclick', (e) => {
      if (panel.dataset.titleCollapse === 'false' || e.target.closest('.panel-tools') || !toolInput(panel, 'minimize')) return;
      document.getSelection()?.removeAllRanges();
      panelApi.setState(panel, panel.hasAttribute('data-minimized') ? 'default' : 'minimized');
    });
    // Escape leaves maximized
    panel.addEventListener('keydown', (e) => {
      if (e.key !== 'Escape' || !panel.hasAttribute('data-maximized')) return;
      e.stopPropagation();
      panelApi.setState(panel, 'default');
      toolInput(panel, 'maximize')?.focus();
    });
    panel.api = {
      setState: (stateName, config) => panelApi.setState(panel, stateName, config),
      getState: () => panelApi.getState(panel),
    };
    // markup may start minimized / maximized (attribute or a checked swap)
    const start = panel.hasAttribute('data-maximized') || toolInput(panel, 'maximize')?.checked ? 'maximized'
      : panel.hasAttribute('data-minimized') || toolInput(panel, 'minimize')?.checked ? 'minimized'
        : 'default';
    triggerStateChange(panel, start);
    panel.dataset.stateName = start;
    panel._stateConfig = {};
  });
}
// -- df$.shadcn.panel: the imperative surface ------------------------------------------
const act = (t, state) => {
  const panel = resolve(t);
  if (panel?.api) panel.api.setState(state);
  return panel ?? null;
};
df$.panel = {
  /** Title bar only - in a border layout region, the region shrinks with it. */
  minimize: (t) => act(t, 'minimized'),
  /** Fills its border layout / [data-panel-host] / the viewport. */
  maximize: (t) => act(t, 'maximized'),
  /** Back to title bar + body at the authored size. */
  restore: (t) => act(t, 'default'),
  /** Minimizes or restores; returns whether it is now minimized. */
  toggle: (t) => {
    const panel = resolve(t);
    if (!panel?.api) return false;
    panel.api.setState(panel.hasAttribute('data-minimized') ? 'default' : 'minimized');
    return panel.hasAttribute('data-minimized');
  },
};
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub