Splitters
Style splitters as hairlines or wide bars, give them a bigger grab area, resize with the keyboard, and see the commands a drag runs.
Dockable.Row inserts a Dockable.Splitter between every two children. The default splitter is
correct (pointer drag, keyboard, ARIA) but invisible until you style it: it renders an empty
div with no size.
The one gotcha: orientation
A splitter's data-orientation (and aria-orientation) is the orientation of the bar, the
ARIA separator convention:
| splitter between | data-orientation | it is | size it with |
|---|---|---|---|
| side by side children (a horizontal row) | "vertical" | a vertical bar | width |
| stacked children (a vertical row) | "horizontal" | a horizontal bar | height |
That is the opposite of its row's data-orientation. Style splitters by their own attribute.
Wide splitters
A visible bar, as thick as its grab area:
const splitterClassName = [
"shrink-0 bg-palette-line outline-none hover:bg-palette-ring focus-visible:bg-palette-ring data-dragging:bg-palette-ring",
"data-[orientation=vertical]:w-1.5 data-[orientation=vertical]:cursor-ew-resize",
"data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:cursor-ns-resize",
].join(" ");
function renderSplitter(props: RowSplitterProps) {
return <Dockable.Splitter {...props} aria-label="Resize" className={splitterClassName} />;
}A handle inside the splitter
A splitter renders its children, so a handle is an element inside it. It reads the splitter's
state from the splitter: a group class on the splitter, then group-data-… classes on the
handle follow its orientation, hover and drag.
<Dockable.Splitter {...props} aria-label="Resize" className="group/splitter …">
<span aria-hidden="true" className="flex gap-1 group-data-[orientation=vertical]/splitter:flex-col">
<span className="size-1 rounded-full bg-palette-line group-data-dragging/splitter:bg-palette-ring" />
<span className="size-1 rounded-full bg-palette-line group-data-dragging/splitter:bg-palette-ring" />
<span className="size-1 rounded-full bg-palette-line group-data-dragging/splitter:bg-palette-ring" />
</span>
</Dockable.Splitter>The handle may be bigger than the splitter. The engine measures the splitter element itself, so
a thin bar keeps the panes close together while a framed pill on it overflows to both sides. Give
the splitter relative z-10 so the overflow paints above the panels next to it:
Hairline splitters with a wide grab area
A 1px line is hard to grab. Keep the element 1px (the engine measures it, so the split math is
exact) and put the grab area in a centered ::after, VS Code style:
const splitterClassName = [
"relative z-10 shrink-0 bg-palette-line outline-none",
"after:absolute after:bg-transparent",
"data-dragging:after:bg-palette-ring focus-visible:after:bg-palette-ring",
// between side by side children: 1px wide, a 6px grab area
"data-[orientation=vertical]:w-px data-[orientation=vertical]:cursor-ew-resize",
"data-[orientation=vertical]:after:inset-y-0 data-[orientation=vertical]:after:start-1/2",
"data-[orientation=vertical]:after:w-1.5 data-[orientation=vertical]:after:-translate-x-1/2",
"rtl:data-[orientation=vertical]:after:translate-x-1/2",
// between stacked children
"data-[orientation=horizontal]:h-px data-[orientation=horizontal]:cursor-ns-resize",
"data-[orientation=horizontal]:after:inset-x-0 data-[orientation=horizontal]:after:top-1/2",
"data-[orientation=horizontal]:after:h-1.5 data-[orientation=horizontal]:after:-translate-y-1/2",
].join(" ");relative z-10 keeps the grab area above the tabsets and panels it overlaps (panels are
absolutely positioned in the root and would otherwise cover it). This is gap 9 in
Limitations: the pattern works; a SplitterHandle part is not scheduled.
Style every splitter
renderSplitter is a prop of each Row. Pass it to the root row, to every nested row in your
recursion, and to the row inside Dockable.Popout (gap 6):
import type { RowNode, TabsetNode } from "@fragiola/dockable-react";
function renderNode(child: TabsetNode<Types> | RowNode<Types>): ReactNode {
if (child.type === "tabset") return <MyTabSet node={child} />;
return (
<Dockable.Row node={child} renderSplitter={renderSplitter}>
{renderNode}
</Dockable.Row>
);
}Or skip renderSplitter and style the default splitter with a stylesheet:
[role="separator"][data-orientation="vertical"] { width: 4px; } reaches every one. The default
splitter has no name, though: to give it one (an aria-label), you still need renderSplitter.
Live or preview resizing
A splitter changes the layout with a command, like everything else: row.resize with the row's
new weights (one per child), or border.resize with a border's new size in px. Both go
through the model's middleware and reach model.subscribe.
By default a drag resizes live: the command runs on every pointer move, marked
transient ({ transient: true }), and once more without the mark on release. The engine
applies a transient row.resize by writing flex-grow directly, without a React render, so a
drag stays cheap. A listener sees event.transient: an undo history merges a gesture's transient
steps and its final command into one step, and a save-on-change can wait for the non-transient
one:
model.subscribe((event) => {
if (event.transient) return; // a splitter is still moving
save(model.get("layout-json"));
});realtimeResize={false} on Dockable.Root previews instead: the splitter moves with a
structural transform while data-dragging is set, and a single, non-transient command runs on
release.
<Dockable.Root model={model} realtimeResize={false}>Style the preview through data-dragging (for example a stronger color while it moves).
The same commands resize from code. Weights are relative, so [1, 2] gives the second child
twice the first's share, and a border's size is clamped to its minSize and maxSize:
model.run("row.resize", { rowId: "main-row", weights: [30, 70] });
model.run("border.resize", { borderId: "border_start", size: 280 });row.resize needs one weight per child (invalid_payload otherwise); the root row's id is
model.get("root-row")?.id.
Keyboard
A splitter is focusable (tabIndex={0}). The arrow keys along its axis move it by 10px:
Left/Right for a vertical bar (the way they point, in RTL too), Up/Down for a
horizontal one; each press runs one row.resize (or border.resize). Its ARIA value is the
position of its start child's end within the row (0–100, as measured, to a tenth) with an aria-valuetext like "40.5%", current
after each press. It has no name of its own: give it
an aria-label in renderSplitter (see Accessible names).
Disabling resizing
<Dockable.Row splitter={false}> renders no splitters for that row. While a tabset is
maximized, every row splitter is hidden (display: none, structural).
Reference
Dockable.Splitter, useSplitter,
Dockable.Row, and the row.resize and border.resize entries of
Commands.