Split Pane
A dependency-free resizable split layout — percentage flex-basis panes, per-pane percent/px minimums, double-click collapse, full keyboard resizing, and an optional layout that survives reloads.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/split-pane.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SplitPane" component with NO resizing
library (no react-resizable-panels, no react-split-pane, no Radix) — the
pointer, keyboard and persistence logic is the product.
Contract
- export const SplitPane = React.forwardRef<HTMLDivElement, SplitPaneProps>,
remaining props spread onto the root: panes: React.ReactNode[] (2+;
position is identity — panes[i] owns sizes[i]); direction?: "horizontal" |
"vertical" (default "horizontal" = panes side by side, drag left/right;
"vertical" = stacked, drag up/down); defaultSizes?: number[] (percent,
normalized to sum 100, default equal split); sizes?: number[] +
onSizesChange?: (sizes: number[]) => void (controlled pair); minSizes?:
(number | string)[] — a number is a percentage, a "180px" string is
pixels; collapsible?: boolean[] (per pane); gutterSize? (default 6, px);
storageKey?: string; className.
- export function clearSplitPaneSizes(key: string) — removes the persisted
entry and notifies every mounted SplitPane using that key, so a "Reset
layout" button really snaps the panes back to defaultSizes.
- Sizes are ALWAYS percentages summing to 100. Any entry that is not a
finite non-negative number is replaced by an equal share before
normalizing, so a short or garbage `defaultSizes` can never produce a
zero-width pane by accident; an explicit 0 is meaningful and means
"collapsed".
- storageKey is ignored while `sizes` is controlled — the consumer owns
persistence there, and reading the store would fight their state.
Behavior
- Drag: pointerdown on a separator captures the pointer (setPointerCapture)
and snapshots {pointerId, index, origin coordinate, the current sizes
array, the available px}. pointermove/up/cancel ignore any event whose
pointerId is not the captured one, so a second finger cannot hijack a live
drag. deltaPct = deltaPx / availablePx * 100, where availablePx =
container px - gutterSize * (panes - 1).
- Size algebra, the core rule: a drag on separator i trades the delta
between panes i and i+1 ONLY. pairTotal = sizes[i] + sizes[i+1]; the
leading pane is clamped to [min(mins[i], pairTotal), pairTotal -
mins[i+1]] and the trailing pane takes the remainder. Every other pane
keeps its exact size, so dragging one separator to the very end can never
squeeze a pane it isn't touching — it just stops at the neighbour's
minimum.
- Minimums are resolved per render, never stored: percent entries pass
through, "180px" entries divide by the measured availablePx, and if the
minimums over-subscribe the container they are scaled down together (that
keeps sum(mins) <= 100, which is what makes the pair clamp total-safe). A
separate clamp pass raises any pane below its minimum and pays for it out
of the slack of the panes that have room, keeping the sum at 100. Because
it is derived, a container resize or a px minimum that just grew past its
pane is corrected on the next render — no observer -> setState round trip,
no accumulated drift.
- Collapse: double-clicking a separator (or pressing Enter on it) collapses
the first adjacent pane whose `collapsible` flag is true, preferring the
leading one; its size goes to the other pane of the pair and its previous
size is remembered in a ref. Doing it again restores exactly that size
(after a reload that memory is gone, so it falls back to the pane's share
of defaultSizes). A pane at 0 counts as collapsed and is exempt from its
own minimum — collapsing is an explicit intent, a min must not bounce it
open — and dragging its separator reopens it at that minimum.
- Keyboard: every separator is role="separator" tabIndex={0} with
aria-orientation (vertical when the panes sit side by side, horizontal
when they stack), aria-controls pointing at the leading pane's id, and
aria-valuenow / valuemin / valuemax / valuetext describing the leading
pane's percentage. Arrow keys along the layout axis (Left/Right when
horizontal, Up/Down when vertical) move 1%, with Shift 10%; Home/End jump
to the same limits aria-valuemin/valuemax advertise; Enter toggles
collapse when the pane is collapsible. Every handled key calls
preventDefault so the page doesn't scroll instead.
- Persistence: reads go through useSyncExternalStore — subscribe to both the
native "storage" event (other tabs) and a custom same-tab event; the
snapshot is localStorage.getItem parsed and CACHED against its raw string
(parsing fresh on every call returns a new array and re-renders forever);
the server snapshot is null. Never read storage during render or in an
effect body. The layout is written once per drag (on pointerup) and once
per keyboard step, not once per pointermove. When the store changes
underneath the component — another tab, another instance, a reset button —
state is adjusted DURING render by comparing the snapshot reference with
the last one seen, the same technique used for "props changed, reset
state". Corrupt/hand-edited entries and quota/private-mode failures fall
back silently: the layout still works, it just isn't remembered.
- Container: one ResizeObserver on the root feeds the px<->% math (it fires
once on observe(), which doubles as the first measurement) and disconnects
on unmount. Nothing else depends on it — because sizes are percentages the
ratio survives any resize for free.
Rendering & styling
- Panes are `flex-basis: <size>%` with flex-grow 0 and flex-shrink 1, plus
min-w-0 / min-h-0 and overflow-hidden. The bases sum to 100% while the
gutters add px on top, so flex shrink removes the gutter width from every
pane proportionally — the rendered ratio stays exactly `sizes` without any
calc() bookkeeping, and a collapsed pane (basis 0) shrinks by nothing and
stays at 0.
- Semantic tokens only: bg-card + border + rounded-lg (root), bg-border (the
hairline inside the gutter), bg-muted-foreground/40 -> bg-primary for the
grip on hover/focus/drag, bg-primary/10 (gutter hover and active wash),
ring-ring for focus-visible. No hex, no rgb().
- The separator is only `gutterSize` px thick but carries an invisible
::after that extends 4px past it on the drag axis, so a 6px gutter is
still comfortably grabbable; it sits at z-10 and has touch-action: none so
touch drags don't scroll the page instead. The root gets select-none only
while a drag is live.
- Only collapse/expand animates (transition-[flex-basis] duration-200,
enabled by a state flag that the pointer and arrow-key paths switch off so
a drag tracks the pointer 1:1), and it carries
motion-reduce:transition-none — with reduced motion the pane still
collapses, just instantly.
Customization levers
- Axis and count: the same component does 2 or N panes and both directions —
pass `panes` of any length; `direction` flips the flex axis, the arrow-key
pair, the cursor and aria-orientation together.
- Minimums are the main tuning knob: percentages for fluid layouts, "260px"
for panes with a real content floor (a file tree, a chat composer). Give
the pane you never want squeezed a percentage min and its neighbours px.
- Gutter feel: gutterSize 1–2 for a hairline seam, 8–12 for a chunky IDE
handle; swap the grip span for an icon (lucide GripVertical) or drop it
and keep just the hairline. The hover/active wash is a single token
(bg-primary/10) — retint or remove it without touching the logic.
- Collapse policy: `collapsible` is per pane, so ship it only for the
sidebar; the "leading pane wins" tie-break lives in one small function if
you want the trailing pane to win instead.
- Persistence: drop `storageKey` for a stateless splitter, keep it for
per-user layout memory, or go controlled (`sizes` + `onSizesChange`) and
write the sizes into your own store or server-side user preferences —
clearSplitPaneSizes stays useful as the "reset layout" action.
- Chrome: the root is a plain bordered card — remove the border for a
seamless full-bleed workspace, or give each pane its own card by moving
the border onto the pane children.Concepts
- Pair-local redistribution — a drag trades the delta between the two panes flanking that separator only; every other pane keeps its exact number. That is why dragging all the way to the end can never squeeze a pane you aren't touching: no cascading push algorithm is needed, going too far simply stops at the neighbour's min.
- Derived clamping — per-pane minimums never enter state; they are resolved from
minSizesplus the measured container pixels on every render and clamped there. So a container resize, or a px min that just grew past its pane, heals on the next render — no observer writing back into state, and no drift accumulating from repeated clamping. - Percent basis + flex shrink — sizes are percentage
flex-basisand the gutters are pixels on top; letting flex shrink absorb those pixels in proportion to the bases makes the rendered ratio exactlysizes, with nocalc()bookkeeping, and the ratio survives a container resize for free. - Collapse memory — collapsing hands the pane's entire size to its neighbour and sets it to 0, recording the pre-collapse value in a ref; triggering it again restores exactly that value. A pane at 0 is exempt from its own min, which would otherwise bounce it straight back open.
- pointerId isolation — pointerdown calls
setPointerCapture, and move/up/cancel afterwards accept only that onepointerId; a second finger or another pen cannot take over a live drag, nor leave the state half-cleared when it lifts. - Hydration-safe persistence — localStorage is read only through
useSyncExternalStore: the server snapshot is always null, and the client snapshot caches its parsed value against the raw string (a freshJSON.parseper call returns a new array and re-renders forever). Outside changes — another tab, a reset button — adjust state during render by comparing snapshot references, not by calling setState in an effect body.
Steps Checklist
A dismissible onboarding checklist — jump-around completion, a required-only progress bar, per-step expandable instructions with a CTA, and a dependency-free celebration state.
Settings List
Grouped settings rows — label and description on the left, your own control on the right, with whole-row links, badges, disabled rows and a danger zone.