Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------
 * Persistence — localStorage is read through useSyncExternalStore only.
 * Reading storage during render would desync SSR/hydration; reading it in an
 * effect body would need a setState there. The store below is the third way:
 * a snapshot function React is allowed to call whenever it likes.
 * ---------------------------------------------------------------------- */

/** Same-tab cross-instance sync — the native "storage" event only fires in *other* tabs. */
const SYNC_EVENT = "zyeon:split-pane-sync"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/split-pane.json

Prompt

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 minSizes plus 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-basis and the gutters are pixels on top; letting flex shrink absorb those pixels in proportion to the bases makes the rendered ratio exactly sizes, with no calc() 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 one pointerId; 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 fresh JSON.parse per 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.

On This Page