Your first layout
From a typed JSON layout to a resizable, draggable layout with live content, step by step.
This page builds the smallest complete layout: two tabsets side by side, a splitter between
them, tabs you can drag, and panels whose content survives every move. It ends where the
hello-layout example starts.
Declare what your tabs hold
Each tab names a component (what it shows) and carries data (its state). Declare the data type of each component once, in a registry. Everything below is typed by it:
export type Types = {
tabs: {
welcome: undefined;
notes: { text: string };
inspector: undefined;
};
};The tab's name is in its data: the model holds no text, so the strip renders what you put there
(Typed data).
Describe the layout as JSON
A layout is a tree: a row splits its space between its children, a tabset holds
tabs. weight is a relative size. A row nested in a row takes the opposite orientation, so
it acts as a column.
import type { LayoutJson } from "@fragiola/dockable-react";
import type { Types } from "./types";
export const json: LayoutJson<Types> = {
version: 1,
root: {
type: "row",
children: [
{
type: "tabset",
weight: 60,
children: [
{ component: "welcome", label: "Welcome" },
{ component: "notes", label: "Notes", data: { text: "" } },
],
},
{
type: "tabset",
weight: 40,
children: [{ component: "inspector", label: "Inspector" }],
},
],
},
};This is JSON v1: version: 1 and a root row. Nodes without an id get one when the layout
loads. A tab whose data does not match its component does not compile. Every field is listed in
the JSON model reference.
Create the model once
The model is the source of truth. Create it once (not on every render) and keep it:
const [model] = useState(() => createModel<Types>(json));The model keeps its identity for the life of the layout: loading a saved layout or undoing a step
replaces its state in place (layout.load), never the model. Recreating it on every render would
reset the layout each time.
Render the structure
You own the recursion. Dockable.Row calls its child function for every child of the row: a
tabset or a nested row. A tabset renders its tab strip (TabList › Tab) and marks its content
area (TabSetContent). Row inserts the splitters between children by itself.
import { Dockable, type RowNode, type TabsetNode } from "@fragiola/dockable-react";
import type { ReactNode } from "react";
import type { Types } from "./types";
export function renderNode(child: TabsetNode<Types> | RowNode<Types>): ReactNode {
if (child.type === "tabset") {
return (
<Dockable.TabSet node={child} className="tabset">
<Dockable.TabList<Types> aria-label="Tabs" className="tabs">
{(tab) => (
<Dockable.Tab node={tab} className="tab">
{tab.label}
</Dockable.Tab>
)}
</Dockable.TabList>
<Dockable.TabSetContent />
</Dockable.TabSet>
);
}
// a nested row: the same function renders its children
return <Dockable.Row node={child}>{renderNode}</Dockable.Row>;
}Render the panels
Tab content does not live inside the tabset. Dockable.Panels (placed once, directly under
the root) calls its child function for every tab whose content should render, and
Dockable.Panel puts the content in an element the engine positions over the tabset's content
area. That is how content survives moves: the element is re-parented, never remounted.
<Dockable.Panels<Types>>
{(tab) => (
<Dockable.Panel node={tab} className="panel">
<TabContent tab={tab} />
</Dockable.Panel>
)}
</Dockable.Panels>The parts that call a children function (Row, TabList, Panels) take your registry as a type
argument: a child cannot infer it from the root. With it, tab is a TabOf<Types>.
Pick the content from the tab's component, as a factory would. The switch narrows tab.data to
that component's data, with no cast:
import type { TabOf } from "@fragiola/dockable-react";
import type { Types } from "./types";
export function TabContent({ tab }: { tab: TabOf<Types> }) {
switch (tab.component) {
case "notes":
// tab.data is { name: string; text: string }
return <textarea aria-label={tab.label} defaultValue={tab.data.text} />;
case "welcome":
case "inspector":
return <p>{tab.label}</p>;
}
}Put it together, and give the root a size
"use client";
import { createModel, Dockable } from "@fragiola/dockable-react";
import { useState } from "react";
import { json } from "./layout";
import { renderNode } from "./render-node";
import { TabContent } from "./tab-content";
import type { Types } from "./types";
export function App() {
const [model] = useState(() => createModel<Types>(json));
return (
<Dockable.Root model={model} style={{ height: "100vh" }}>
<Dockable.Row<Types>>{renderNode}</Dockable.Row>
<Dockable.Panels<Types>>
{(tab) => (
<Dockable.Panel node={tab} className="panel">
<TabContent tab={tab} />
</Dockable.Panel>
)}
</Dockable.Panels>
<Dockable.DropIndicator className="drop-indicator" />
</Dockable.Root>
);
}The root must have a size: it has no intrinsic size, and an unsized root renders nothing visible. See Sizing the root.
Now style it
At this point the layout works (drag a tab onto the other tabset, drag the splitter, press the
arrow keys on a focused tab) but it is invisible: the package paints nothing. A few rules make
it usable. The selectors use only the data-* attributes the primitives expose:
.tabset { background: Canvas; }
.tabs { display: flex; gap: 2px; min-height: 30px; padding-inline-start: 4px; }
.tab { padding: 4px 12px; cursor: pointer; }
.tab[data-selected] { background: color-mix(in srgb, CanvasText 10%, Canvas); }
.tab[data-dragging] { opacity: 0.4; }
[role="separator"] { background: GrayText; }
[role="separator"][data-orientation="vertical"] { width: 4px; cursor: ew-resize; }
[role="separator"][data-orientation="horizontal"] { height: 4px; cursor: ns-resize; }
.panel { background: Canvas; overflow: auto; }
.drop-indicator { z-index: 10; outline: 2px solid Highlight; }The plain CSS guide explains each rule (including why the drop
indicator needs a z-index), and the Tailwind guide does the
same with utilities.
What you have
- Drag and drop: drag a tab into another tabset, onto a tabset's edge to split it, or to the
layout's edge. Each drop runs the
tab.movecommand, through any middleware you add withmodel.use. - Splitters: drag with the pointer, or focus and use the arrow keys (10px per press). They
have no name yet: Accessible names shows how to give
them one with
renderSplitter. - Keyboard: arrow keys, Home and End move between tabs; Enter or Space selects;
Ctrl+Deletecloses a closeable tab. - Content that survives: type in the Notes tab, then drag it to the other tabset. The text is still there.
Next, read the model and commands to change the layout from
code (model.run("tab.add", …)), typed data for everything the
registry types, and composition for everything you can put in the
recursion.