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
PropTypeDefaultDescriptionPlatforms
label requiredstring—

What the divider resizes ("Sidebar width", "Preview height"). The separator's accessible name.

Accessibility: aria-label on the separator.

web, lit, rn, swiftui
orientationhorizontal | verticalhorizontal

`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.

web, lit, rn, swiftui
primary requiredcontent—

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.

web, lit, rn, swiftui
secondary requiredcontent—

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.

web, lit, rn, swiftui
sizenumber—

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.

web, lit, rn, swiftui
defaultSizenumber30

Initial primary size, percent.

web, lit, rn, swiftui
minSizenumber10

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.

web, lit, rn, swiftui
maxSizenumber90

Largest primary size, percent.

web, lit, rn, swiftui
stepnumber2

Arrow-key increment, percent.

web, lit, rn, swiftui
collapsiblebooleanfalse

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.

web, lit, rn, swiftui
collapsedboolean—

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.

web, lit, rn, swiftui
defaultCollapsedbooleanfalse

Initial collapsed state when uncontrolled.

web, lit, rn, swiftui
persistKeystring—

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.

web, lit, rn, swiftui
stackBelowprose | content | neverprose

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.

web, lit, rn, swiftui

Events

3 rows
EventTypeDescriptionPlatforms
onSizeChangeonSizeChange / 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.

web, lit, rn, swiftui
onSizeChangeEndonSizeChangeEnd / 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.

web, lit, rn, swiftui
onCollapseChangeonCollapseChange / collapse-change

Fired when the primary pane collapses or restores.

web, lit, rn, swiftui

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

containerprimaryPanesecondaryPaneseparatorhandlecollapseButton

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.

Splitter keyboard support

6 rows
KeyAction
TabThe separator is a tab stop between the two panes' content.
ArrowRight or ArrowDownGrows the primary pane by `step` (ArrowDown when vertical; ArrowRight when horizontal). In RTL a horizontal splitter swaps ArrowLeft and ArrowRight, so the separator moves the way the arrow points, as dragging does.
ArrowLeft or ArrowUpShrinks the primary pane by `step`.
HomeSets the primary pane to `minSize`.
EndSets the primary pane to `maxSize`.
EnterCollapses the primary pane, or restores it to its previous size. While collapsed, arrows, Home, End and pointer drag do nothing; only Enter or the collapse button restores. Those keys are still consumed (preventDefault) so a focused separator does not scroll the page instead — the exception is Shift+F6, which the splitter leaves to the browser entirely. (collapsible)

Contrast pairs

2 rows
ForegroundBackgroundLevel
color.border.strongcolor.backgroundAA (non-text)
color.control.selectedBackgroundcolor.backgroundAA (non-text)

Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.