Documentation
Getting started

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:

types.ts
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.

layout.ts
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.

render-node.tsx
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:

tab-content.tsx
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

app.tsx
"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:

layout.css
.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.

Hello layout
Gallery

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.move command, through any middleware you add with model.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+Delete closes 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.