Splitter
Two panes side by side (or stacked) with a draggable, keyboard-adjustable divider between them — a file tree beside its contents, a list beside a detail, an editor beside a preview. The APG window splitter.
Basic usage
Install the package:
pnpm add @design-schema/react @design-schema/tokens Import the stylesheet once for the app, then the component:
import '@design-schema/tokens/calm-precise/css';
import '@design-schema/react/index.css';
import { Splitter } from '@design-schema/react'; Examples
Overview
Select a section from the sidebar to see its detail here.
import { Splitter, Stack, Text, Link } from '@design-schema/react';
const primaryContent = (
<Stack element="nav" gap="normal" align="start">
<Text element="p" weight="medium">
Sections
</Text>
<Link href="#" label="Overview" />
<Link href="#" label="Reports" />
<Link href="#" label="Settings" />
</Stack>
);
const secondaryContent = (
<Stack gap="normal">
<Text element="p" weight="medium">
Overview
</Text>
<Text element="p">Select a section from the sidebar to see its detail here.</Text>
</Stack>
);
<Splitter
label="Sidebar width"
primary={primaryContent}
secondary={secondaryContent}
/>Props
14 rows| Prop | Type | Default | Description |
|---|---|---|---|
| label required | string | — | What the divider resizes ("Sidebar width", "Preview height"). The separator's accessible name. Accessibility: aria-label on the separator. |
| orientation | horizontal | vertical | horizontal | `horizontal` places panes side by side (the separator is vertical); `vertical` stacks them. The splitter fills its parent (block-size 100%), so a vertical splitter needs a parent with a definite height. |
| primary required | content | — | The first pane (start or top). Its size is what the separator controls and reports. On React Native the component wraps string or number content in the package `Text` (as Disclosure does); on Lit this is the `primary` slot. |
| secondary required | content | — | The second pane, which takes the remaining space. Strings are wrapped in `Text` on React Native, as `primary`; on Lit this is the `secondary` slot. |
| size | number | — | Controlled size of the primary pane as a percentage of the container (0–100). A value outside `minSize`–`maxSize` is clamped for the layout, the value text and aria-valuenow, and fires nothing: the caller is told nothing it did not already know, and a controlled splitter only ever re-renders from its own prop. |
| defaultSize | number | 30 | Initial primary size, percent. |
| minSize | number | 10 | Smallest primary size, percent. With `collapsible`, dragging or stepping below it collapses the pane instead of clamping; otherwise it is the hard floor. An arrow step that would cross it from above clamps to `minSize` first, and the next shrink step from `minSize` collapses; Home sets `minSize` and never collapses. Without `collapsible` a shrink step at `minSize` clamps and fires nothing, and a drag collapses only when the pointer maps strictly below `minSize`, so landing exactly on it clamps. On React Native, where there are no keys, the step rules belong to the decrement accessibility action, and setMinimum never collapses as Home does not. A collapse fires only onCollapseChange, no size events. |
| maxSize | number | 90 | Largest primary size, percent. |
| step | number | 2 | Arrow-key increment, percent. |
| collapsible | boolean | false | The primary pane can collapse to nothing: drag past the minimum, press Enter on the separator, or use the collapse button. Enter again restores the last size. Without it the collapsed state is pinned false whatever `collapsed`/`defaultCollapsed` say, no collapse button is rendered, and `onCollapseChange` never fires by any route. |
| collapsed | boolean | — | Controlled collapsed state. Ignored unless `collapsible`. On Lit the property is `boolean | undefined` reflected when true: a controlled `false` is set as a property, and an absent attribute means uncontrolled. |
| defaultCollapsed | boolean | false | Initial collapsed state when uncontrolled. |
| persistKey | string | — | When set, the size and collapsed state are remembered per user under this key so a sidebar stays where it was left: localStorage on web and Lit (in try/catch); on native a module-level memory map (no storage dependency is allowed), which survives remounts but not a restart. It records every change in either mode, so a controlled splitter still remembers what its parent chose; on mount the stored value seeds only an uncontrolled `size` or `collapsed` (beating `defaultSize`/`defaultCollapsed`), while a controlled prop wins over the store. The record is JSON `{size, collapsed}` under the key exactly as given, with no namespace prefix, written whenever either value settles — including once on the first render, before the user has changed anything. The native map is keyed the same way and is neither namespaced nor evicted; it is a per-process cache of a handful of splitters, not a store. |
| stackBelow | prose | content | never | prose | Below this width of the splitter's own box — its own box, not the viewport, so nested splitters work: a ResizeObserver on web and Lit, onLayout on native — a horizontal splitter stacks its panes and the separator is not rendered. The comparison is strict: it stacks while the box is narrower than the breakpoint. A stacked splitter has no separator, so it carries no separator name and no size value. A vertical splitter never stacks. While stacked both panes render in full and a collapsed primary pane keeps its collapsed state without showing it (no hiding, no collapse Button); growing back past the breakpoint applies it again. All three values apply on every platform (`prose` = layout.maxWidth.prose, `content` = layout.maxWidth.content, `never` = no stacking). The token is read as a length in px, rem or em (rem against the root font size, em against the splitter's own); a value in any other unit is unreadable and the splitter never stacks, as when the token is absent. |
Events
3 rows| Event | Type | Description |
|---|---|---|
| onSizeChange | onSizeChange / size-change | Fired continuously while dragging and on each key press that changes the size, with the primary size in percent (unrounded). It never repeats the size it last reported: a key press at a bound (End at `maxSize`, Home at `minSize`) fires nothing, the native increment/decrement/setMinimum/setMaximum actions fire nothing when the clamped target equals the current size, and a drag on past `maxSize` is just as silent — "continuously" means on every move that moves the separator, not on every event the platform emits. |
| onSizeChangeEnd | onSizeChangeEnd / size-change-end | Fired with the final size once when a drag ends and after each key press that changed the size (a key press is a complete interaction), so a caller can persist on it. A drag always fires it once on release; if the drag collapsed the pane, the rest of that gesture is ignored and it carries the last expanded size, since a collapsed pane keeps its size for restoring — that holds even when the gesture collapsed before ever changing the size, a pointer down followed straight by a move below `minSize`. A press that never moved the separator is not a drag and fires nothing, so a stray tap on the separator is silent; "moved" is a change of the clamped size, not pointer travel, so a drag held against `maxSize` is a press by this rule, and a native pan responder's move events with a zero delta never make one. A gesture the platform cancels counts as a release. |
| onCollapseChange | onCollapseChange / collapse-change | Fired when the primary pane collapses or restores. |
Accessibility
Decisions this component leaves to you
Supply an accessible name. This component has no text of its own to fall back on, so without one a screen reader announces its role and nothing else.
If you wire up the gesture events, provide the pointer and keyboard path to the same outcome.
Required props that carry accessibility meaning: label. Each is described in the props table above.
Implicit ARIA role: separator. React Native renders role adjustable.
Anatomy
What generation guarantees
Accessible name — Every instance carries a programmatic name. Where the component has no text of its own to use, the name is a prop the consumer supplies.
Keyboard operable — Everything the pointer can do here, the keyboard can do — see the keyboard section.
Arrow navigation — Arrow keys move between items; Home and End jump to the ends.
Visible focus — Keyboard focus is always drawn, from the theme’s focus tokens, and is never removed.
Contrast — Text and meaningful non-text contrast meets WCAG AA; the pairs are listed below.
Target size — Interactive targets are at least 24×24 CSS pixels (WCAG 2.2 Target Size, minimum).
Gesture alternative — Every gesture — swipe, drag, long-press — has a single-pointer and a keyboard equivalent.
Reduced motion — Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.
Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.