Border LayoutORG
An application frame in five regions - north, south, west and east around a center - with every divider draggable. It reuses the Resizer: each side is a .resizer wrapping its pane, and inside a border layout its handle becomes a full-length divider you drag or move with the arrow keys. Leave regions out and the same frame is a horizontal split, a vertical split or three columns; nest it for more. North/south or west/east dominant, grips, dashed / dotted / double / gap dividers, custom handles, collapsible and remembered panels - after shadcn's Resizable.
On this page (14)
§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 sizescollapsed- 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:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
collapsed | boolean | true, false | false | One 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