Documentation
Guides

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 betweendata-orientationit issize it with
side by side children (a horizontal row)"vertical"a vertical barwidth
stacked children (a vertical row)"horizontal"a horizontal barheight

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} />;
}
Wide splitter
Gallery

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>
Dotted handle
Gallery

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:

Framed handle
Gallery

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.

Line splitter
Gallery

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.