Resizable panes separated by draggable handles — the three-pane shell every IDE-shaped product needs.
Splitter is a compound: you place <SplitterPane> and <SplitterHandle> children yourself, interleaved in source order. That is deliberate rather than a panes-only API, because each pane holds arbitrary content and an explicit handle keeps the DOM order obvious.
Sizing is relative weights plus absolute pixel limits. size is a weight — three panes at 1 / 2 / 1 take a quarter, a half and a quarter of the space left after the handles — while min and max are pixels, because that is what content actually needs (“this sidebar stops being usable under 200px”). Because weights are the source of truth, resizing the container redistributes proportionally rather than replaying stale pixel values.
The default axis splits left/right. Drag a handle and only the two panes on either side of it move — panes further along never shift under the cursor, so a drag is a local negotiation rather than a relayout.
---
// GENERATED from packages/demos/splitter/horizontal.demo.tsx — edit the source, not this file.
import Splitter from "@pindoba/astro-splitter";
import { SplitterPane, SplitterHandle } from "@pindoba/astro-splitter";
import { css } from "@pindoba/styled-system/css";
const pane = css({ padding: "sm", fontSize: "sm" });
const frame = css({
width: "100%",
height: "12rem",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "neutral.border.muted",
borderRadius: "sm",
overflow: "hidden",
});
---
<div class={frame}>
<Splitter
panes={[
{ id: "sidebar", size: 1, min: 70 },
{ id: "main", size: 3, min: 90 },
{ id: "inspector", size: 1, min: 70 },
]}
>
<SplitterPane id="sidebar">
<div class={pane}>Sidebar</div>
</SplitterPane>
<SplitterHandle controls="sidebar" />
<SplitterPane id="main">
<div class={pane}>Canvas</div>
</SplitterPane>
<SplitterHandle controls="main" />
<SplitterPane id="inspector">
<div class={pane}>Inspector</div>
</SplitterPane>
</Splitter>
</div>orientation="vertical" splits top/bottom. Set it on the root and on each handle, so a handle rendered on its own still knows which way it moves.
---
// GENERATED from packages/demos/splitter/vertical.demo.tsx — edit the source, not this file.
import Splitter from "@pindoba/astro-splitter";
import { SplitterPane, SplitterHandle } from "@pindoba/astro-splitter";
import { css } from "@pindoba/styled-system/css";
const pane = css({ padding: "sm", fontSize: "sm" });
const frame = css({
width: "100%",
height: "14rem",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "neutral.border.muted",
borderRadius: "sm",
overflow: "hidden",
});
---
<div class={frame}>
<Splitter
orientation="vertical"
panes={[
{ id: "editor", size: 2, min: 60 },
{ id: "output", size: 1, min: 48 },
]}
>
<SplitterPane id="editor">
<div class={pane}>Editor</div>
</SplitterPane>
<SplitterHandle orientation="vertical" controls="editor" />
<SplitterPane id="output">
<div class={pane}>Output</div>
</SplitterPane>
</Splitter>
</div>Mark a pane collapsible and it snaps shut once dragged past its min by collapseThreshold px — a deliberate push, not a twitch. Enter on an adjacent handle toggles the same state from the keyboard. A collapsed pane keeps its DOM (and its scroll position); only its track goes to zero.
---
// GENERATED from packages/demos/splitter/collapsible.demo.tsx — edit the source, not this file.
import Splitter from "@pindoba/astro-splitter";
import { SplitterPane, SplitterHandle } from "@pindoba/astro-splitter";
import { css } from "@pindoba/styled-system/css";
const pane = css({ padding: "sm", fontSize: "sm" });
const frame = css({
width: "100%",
height: "10rem",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "neutral.border.muted",
borderRadius: "sm",
overflow: "hidden",
});
---
<div class={frame}>
<Splitter
panes={[
{ id: "nav", size: 1, min: 90, collapsible: true },
{ id: "body", size: 3, min: 90 },
]}
>
<SplitterPane id="nav">
<div class={pane}>Drag me shut, or press Enter on the handle</div>
</SplitterPane>
<SplitterHandle controls="nav" />
<SplitterPane id="body">
<div class={pane}>Content</div>
</SplitterPane>
</Splitter>
</div>Each handle is a role="separator" that is explicitly focusable — a bare separator is inert to assistive tech, so it is made operable on purpose — and carries a live aria-valuenow as a percentage of the split axis, plus aria-controls pointing at the pane it resizes.
| Key | Action |
|---|---|
| ← / → (horizontal) | Resize by keyboardStep px |
| ↑ / ↓ (vertical) | Resize by keyboardStep px |
| Home | Shrink the leading pane to its min |
| End | Grow it until the neighbour hits its min |
| Enter | Toggle a collapsible pane |
Only the keys along the splitter’s own axis do anything, so a vertical splitter never steals the horizontal arrows from its content. Note that aria-orientation describes the divider, which is perpendicular to the split: a left/right layout is divided by a vertical line.
Give the root a persistKey and the resolved weights are saved to localStorage and restored on the next mount. Weights (not pixels) are persisted, so a layout saved on a wide screen still makes sense on a narrow one. A corrupt or wrong-length stored value is ignored rather than allowed to break the layout.