Theme
On this page (14)
Component Skill — components/border-layout/component-skill.md

Native basis

A CSS grid with named areas; each resizable region is a .resizer whose one handle - a role="separator" - becomes the divider.

Web Platform APIs

grid-template-areassetPointerCapture()role="separator"ResizeObserverlocalStorage

Classes

.border-layout.border-layout-north.border-layout-south.border-layout-west.border-layout-east.border-layout-center.border-layout-pane

Data attributes

data-dominant (ns, we), data-center-min, data-collapsible, data-save, data-frame="none", data-divider (dashed, dotted, double, thick, none, gap), data-grip (dots, bar, none); on a region's resizer: data-min / data-max, data-collapsible, data-divider, data-grip; --border-layout-divider-color / -width, --border-layout-accent; events resizer-resize, border-layout-collapse; API df$.shadcn.borderLayout.

§Border layout

Five regions on a grid: north and south span the width (the default, data-dominant='ns'), west and east sit between them around the center. Every side is a .resizer wrapping its pane - drag a line, or Tab to it and use the arrow keys (they move the line the way they point; Home / End jump to the limits). The center never drops below 120px.

§West and east dominate

data-dominant='we': west and east run the full height, north and south sit between them above and below the center - the frame of an IDE with full-height sidebars.

§Horizontal split

Just west and a center: two panes side by side, the regions left out take no room. The west pane starts at 50%; a drag writes px.

§Vertical split

Just north and a center: two panes stacked.

§Three columns

West, center, east - a mail client: folders, the list, the reading pane.

§Nested splits

A border layout in the center of another, with data-frame='none': a horizontal split whose right side is split vertically. Each divider belongs to its own layout.

§Grips

data-grip on the layout draws a handle on every divider: dots (a small dotted card) or bar (a pill); on one region's resizer it overrides the layout - data-grip='none' switches it off there.

§Divider styles

data-divider: solid (the default), dashed, dotted, double, thick, none - invisible but still draggable - and gap, where the regions float as cards in a gutter and the divider lives in the gap. Set it on one region's resizer to style just that divider.

§Custom handle

The divider is a .resizer-handle: --border-layout-divider-color / -width / --border-layout-accent retheme it, and plain CSS on its ::before (the line) and ::after (the grip) makes any handle - here a bold primary rail with a round knob.

§Collapsible and remembered

data-collapsible: double-click a divider (or focus it and press Enter) to fold its region away - the divider stays to bring it back, and dragging it unfolds. data-save='…' remembers sizes and folded regions across visits (localStorage) - resize, reload the page, it comes back as you left it. The buttons use df$.shadcn.borderLayout.

§An application window

The frame of an app, in a Window: a menubar in the north, a file tree in the west, the editor in the center, an outline in the east and a terminal (a code mockup) in the south - west and east dominate. Every divider drags; View folds and unfolds the panels through df$.shadcn.borderLayout.

§States

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

  • default - every region open; setting it restores the authored sizes
  • collapsed - one or more regions folded: { regions: ['west', 'south'] } or { region: 'west' }

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

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

StateTypeValuesDefaultDescription
collapsedbooleantrue, falsefalseOne or more regions folded away (the schema folds west); default opens them all.

§CSS view file

/* -- Border Layout component --------------------------------------
   An application frame in five regions - north, south, west, east around a
   center - on a CSS grid. Every side region that should resize is a
   .resizer wrapping its pane: inside a border layout the resizer's handle
   becomes a full-length divider (line, optional grip), dragged or moved with
   the arrow keys. Regions left out collapse, so the same frame is a
   horizontal split, a vertical split, three columns... */
@layer components {
  .border-layout {
    /* the divider, themable per layout or per region */
    --_line-width: var(--border-layout-divider-width, 1px);
    --_line-style: solid;
    --_line-color: var(--border-layout-divider-color, var(--border));
    --_accent: var(--border-layout-accent, var(--ring));
    --_hit: 0.75rem;
    --_gap: 0px;
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    grid-template-rows: auto minmax(0, 1fr) auto;
    /* north and south dominate: they span the full width */
    grid-template-areas:
      "north north north"
      "west  center east"
      "south south south";
    gap: var(--_gap);
    min-width: 0;
    min-height: 0;
    overflow: hidden;
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    background: var(--card);
    color: var(--card-foreground);
    /* west and east dominate: they span the full height */
    &[data-dominant="we"] {
      grid-template-areas:
        "west north  east"
        "west center east"
        "west south  east";
    }
    /* no frame of its own - fills a window or a page */
    &[data-frame="none"] {
      border: 0;
      border-radius: 0;
      background: transparent;
    }
  }
  .border-layout > .border-layout-north { grid-area: north; }
  .border-layout > .border-layout-south { grid-area: south; }
  .border-layout > .border-layout-west { grid-area: west; }
  .border-layout > .border-layout-east { grid-area: east; }
  .border-layout > .border-layout-center {
    grid-area: center;
    min-width: 0;
    min-height: 0;
    overflow: auto;
  }
  /* -- Regions ------------------------------------------------------ */
  /* a resizable region: the resizer wrapper fills its grid cell, its pane
     stretches across the other axis and carries the size on its own */
  .border-layout > .resizer {
    display: flex;
    width: auto;
    min-width: 0;
    min-height: 0;
  }
  .border-layout > :is(.border-layout-north, .border-layout-south).resizer { flex-direction: column; }
  .border-layout > .resizer > :not(.resizer-handle) {
    flex: none;
    box-sizing: border-box;
    min-width: 0;
    min-height: 0;
    overflow: auto;
  }
  /* the pane of a collapsed region is gone; its divider stays to bring it back */
  .border-layout > .resizer[data-collapsed] > :not(.resizer-handle) { display: none; }
  /* a region that does not resize still gets the divider line */
  .border-layout > .border-layout-north:not(.resizer) { border-block-end: var(--_line-width) var(--_line-style) var(--_line-color); }
  .border-layout > .border-layout-south:not(.resizer) { border-block-start: var(--_line-width) var(--_line-style) var(--_line-color); }
  .border-layout > .border-layout-west:not(.resizer) { border-inline-end: var(--_line-width) var(--_line-style) var(--_line-color); }
  .border-layout > .border-layout-east:not(.resizer) { border-inline-start: var(--_line-width) var(--_line-style) var(--_line-color); }
  /* padding for panes that hold text (optional) */
  .border-layout-pane { padding: 0.75rem; }
  /* -- Dividers: the resizer handle, redrawn --------------------------- */
  .border-layout > .resizer > .resizer-handle {
    z-index: 5;
    width: auto;
    height: auto;
    border: 0;
    border-radius: 0;
    background: transparent;
    box-shadow: none;
    opacity: 1;
    color: var(--muted-foreground);
    /* the line */
    &::before {
      content: "";
      position: absolute;
      transition: border-color 120ms;
    }
    /* the grip - hidden unless data-grip asks for one */
    &::after {
      content: none;
      inset: auto;
    }
    &:hover,
    &:focus-visible { background: transparent; outline: none; }
    &:is(:hover, :focus-visible)::before { border-color: var(--_accent); }
    &:focus-visible::after { outline: 2px solid var(--_accent); outline-offset: 1px; }
    /* vertical dividers: west's east edge, east's west edge */
    &:is([data-handle="e"], [data-handle="w"]) {
      top: 0;
      bottom: 0;
      width: var(--_hit);
      cursor: col-resize;
      &::before {
        inset-block: 0;
        left: 50%;
        border-inline-start: var(--_line-width) var(--_line-style) var(--_line-color);
        translate: -50% 0;
      }
    }
    &[data-handle="e"] { right: 0; left: auto; translate: calc(50% + var(--_gap) / 2) 0; }
    &[data-handle="w"] { left: 0; right: auto; translate: calc(-50% - var(--_gap) / 2) 0; }
    /* horizontal dividers: north's south edge, south's north edge */
    &:is([data-handle="n"], [data-handle="s"]) {
      left: 0;
      right: 0;
      height: var(--_hit);
      cursor: row-resize;
      &::before {
        inset-inline: 0;
        top: 50%;
        border-block-start: var(--_line-width) var(--_line-style) var(--_line-color);
        translate: 0 -50%;
      }
    }
    &[data-handle="s"] { bottom: 0; top: auto; translate: 0 calc(50% + var(--_gap) / 2); }
    &[data-handle="n"] { top: 0; bottom: auto; translate: 0 calc(-50% - var(--_gap) / 2); }
  }
  /* dragging: the divider lights up; the resizer's own box outline stays off */
  .border-layout > .resizer[data-resizing] {
    outline: none;
    & > .resizer-handle { background: transparent; }
    & > .resizer-handle::before { border-color: var(--_accent); }
  }
  /* -- Grips: data-grip on the layout (every divider) or on one region -- */
  :is(.border-layout[data-grip] > .resizer, .border-layout > .resizer[data-grip]) > .resizer-handle::after {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    translate: -50% -50%;
    box-sizing: border-box;
    border-radius: var(--radius-sm);
  }
  /* dots: a small card with a dotted grip */
  :is(.border-layout[data-grip="dots"] > .resizer, .border-layout > .resizer[data-grip="dots"]) > .resizer-handle::after {
    width: 0.75rem;
    height: 1.25rem;
    border: 1px solid var(--_line-color);
    background:
      radial-gradient(circle, currentColor 0.75px, transparent 1.25px) center / 4px 4px,
      var(--background);
  }
  /* bar: a pill across the line */
  :is(.border-layout[data-grip="bar"] > .resizer, .border-layout > .resizer[data-grip="bar"]) > .resizer-handle::after {
    width: 0.25rem;
    height: 2rem;
    border-radius: 999px;
    background: color-mix(in oklch, var(--foreground) 35%, var(--_line-color));
  }
  /* data-grip="none" - on the layout (regions without their own grip) or one
     region - switches it off; more specific than the rules above */
  :is(.border-layout[data-grip="none"] > .resizer:not([data-grip]), .border-layout > .resizer[data-grip="none"]) > .resizer-handle::after { content: none; }
  /* horizontal dividers turn the grip sideways */
  .border-layout .resizer > .resizer-handle:is([data-handle="n"], [data-handle="s"])::after { rotate: 90deg; }
  .border-layout .resizer > .resizer-handle:is(:hover, :focus-visible)::after { border-color: var(--_accent); }
  /* -- Divider styles (data-divider on the layout or one region) ----------- */
  :is(.border-layout, .border-layout > .resizer, .border-layout > [class*="border-layout-"]) {
    &[data-divider="dashed"] { --_line-style: dashed; }
    &[data-divider="dotted"] { --_line-style: dotted; --_line-width: var(--border-layout-divider-width, 2px); }
    &[data-divider="double"] { --_line-style: double; --_line-width: var(--border-layout-divider-width, 4px); }
    &[data-divider="thick"] { --_line-width: var(--border-layout-divider-width, 4px); --_line-color: var(--border-layout-divider-color, var(--muted)); }
    &[data-divider="none"] { --_line-width: 0px; }
  }
  /* gap: the regions float as cards in a gutter, the divider lives in the gutter */
  .border-layout[data-divider="gap"] {
    --_gap: 0.5rem;
    --_line-width: 0px;
    padding: var(--_gap);
    background: var(--muted);
    & > :is(.border-layout-north, .border-layout-south, .border-layout-west, .border-layout-east, .border-layout-center):not(.resizer),
    & > .resizer > :not(.resizer-handle) {
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      background: var(--card);
    }
  }
  /* -- Accessibility -------------------------------------------- */
  @media (prefers-reduced-motion: reduce) {
    .border-layout > .resizer > .resizer-handle::before { transition: none; }
  }
  @media (prefers-contrast: more) {
    .border-layout { --_line-color: var(--foreground); }
  }
  @media (forced-colors: active) {
    .border-layout { border: 1px solid CanvasText; --_line-color: CanvasText; --_accent: Highlight; }
    .border-layout > .resizer > .resizer-handle::before { forced-color-adjust: none; }
  }
}

§JS view file

// -- Border Layout ----------------------------------------------
// North, south, west and east around a center, on a CSS grid. The resizing
// is the Resizer's: every resizable region is a .resizer wrapping its pane,
// with one handle on its inner edge. This module adds the layout on top:
// sensible resizer defaults per region, the center never squeezed below its
// minimum, collapsible regions (double-click or Enter on the divider),
// sizes remembered across visits (data-save), the window-splitter values
// on each divider - and the named-state API (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 = every region open at its authored size; collapsed = one or
 *  more regions folded away (their divider stays). */
const borderLayoutStates = ['default', 'collapsed'];
const SIDES = {
  north: { handle: 's', axis: 'h', size: 'height' },
  south: { handle: 'n', axis: 'h', size: 'height' },
  west: { handle: 'e', axis: 'w', size: 'width' },
  east: { handle: 'w', axis: 'w', size: 'width' },
};
const REGIONS = Object.keys(SIDES);
const num = (v, fallback) => {
  const n = parseFloat(v);
  return Number.isFinite(n) ? n : fallback;
};
const resolve = (t) => (typeof t === 'string' ? document.getElementById(t) ?? document.querySelector(t) : t);
/** The region element (resizer wrapper or plain pane) for a side. */
const regionOf = (layout, side) => layout.querySelector(`:scope > .border-layout-${side}`);
/** The pane that carries the size: the resizer's wrapped element. */
const paneOf = (region) => (region.classList.contains('resizer')
  ? Array.from(region.children).find((c) => !c.classList.contains('resizer-handle'))
  : region);
const sizeOf = (region, side) => {
  if (region.hasAttribute('data-collapsed')) return 0;
  const box = region.getBoundingClientRect();
  return Math.round(SIDES[side].axis === 'w' ? box.width : box.height);
};
/** Sets a region's size through the resizer (one axis, clamped) - or directly
 *  on the pane before the resizer has initialized. */
function setSize(region, side, px) {
  const { size } = SIDES[side];
  const pane = paneOf(region);
  if (!pane) return;
  const value = String(Math.round(px));
  if (region.hasAttribute('data-init') && region.api) region.dataset[size] = value; // the resizer applies it
  else pane.style[size] = `${value}px`;
}
/**
 * The center keeps at least data-center-min px (default 120): each side's
 * maximum is what the frame has left after the opposite side and the
 * center. Recomputed before every drag / key and whenever the frame
 * resizes; a side already too big is brought back in.
 */
function clamp(layout) {
  const centerMin = num(layout.dataset.centerMin, 120);
  const style = getComputedStyle(layout);
  const gapW = num(style.columnGap, 0) * 2;
  const gapH = num(style.rowGap, 0) * 2;
  const pad = (a, b) => num(style[a], 0) + num(style[b], 0);
  const innerW = layout.clientWidth - pad('paddingLeft', 'paddingRight') - gapW;
  const innerH = layout.clientHeight - pad('paddingTop', 'paddingBottom') - gapH;
  const OPPOSITE = { north: 'south', south: 'north', west: 'east', east: 'west' };
  for (const side of REGIONS) {
    const region = regionOf(layout, side);
    if (!region?.classList.contains('resizer')) continue;
    const other = regionOf(layout, OPPOSITE[side]);
    const taken = other ? sizeOf(other, OPPOSITE[side]) : 0;
    const room = (SIDES[side].axis === 'w' ? innerW : innerH) - taken - centerMin;
    const authoredMax = num(region.dataset.maxAuthored, Infinity);
    const max = Math.max(num(region.dataset.min, 48), Math.min(room, authoredMax));
    region.dataset[SIDES[side].axis === 'w' ? 'maxW' : 'maxH'] = String(Math.round(max));
    if (!region.hasAttribute('data-collapsed') && sizeOf(region, side) > max + 1) setSize(region, side, max);
  }
}
/** The divider speaks the window-splitter pattern: its value is the region size. */
function aria(layout) {
  for (const side of REGIONS) {
    const region = regionOf(layout, side);
    const handle = region?.querySelector(':scope > .resizer-handle');
    if (!handle) continue;
    const pane = paneOf(region);
    if (pane && !pane.id) pane.id = `${layout.id || 'border-layout'}-${side}-${Math.random().toString(36).slice(2, 7)}`;
    if (pane) handle.setAttribute('aria-controls', pane.id);
    const name = region.getAttribute('aria-label') || pane?.getAttribute('aria-label') || side;
    handle.setAttribute('aria-label', `Resize ${name}`);
    handle.setAttribute('aria-valuenow', String(sizeOf(region, side)));
    handle.setAttribute('aria-valuemin', String(region.hasAttribute('data-collapsible') || layout.hasAttribute('data-collapsible') ? 0 : num(region.dataset.min, 48)));
    handle.setAttribute('aria-valuemax', String(num(region.dataset[SIDES[side].axis === 'w' ? 'maxW' : 'maxH'], 2000)));
  }
}
const collapsible = (layout, region) => region.hasAttribute('data-collapsible') || layout.hasAttribute('data-collapsible');
/** Folds a region away (or back); its divider stays in place to bring it back. */
function collapse(layout, side, collapsed) {
  const region = regionOf(layout, side);
  if (!region) return;
  const was = region.hasAttribute('data-collapsed');
  if (was === collapsed) return;
  region.toggleAttribute('data-collapsed', collapsed);
  aria(layout);
  save(layout);
  layout.dispatchEvent(new CustomEvent('border-layout-collapse', { bubbles: true, detail: { region: side, collapsed } }));
  syncState(layout);
}
/** The named state follows the regions: any collapsed → 'collapsed'. */
function syncState(layout) {
  const folded = REGIONS.filter((s) => regionOf(layout, s)?.hasAttribute('data-collapsed'));
  layout.dataset.stateName = folded.length ? 'collapsed' : 'default';
  layout._stateConfig = folded.length ? { regions: folded } : {};
}
// -- Persistence (data-save="key") ---------------------------------------------
const storeKey = (layout) => `defuss-shadcn:border-layout:${layout.dataset.save}`;
function save(layout) {
  if (!layout.dataset.save || layout._restoring) return;
  const data = {};
  for (const side of REGIONS) {
    const region = regionOf(layout, side);
    if (!region?.classList.contains('resizer')) continue;
    const pane = paneOf(region);
    const px = Math.round(num(pane?.style[SIDES[side].size], NaN));
    data[side] = { size: Number.isFinite(px) ? px : null, collapsed: region.hasAttribute('data-collapsed') };
  }
  try { localStorage.setItem(storeKey(layout), JSON.stringify(data)); } catch { /* storage may be off */ }
}
function restore(layout) {
  if (!layout.dataset.save) return;
  let data = null;
  try { data = JSON.parse(localStorage.getItem(storeKey(layout)) || 'null'); } catch { data = null; }
  if (!data) return;
  layout._restoring = true;
  for (const side of REGIONS) {
    const region = regionOf(layout, side);
    const saved = data[side];
    if (!region || !saved) continue;
    if (saved.size) setSize(region, side, saved.size);
    region.toggleAttribute('data-collapsed', !!saved.collapsed);
  }
  layout._restoring = false;
}
// -- State API -------------------------------------------------------------------
/**
 * UI side of setState. 'default' opens every region at its authored size;
 * 'collapsed' folds `{ regions: ['west', …] }` (or `{ region: 'west' }`) and
 * opens the others.
 */
function triggerStateChange(layout, stateName, config) {
  if (stateName === 'default') {
    for (const side of REGIONS) {
      const region = regionOf(layout, side);
      if (!region) continue;
      region.removeAttribute('data-collapsed');
      if (!region._authored) continue;
      // the computed maximum reflects the old layout - lift it, then re-clamp
      region.dataset[SIDES[side].axis === 'w' ? 'maxW' : 'maxH'] = region.dataset.maxAuthored ?? '2000';
      setSize(region, side, region._authored);
    }
    clamp(layout);
  } else {
    const want = new Set(config.regions ?? (config.region ? [config.region] : []));
    for (const side of REGIONS) {
      const region = regionOf(layout, side);
      if (region) region.toggleAttribute('data-collapsed', want.has(side));
    }
  }
  aria(layout);
  save(layout);
}
export const borderLayoutApi = {
  setState(layout, stateName, config = {}) {
    if (!borderLayoutStates.includes(stateName)) {
      throw new Error(`border-layout: unknown state "${stateName}" (supported: ${borderLayoutStates.join(', ')})`);
    }
    triggerStateChange(layout, stateName, config);
    syncState(layout);
  },
  getState(layout) {
    return { name: layout.dataset.stateName || 'default', config: layout._stateConfig ?? {} };
  },
};
df$.borderLayoutApi = borderLayoutApi;
df$.borderLayoutStates = borderLayoutStates;
// -- init --------------------------------------------------------------------------
function init() {
  document.querySelectorAll('.border-layout:not([data-init])').forEach((layout) => {
    layout.dataset.init = '';
    for (const side of REGIONS) {
      const region = regionOf(layout, side);
      if (!region?.classList.contains('resizer')) continue;
      // the resizer defaults a border region needs - authored values win
      const d = region.dataset;
      d.handles ??= SIDES[side].handle;
      d.axis ??= SIDES[side].axis;
      d.keys ??= 'edge';
      d.min ??= '48';
      if (d.max) d.maxAuthored = d.max;
      // a percentage start (width: 30%) is of the layout, not of the region's
      // own content-sized column (circular - it would collapse): resolve it
      const pane = paneOf(region);
      const prop = SIDES[side].size;
      const authored = pane?.style[prop] ?? '';
      if (authored.endsWith('%')) {
        const inner = prop === 'width' ? layout.clientWidth : layout.clientHeight;
        pane.style[prop] = `${Math.round((parseFloat(authored) / 100) * inner)}px`;
      }
      region._authored = sizeOf(region, side) || null;
    }
    restore(layout);
    // before any drag or key: recompute the room every side may take; a drag
    // on a folded region unfolds it first
    const before = (e) => {
      const handle = e.target.closest?.('.resizer-handle');
      if (!handle || handle.parentElement?.parentElement !== layout) return;
      clamp(layout);
      const side = REGIONS.find((s) => handle.parentElement.classList.contains(`border-layout-${s}`));
      if (e.type === 'pointerdown' && side && handle.parentElement.hasAttribute('data-collapsed')) collapse(layout, side, false);
    };
    layout.addEventListener('pointerdown', before, true);
    layout.addEventListener('keydown', before, true);
    layout.addEventListener('focusin', (e) => { before(e); aria(layout); });
    // double-click or Enter on a divider folds a collapsible region
    const toggle = (handle) => {
      const region = handle.parentElement;
      const side = REGIONS.find((s) => region.classList.contains(`border-layout-${s}`));
      if (!side || !collapsible(layout, region)) return;
      collapse(layout, side, !region.hasAttribute('data-collapsed'));
    };
    layout.addEventListener('dblclick', (e) => {
      const handle = e.target.closest('.resizer-handle');
      if (handle && handle.parentElement?.parentElement === layout) toggle(handle);
    });
    layout.addEventListener('keydown', (e) => {
      const handle = e.target.closest?.('.resizer-handle');
      if (e.key === 'Enter' && handle && handle.parentElement?.parentElement === layout) {
        e.preventDefault();
        toggle(handle);
      }
    });
    // every size change: splitter values, persistence
    layout.addEventListener('resizer-resize', (e) => {
      if (e.target.parentElement !== layout) return;
      aria(layout);
      save(layout);
    });
    new ResizeObserver(() => { clamp(layout); aria(layout); }).observe(layout);
    layout.api = {
      setState: (stateName, config) => borderLayoutApi.setState(layout, stateName, config),
      getState: () => borderLayoutApi.getState(layout),
    };
    syncState(layout);
    // the resizers may initialize after this module - label their handles then
    queueMicrotask(() => { clamp(layout); aria(layout); });
  });
}
// -- df$.shadcn.borderLayout: the imperative surface ----------------------------------
df$.borderLayout = {
  /** Folds a region away. */
  collapse: (t, side) => { const l = resolve(t); if (l) collapse(l, side, true); },
  /** Brings a folded region back. */
  expand: (t, side) => { const l = resolve(t); if (l) collapse(l, side, false); },
  /** Folds or unfolds; returns whether it is now collapsed. */
  toggle: (t, side) => {
    const l = resolve(t);
    const region = l && regionOf(l, side);
    if (!region) return false;
    collapse(l, side, !region.hasAttribute('data-collapsed'));
    return region.hasAttribute('data-collapsed');
  },
  /** Sets a region's size in px (clamped by the resizer). */
  resize: (t, side, px) => { const l = resolve(t); const r = l && regionOf(l, side); if (r) { clamp(l); setSize(r, side, px); } },
  /** The current sizes: { west: 240, east: 0 (collapsed), … }. */
  sizes: (t) => {
    const l = resolve(t);
    const out = {};
    if (l) for (const side of REGIONS) { const r = regionOf(l, side); if (r) out[side] = sizeOf(r, side); }
    return out;
  },
};
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

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