Build your own layout
Compose the Layout and Tabset primitives into a custom layout from the same parts DashfooLayout is built from, with your own chrome, labels, and toolbars.
DashfooLayout is a thin assembly of exported primitives. When its chrome
doesn't fit (custom tab labels, a different toolbar order, extra controls in
the strip), compose the same parts yourself. The parts keep selection,
close-with-focus-restore, inline rename, keyboard navigation, overflow,
maximize, and drag-dock outside DashfooLayout.
The three layers
A hand-built layout wires three things:
- A store.
useDashfooStore({ defaultModel })owns the model, dispatch, and undo/redo, the same stateDashfooLayoutuses internally. - The
Layoutframe.Layout.Roottakesmodel,dispatch, andrenderers, and provides the layout store every part below reads through selectors.Layout.DragLayeropts the tree into drag-dock (omit it and the layout still works without dragging).Layout.Rowsrenders the split tree on the resize adapter. - A tabset composition. Pass
renderTabsettoLayout.Rowsto substitute your own arrangement ofTabset.*parts at every leaf, or useLayout.Tabset, the stock composition, where custom chrome isn't needed.
import { Layout, Tabset, useDashfooStore } from "@dashfoo/react";
const App = () => {
const store = useDashfooStore({ defaultModel });
return (
<Layout.Root dispatch={store.dispatch} model={store.model} renderers={{ tab: renderTab }}>
<Layout.DragLayer>
<Layout.Rows node={store.model.layout} renderTabset={(node) => <MyTabset node={node} />} />
</Layout.DragLayer>
</Layout.Root>
);
};Composing a tabset
Tabset.Root creates a per-tabset store; the parts inside coordinate through
it, so they can be reordered, wrapped, or omitted without changing behavior:
const MyTabset = ({ node }: { node: TabsetNode }) => (
<Tabset.Root node={node}>
<Tabset.TabStrip>
<Tabset.Tablist>
{node.children.map((tab) => (
<Tabset.Tab key={tab.id} tab={tab}>
<Tabset.Trigger>
<MyLabel />
</Tabset.Trigger>
<Tabset.RenameInput />
<Tabset.CloseButton />
</Tabset.Tab>
))}
</Tabset.Tablist>
<Tabset.Toolbar>
<TabCounter />
<Tabset.OverflowMenu />
<Tabset.MaximizeButton />
<Tabset.Grip />
</Tabset.Toolbar>
</Tabset.TabStrip>
<Tabset.Content />
</Tabset.Root>
);Custom parts read the same stores the built-in parts do:
import { useTab, useTabset } from "@dashfoo/react";
// A label that knows which tab it is and whether it's selected.
const MyLabel = () => {
const { index, tab } = useTab();
const selected = useTabset((state) => state.visualSelected === index);
return <span className={selected ? "font-bold" : undefined}>{tab.name}</span>;
};
// A toolbar add-on the stock composition doesn't have.
const TabCounter = () => {
const count = useTabset((state) => state.node.children.length);
return <span>{count} tabs</span>;
};The hooks are selector-based: a part re-renders only when its selected slice changes, so a drag pulse or a rename in one tab doesn't wake the rest of the strip.
What each part owns
| Part | Owns |
|---|---|
Tabset.Root | The per-tabset store, drop registration, overflow measurement, the drag-out selection preview, and focus restore after a close. |
Tabset.Tablist | The scrolling viewport and the role="tablist" marker that owns the triggers by id. |
Tabset.Tab | Tab identity for the parts inside, and focus return after a rename. |
Tabset.Trigger | The role="tab" button: ids and aria wiring, the roving-tabindex keyboard model, select on click, rename on double-click, tab dragging. Children override the label. |
Tabset.RenameInput | The inline editor (Enter commits, Escape cancels, blur commits once); renders only while its tab is being renamed. |
Tabset.CloseButton | Close without selecting; hides while renaming or when the tab isn't closable. |
Tabset.Content | The role="tabpanel" pane(s), keepMounted included. A children render-prop overrides the layout's renderTab for this tabset. |
Tabset.OverflowMenu / Grip / MaximizeButton | Self-hide when their feature is off, so compositions never need the visibility conditions by hand. |
A11y wiring (aria-controls, aria-labelledby, aria-selected, roving
tabIndex) flows through the stores, so it stays correct in any part order.
The tablist marker owns the triggers through aria-owns, so Tabset.CloseButton
and Tabset.RenameInput stay siblings in the accessibility tree instead of
invalid tablist children. Every part forwards its ref to the element it renders.
Maximize
In a hand-built layout, maximize is the host's branch. DashfooLayout uses the
same branch internally:
import { findTabset } from "@dashfoo/core";
const maximized = store.model.maximizedTabsetId
? findTabset(store.model, store.model.maximizedTabsetId)
: undefined;
{maximized ? <MyTabset node={maximized} /> : <Layout.Rows node={store.model.layout} … />}Responsive
DashfooLayout's responsive prop isn't available here, but the
mechanism behind it is two exported pieces. Measure the container with
useContainerWidth, render a stackModel projection below the breakpoint, and
pass the lock flags to Layout.Root. The projection is view-only: dispatch
still targets store.model, so nothing maps back and widening up is lossless.
The store, history, and mounted panels survive the breakpoint cross.
import { stackModel } from "@dashfoo/core";
import { Layout, useContainerWidth, useDashfooStore } from "@dashfoo/react";
import { useMemo, useRef } from "react";
const App = () => {
const store = useDashfooStore({ defaultModel });
const [containerRef, width] = useContainerWidth();
const isCompact = width <= 720;
const view = useMemo(
() => (isCompact ? stackModel(store.model) : store.model),
[isCompact, store.model],
);
// View-only: drop the adjustSplit rrp auto-fires while it re-measures the
// stacked structure, so it never writes to the canonical model. The ref
// (written during render so it is current before rrp's onLayoutChanged) gates
// even the closure rrp captured before the swap.
const isCompactRef = useRef(isCompact);
isCompactRef.current = isCompact;
const dispatch = (action) =>
isCompactRef.current && action.type === "adjustSplit" ? undefined : store.dispatch(action);
return (
<Layout.Root
dispatch={dispatch}
draggableTabs={!isCompact}
draggableTabsets={!isCompact}
model={view}
renderers={{ tab: renderTab }}
resizableSplits={!isCompact}
rootRef={containerRef}
>
<Layout.DragLayer>
<Layout.Rows node={view.layout} renderTabset={(node) => <MyTabset node={node} />} />
</Layout.DragLayer>
</Layout.Root>
);
};rootRef is forwarded onto the layout's root element so useContainerWidth can
measure it. Maximize and the projection compose: branch on view, not
store.model, so a maximized tabset resolves against the stacked tree too.
Guard rails
Misuse is never silent. A part rendered outside its provider throws
(Tabset.Trigger outside Tabset.Tab, any tabset part outside Tabset.Root).
Soft mistakes (two Tablists under one Root, a Tab whose node isn't in
the tabset, double-click rename with no RenameInput composed) log a
[dashfoo] warning and keep rendering.
Working reference
The demo's Raw primitives page (apps/demo-vite/src/pages/raw.tsx) builds
a complete layout this way (custom labels, a tab counter, reordered toolbar,
undo/redo buttons wired to the store) with no DashfooLayout anywhere, and
its Playwright suite drags, renames, and maximizes against it.