Skip to content
design
Components

Splitter

The handle on the edge between two panels that resizes one of them, by dragging or by the keyboard.

On this page
Chart
  • GALATEA 0.4 NM
  • AR MEN 1.4 NM
  • BREIZH 2.9 NM
  • PEN HIR 4.1 NM
The chart and the AIS target list: drag the splitter, or focus it and use the arrow keys, Shift, Home and End; Enter shuts the list and opens it again.
Code
import { Splitter } from "@bosunkit/components/splitter";
import { useId, useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "The chart and the AIS target list side by side: drag the splitter, or focus it and use the arrow keys, Shift, " +
  "Home and End; Enter shuts the list and opens it again.";

const TARGETS: readonly string[] = ["GALATEA 0.4 NM", "AR MEN 1.4 NM", "BREIZH 2.9 NM", "PEN HIR 4.1 NM"];

export default function Showcase(): ReactElement {
  const listId: string = useId();
  const [width, setWidth]: [number, Dispatch<SetStateAction<number>>] = useState<number>(240);
  const [shut, setShut]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
  return (
    <div style={{ display: "flex", width: "100%", minHeight: "14em", border: "1px solid var(--border)" }}>
      <div style={{ flex: "1 1 auto", minWidth: 0, padding: "var(--space-lg)", background: "var(--surface)" }}>
        Chart
      </div>
      <Splitter
        label="Resize the target list"
        controls={listId}
        panel="end"
        value={width}
        min={180}
        max={360}
        collapsible
        collapsed={shut}
        onCollapsedChange={setShut}
        onValueChange={setWidth}
      />
      <ul
        id={listId}
        hidden={shut}
        style={{ width: `${String(width)}px`, margin: 0, padding: "var(--space-lg)", listStyle: "none" }}
      >
        {TARGETS.map((target: string): ReactElement => (
          <li key={target} style={{ fontFamily: "var(--font-mono)", padding: "var(--space-xs) 0" }}>
            {target}
          </li>
        ))}
      </ul>
    </div>
  );
}

A splitter stands on the edge between two panels and resizes the one it controls. It follows the WAI-ARIA window splitter: a focusable separator, named for the panel it resizes, pointing at it with aria-controls and reading its size as a value between a smallest and a largest. The app shell puts one on the navigation's edge and one on the side panel's.

The splitter holds no size of its own: the caller passes value and receives each new size through onValueChange, clamped to min and max, and the size at the end of a drag through onValueCommitted. panel says which side the resized panel is on, so dragging toward it shrinks it and away from it grows it; a right-to-left page reverses both. Its hit area is a touch target wide over a thin line, which widens and takes the accent while the splitter is hovered, dragged or focused.

InputWhat it does
Drag with a mouse, a finger or a penResizes by the distance moved, in the caller's unit (pixelsPerUnit). Nothing animates.
Arrow keysMove the splitter by step along the line.
Shift with an arrow keyMoves it by largeStep.
Home, EndGo to the smallest and the largest size.
EnterFolds the panel to collapsedValue and unfolds it, when collapsible.
01Reachable without a pointerEvery size a drag can reach, the keyboard reaches too, and Enter folds a panel without a drag.
02A thumb-wide target, a thin lineThe handle takes a touch target across the edge, so a finger finds it at sea, while the line it draws stays a hairline.
03BoundedA panel never shrinks below what its content needs or grows past what leaves the page room: the splitter clamps every move.

Examples

A navigation that folds to a rail

The page
A panel before the splitter, measured in em: dragging toward the end widens it, and Enter folds it to a 4.5em rail, which the splitter then reads as its value.
Code
import { Splitter } from "@bosunkit/components/splitter";
import { useId, useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A panel before the splitter, measured in em: dragging toward the end widens it, and Enter folds it to a 4.5em " +
  "rail, which the splitter then reads as its value.";

export default function NavigationRail(): ReactElement {
  const panelId: string = useId();
  const [width, setWidth]: [number, Dispatch<SetStateAction<number>>] = useState<number>(14);
  const [folded, setFolded]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
  return (
    <div style={{ display: "flex", width: "100%", minHeight: "10em", border: "1px solid var(--border)" }}>
      <nav
        id={panelId}
        aria-label="Pages"
        style={{ width: `${String(folded ? 4.5 : width)}em`, padding: "var(--space-sm)", background: "var(--surface)" }}
      >
        {folded ? "≡" : "Dashboard, Chart, Anchor"}
      </nav>
      <Splitter
        label="Resize the navigation"
        controls={panelId}
        panel="start"
        value={width}
        min={11}
        max={24}
        step={1}
        largeStep={4}
        pixelsPerUnit={16}
        collapsible
        collapsed={folded}
        collapsedValue={4.5}
        onCollapsedChange={setFolded}
        onValueChange={setWidth}
      />
      <div style={{ flex: "1 1 auto", padding: "var(--space-lg)" }}>The page</div>
    </div>
  );
}

Fixed bounds, saved at the end of a drag

Readings
Chart

Saved width: 200 px

A splitter that cannot fold its panel: it moves only between 160 and 320 px, and reports the width once a drag ends, for a caller that saves it then.
Code
import { Splitter } from "@bosunkit/components/splitter";
import { useId, useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A splitter that cannot fold its panel: it moves only between 160 and 320 px, and reports the width once a drag " +
  "ends, for a caller that saves it then.";

export default function FixedBounds(): ReactElement {
  const panelId: string = useId();
  const [width, setWidth]: [number, Dispatch<SetStateAction<number>>] = useState<number>(200);
  const [saved, setSaved]: [number, Dispatch<SetStateAction<number>>] = useState<number>(200);
  return (
    <div style={{ display: "grid", gap: "var(--space-sm)", width: "100%" }}>
      <div style={{ display: "flex", minHeight: "8em", border: "1px solid var(--border)" }}>
        <div
          id={panelId}
          style={{ width: `${String(width)}px`, padding: "var(--space-sm)", background: "var(--surface)" }}
        >
          Readings
        </div>
        <Splitter
          label="Resize the readings"
          controls={panelId}
          panel="start"
          value={width}
          min={160}
          max={320}
          onValueChange={setWidth}
          onValueCommitted={setSaved}
        />
        <div style={{ flex: "1 1 auto", padding: "var(--space-sm)" }}>Chart</div>
      </div>
      <p style={{ margin: 0, color: "var(--text-dim)" }}>Saved width: {saved} px</p>
    </div>
  );
}

Props

PropTypeDefaultDescription
labelrequiredstring–The splitter's name, saying which panel it resizes ("Resize the side panel").
controlsrequiredstring–The id of the panel it resizes, as aria-controls.
panelrequiredSplitterPanelSide–Where that panel sits: start (before the splitter, a navigation column) grows as the splitter moves toward the end of the line; end (after it, a side panel) grows as it moves toward the start.
valuerequirednumber–The panel's size, in the caller's unit; read as aria-valuenow.
minrequirednumber–The smallest size the crew can drag or step to.
maxrequirednumber–The largest size.
onValueChangerequired(value: number) => void–Called with a new size, clamped to min and max, as the crew drag or press a key.
onValueCommitted((value: number) => void) | undefined–Called with the size when a drag ends, for a caller that stores it then rather than on every move.
pixelsPerUnitnumber | undefined1How many CSS pixels one unit of value is: 1 for pixels, the font size for em.
stepnumber | undefined16The step of an arrow key; Shift with an arrow takes largeStep.
largeStepnumber | undefined64The step of Shift with an arrow key.
collapsibleboolean | undefinedfalseWhether Enter collapses and expands the panel.
collapsedboolean | undefinedfalseWhether the panel is collapsed; the splitter then reads collapsedValue.
collapsedValuenumber | undefined0The panel's size while collapsed (an icon rail's width, or 0 for a closed panel).
onCollapsedChange((collapsed: boolean) => void) | undefined–Called with the new state when Enter, a growing arrow key or a drag collapses or expands the panel.
classNamestring | undefined–Classes added to the root's own.
testIdstring | undefined–Becomes data-testid on the root.
refRef<HTMLDivElement> | undefined–The root element.
Do
label="Resize the side panel"
Name the splitter for the panel it resizes, so a screen reader says what moves.
Don't
label="Drag"
Don't name it for the gesture. A splitter called Drag says nothing about which panel changes.
  • App shell: the splitters on the navigation's and the side panel's edges.
  • Accessibility: target sizes and the keyboard path.
  • Motion: why a drag never animates and a fold does.