Skip to content

Split Views

The two-pane sidebar/content <splitview> (see Windows & Chrome) extends to a third pane, list, for the folders/list/content shape Notes, Mail, and Files share: a source list on the left, a filtered list of items in the middle, a detail view on the right.

The notes example’s three-pane splitview on macOS (AppKit)

The notes example’s three-pane splitview on GNOME (GTK)

Panes are still distinguished by the slot attached prop, now with a third value:

<splitview sidebarWidth={0.25} listWidth={0.3}>
<toolbarview slot="sidebar">
<headerbar title="Folders" />
{/* folder rows */}
</toolbarview>
<toolbarview slot="list">
<headerbar title="Notes" />
{/* list rows */}
</toolbarview>
<toolbarview slot="content">
<headerbar title="Editor" />
{/* detail pane */}
</toolbarview>
</splitview>

(Adapted from examples/notes/threepane-probe.tsx, the headless acceptance fixture.) The list slot is additive: a <splitview> with only sidebar and content children behaves as before.

sidebarWidth and the new listWidth are both create-only fractions setting each split’s initial proportion; collapsed remains live-updatable. See the Widget Reference for the full prop table.

breakpoint (int, pixels, create-only, 0 means off) collapses the sidebar automatically once the split’s own width drops below it, independent of collapsed, then restores whatever collapsed last said once the width climbs back past it: GTK via an AdwBreakpoint on the split’s max-width condition, AppKit via NDSplitViewController.applyBreakpoint, which adds an 8pt hysteresis band around the threshold so a divider drag or a live resize can’t toggle the sidebar on every pixel at the boundary.

macOS: NSSplitViewController with three items

Section titled “macOS: NSSplitViewController with three items”

Each pane becomes an NSSplitViewItem on the same NSSplitViewController (see Windows & Chrome for why that API is what gives the sidebar its Liquid Glass material):

  • sidebarNSSplitViewItem(sidebarWithViewController:), the glass-treated source list.
  • listNSSplitViewItem(contentListWithViewController:), a contentList column.
  • content → a default split item, the detail pane.

The unified NSToolbar splits per divider: with three panes there are two NSTrackingSeparatorToolbarItems, one per divider. Each pane’s headerbar items land in their own section of the one toolbar spanning the window’s top edge, the same idiom as the two-pane case.

GTK builds this as two nested AdwOverlaySplitViews: the outer split’s sidebar is the sidebar pane, and its content is an inner split whose sidebar is the list pane and whose content is the content pane. Same nesting GNOME Files uses. Each pane keeps its own <toolbarview> and <headerbar>, so you get three independent per-pane headers rather than one shared bar.

Caveat: initial sizing is floor-dominated on macOS

Section titled “Caveat: initial sizing is floor-dominated on macOS”

sidebarWidth and listWidth set the initial split proportion, but on macOS each pane carries a hard minimumThickness: 180pt for the sidebar item, 240pt for the list (contentList) item. Those floors win over the fraction whenever the window is narrow enough that the fraction asks for less. A small listWidth such as 0.15 will not render a genuinely narrow list column at typical window widths. Measure against the 240pt floor before relying on an exact initial pixel width.

<splitview> is the app-frame shape. For user-driven tiling (terminal splits, editor panes, anything the user splits and closes at will), use @nativedesktop/panes (packages/panes/): a pure model plus a component over the existing <paned> widget. No new widget, no schema or ABI change. Each split renders as a real native GtkPaned or NSSplitView with a draggable divider.

import { PaneTree, seedPanes, usePaneTree } from "@nativedesktop/panes";
function Editor(): React.ReactNode {
const panes = usePaneTree(seedPanes([{ file: "notes.md" }]));
return (
<PaneTree
model={panes.model}
onChange={panes.setModel}
renderLeaf={({ id, data, focused, solo }) => (
<box orientation="vertical">
<label text={`${data.file}${focused ? " (focused)" : ""}`} />
<button label="Split" onClick={() => panes.split(id, "horizontal", { file: "new.md" })} />
{!solo && <button label="Close" onClick={() => panes.close(id)} />}
</box>
)}
/>
);
}

The model is a strictly binary tree (PaneLeaf | PaneSplit) with pure ops: splitPane, closePane, focusPane/focusPaneAt/focusNeighbor, setPaneRatio, updatePane, paneLeaves, samePaneShape, and migratePanes for reviving persisted state (garbage in, empty model out). Every op returns the same reference when nothing changed, which stops the native positionChanged echo after a programmatic ratio write from looping a render and persist cycle. Ratios are clamped to [0.05, 0.95] (clampPaneRatio), non-finite input becomes 0.5, and echoes at or beyond the clamp bounds are dropped as mid-layout noise, since a settled drag cannot reach them past the backends’ native minimum pane extents.

usePaneTree holds the model in state and applies every op against a ref rather than the render-time model, so an await-resuming split cannot revert a divider drag that happened in between. latest() exposes that ref for persistence. renderLeaf owns all per-pane chrome (focus ring, toolbar); PaneTree supplies focused and solo plus one expanding <box> wrapper per leaf. Splits are keyed on the split node’s id because orientation is create-only on both backends, so a structural collapse landing a different split at the same position remounts instead of mutating.

Persist the model with createStore: store.set(panes.latest()) on change, flush() when samePaneShape says the change was structural. examples/panes/ is the worked example (and the headless acceptance fixture, scripts/headless-panes.sh).

Three-pane trees expose the same getTree/click contract as any other widget nesting (see Automation Socket). Each pane’s rows are ordinary nodes at their real geometry, so an automation script can assert x-order across panes (a sidebar row sits left of a list row, which sits left of the content pane) the same way it would for the two-pane layout.