Splitter
The handle on the edge between two panels that resizes one of them, by dragging or by the keyboard.
On this page
- GALATEA 0.4 NM
- AR MEN 1.4 NM
- BREIZH 2.9 NM
- PEN HIR 4.1 NM
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.
| Input | What it does |
|---|---|
| Drag with a mouse, a finger or a pen | Resizes by the distance moved, in the caller's unit (pixelsPerUnit). Nothing animates. |
| Arrow keys | Move the splitter by step along the line. |
| Shift with an arrow key | Moves it by largeStep. |
| Home, End | Go to the smallest and the largest size. |
| Enter | Folds the panel to collapsedValue and unfolds it, when collapsible. |
Examples
A navigation that folds to a rail
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
Saved width: 200 px
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
| Prop | Type | Default | Description |
|---|---|---|---|
| labelrequired | string | – | The splitter's name, saying which panel it resizes ("Resize the side panel"). |
| controlsrequired | string | – | The id of the panel it resizes, as aria-controls. |
| panelrequired | SplitterPanelSide | – | 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. |
| valuerequired | number | – | The panel's size, in the caller's unit; read as aria-valuenow. |
| minrequired | number | – | The smallest size the crew can drag or step to. |
| maxrequired | number | – | 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. |
| pixelsPerUnit | number | undefined | 1 | How many CSS pixels one unit of value is: 1 for pixels, the font size for em. |
| step | number | undefined | 16 | The step of an arrow key; Shift with an arrow takes largeStep. |
| largeStep | number | undefined | 64 | The step of Shift with an arrow key. |
| collapsible | boolean | undefined | false | Whether Enter collapses and expands the panel. |
| collapsed | boolean | undefined | false | Whether the panel is collapsed; the splitter then reads collapsedValue. |
| collapsedValue | number | undefined | 0 | The 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. |
| className | string | undefined | – | Classes added to the root's own. |
| testId | string | undefined | – | Becomes data-testid on the root. |
| ref | Ref<HTMLDivElement> | undefined | – | The root element. |
label="Resize the side panel"label="Drag"Related
- 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.