Skip to content
On this page

@inklu/dock

Updated October 2026

@inklu/dock is a headless workspace dock: a tabbed, multi-panel surface that sits docked beside your app, reserving width, or lifts into an expanded modal over it with a backdrop, focus trap and scroll lock. You register panel types once, open them through a typed handle, and style stable data-dock-* attributes with plain CSS.

Installation

$
pnpm add @inklu/dock

Requires React 19. Import the stylesheet once, alongside your own CSS:

import "@inklu/dock/styles.css";

Quick start

Call createDock once, at module scope, with every panel type the app can open. definePanel only infers the payload type; it returns its argument unchanged.

"use client";
import { createDock, definePanel } from "@inklu/dock";
import "@inklu/dock/styles.css";
interface RecordRef {
id: string;
kind: string;
}
export const dock = createDock({
record: definePanel<RecordRef>({
title: (payload) => `${payload.kind} #${payload.id}`,
component: ({ payload }) => <div>Content for {payload.id}</div>,
url: ["kind", "id"],
}),
});
function OpenTaskButton() {
// `dock.use()` is a hook — call it during render, not inside the handler.
const { open } = dock.use();
return (
<button onClick={() => open("record", { kind: "task", id: "101" })}>
Open Task 101
</button>
);
}
export function App() {
return (
<dock.Provider>
<dock.Viewport>
<main>
<h1>Workspace</h1>
<OpenTaskButton />
</main>
</dock.Viewport>
</dock.Provider>
);
}

open returns the panel id, or null when the type is unknown or the dock is full and nothing can be evicted.

AI setup

Paste this prompt into Claude Code, Cursor or any coding agent with write access to your project. It needs no other context.

Prompt
Set up a workspace dock (side panel / multi-tab surface) using the `@inklu/dock` package.

Do not invent APIs. If something here is ambiguous, read https://inklu.saddy.me/dock
before guessing.

## 1. Install

`npm install @inklu/dock`

Requires React 19.

## 2. Register your panel types

Call `createDock` once, at module scope, with every panel type the app can open.
`definePanel` exists only to infer the payload type — it returns its argument as-is.

```tsx
"use client";

import { createDock, definePanel } from "@inklu/dock";
import "@inklu/dock/styles.css";

interface RecordRef {
  id: string;
  kind: string;
}

export const dock = createDock({
  record: definePanel<RecordRef>({
    title: (payload) => `${payload.kind} #${payload.id}`,
    component: ({ payload }) => <div>Content for {payload.id}</div>,
    url: ["kind", "id"], // round-trips through the URL — string-valued keys only
  }),
});
```

`createDock` returns a self-contained object: `dock.Provider`, `dock.Viewport`,
lower-level `dock.Surface` / `dock.TabStrip`, and the `dock.use()` hook. A page can
host more than one independent dock by calling `createDock` more than once.

## 3. Mount the provider and viewport

```tsx
export function App() {
  return (
    <dock.Provider>
      <dock.Viewport>
        <main>{/* your app content */}</main>
      </dock.Viewport>
    </dock.Provider>
  );
}
```

`dock.Viewport` reserves layout width in `docked` mode and renders the dock surface
(tabs, resize handle, panel bodies) once anything is open. `dock.Provider` takes
`confirmClose`, `limit` (default 5), `onLimit`, `onOpen`/`onClose`/`onActiveChange`
callbacks, `session` (reopen every tab on reload), `url`/`widthStore` persistence
adapters, and width bounds (`defaultWidth`, `minWidth`, `maxWidth`, `expandedWidth`,
`maxExpandedWidth`) as props — all read live, so changing them takes effect
immediately.

## 4. Open, close, and update panels

`dock.use()` is a hook — call it during render, not inside a handler:

```tsx
const { open, close, closeAll, closeOthers, replace, select, update, exists, expand, collapse, setWidth } = dock.use();

open("record", { kind: "task", id: "101" }); // → panel id, or null if refused
```

- `open` returns `null` when the type isn't registered, or the dock is at its limit
  and nothing is evictable (`onLimit` fires in that case).
- Opening a payload whose identity matches an already-open panel re-selects that tab
  instead of duplicating it. Identity comes from (in order): an explicit
  `options.id` passed to `open`, a declared `key(payload)`, a fingerprint of
  `serialize`/`url`, a conventional `payload.id` field, or else a fresh id every
  call.
- `replace`, `close`, `closeAll`, and `closeOthers` are async — they consult the
  outgoing panel's `beforeClose` guard before resolving, and resolve to `false`/`null`
  if it vetoes.
- `update(panelId, patch)` shallow-merges `patch` into a panel's payload.
- `open` moves focus into the panel it opened: to the element marked
  `data-dock-autofocus` in its content, or to the panel itself. Mark a form's first
  field instead of focusing it from an effect. Pass `{ focus: false }` for opens the
  user didn't trigger (server events); URL and session restores never take focus.

## 5. Forms (dirty tracking + close guard)

`useDockForm` gives a panel draft state, dirty tracking, and a close guard in one hook:

```tsx
import { DockFormFooter, useDockForm } from "@inklu/dock";

function RecordPanel({ payload }: { payload: { id: string; title: string } }) {
  const form = useDockForm({
    initial: { title: payload.title },
    onSubmit: async (values) => saveRecord(payload.id, values),
  });

  return (
    <>
      <input value={form.values.title} onChange={(e) => form.set("title", e.target.value)} />
      <DockFormFooter form={form} />
    </>
  );
}
```

The close guard is registered only while `form.isDirty`, so a clean form does not
block tab-limit eviction. Closing (or navigating away from) a dirty panel is blocked
unless `confirmDiscard` (or the provider's `confirmClose`) approves it.

For a manual guard on a panel that isn't a form, use `useDockLifecycle({ dirty,
beforeClose })` — it must be called from inside a panel's `component`.

## 6. Reading state

Reactive state is read through separate hooks, not through `dock.use()`. Prefer
`useDockSelector` for a slice: it re-renders only when that slice changes, while
`useDockState` re-renders on every change, each step of a resize drag included:

```tsx
import { useActivePanel, useDockSelector, useDockState, useDockTabs } from "@inklu/dock";

const isOpen = useDockSelector((state) => state.panels.length > 0);

const { panels, activeId, mode, width, expandedWidth } = useDockState();
const tabs = useDockTabs(); // what <dock.TabStrip> renders
const active = useActivePanel();
```

## Behaviour to rely on, not re-implement

- `docked` mode sits beside the app and reserves width via padding; `expanded` mode
  lifts the same dock into a modal — backdrop, focus trap, Escape, scroll lock,
  background inerting. It renders inside `<dock.Viewport>`'s own stacking context
  rather than portalling to `document.body`.
- The tab strip already implements the WAI-ARIA tabs pattern: roving `tabIndex`,
  arrow-key/Home/End navigation, Delete/Backspace to close, and middle-click to close.
  Do not add your own keyboard handlers on top of it.
- The resize handle supports both pointer drag and keyboard (arrow keys, Home/End,
  double-click to reset) with live `aria-value*` attributes. It's already wired up
  inside `<dock.Surface>` — no extra markup needed.
- URL persistence is on by default (`<dock.Provider>`'s `url` prop) and only mirrors
  the *selected* panel — the rest of the open tabs are session-local. Pass `url={null}`
  to disable it, or a custom `DockUrlSync` (via `createUrlSync`) to change where it's
  stored.
- At the tab limit, the least-recently-active panel that is both clean and unguarded
  is evicted automatically. A panel with a registered `beforeClose` guard is treated
  as un-evictable, because `open` is synchronous and cannot await a guard's answer.
- Styling is entirely data-attribute driven (`data-dock-*`, plus `data-slot` for
  shadcn-style targeting) and CSS-variable driven (`--dock-*`). There is no
  `className`-per-element API — do not invent one.

## Checklist

- [ ] `createDock` called once, at module scope, with every panel type
- [ ] `<dock.Provider>` wraps `<dock.Viewport>`, which wraps the app's content
- [ ] `dock.use()` called during render, not inside a click handler
- [ ] Panels with unsaved edits use `useDockForm` (or `useDockLifecycle`'s
      `beforeClose`) instead of a raw `window.confirm` on unmount
- [ ] No manual keyboard handlers layered on the tab strip or resize handle
- [ ] No hand-rolled focus trap or scroll lock for expanded mode — it's built in
- [ ] Styling goes through `data-dock-*` attributes and `--dock-*` variables, not a
      new className API

For an agent that keeps working in the repo, install the agent skill instead. It carries the rules, not just the setup:

$
pnpm dlx skills add nkurunziza-saddy/inklu --skill inklu-dock

Agents reading the site start at /dock/llms.txt. The complete reference, including what this page leaves out, is /dock/llms-full.txt.

Panels

definePanel<TPayload>({
component, // required — receives { payload, panelId, isActive }
title, // required — string, or (payload) => string
icon, // optional — node, or (payload) => ReactNode
key, // optional — (payload) => string | undefined
url, // optional — string-valued payload keys to mirror in the URL
serialize, deserialize, // optional — escape hatch for `url` with non-string fields
});

component receives isActive, so a backgrounded panel can pause polling or media without unmounting; its DOM stays alive, keeping scroll and local state.

Opening a payload that matches an open panel selects that tab instead of adding a second one. It matches on key(payload) if declared, then on the serialized URL params, then on a payload.id field.

Provider

<dock.Provider
confirmClose={(panel) => window.confirm(`Discard "${panel.type}"?`)}
limit={5}
onLimit={({ type, limit }) => toast(`${limit}-panel limit reached, opening "${type}" refused`)}
defaultWidth={400}
minWidth={320}
maxWidth={720}
expandedWidth={768}
maxExpandedWidth={1280}
session // reopen every tab on reload (sessionStorage)
onOpen={(panel) => track("dock_open", panel.type)}
onClose={(panel) => track("dock_close", panel.type)}
onActiveChange={(panel) => setTitle(panel?.type ?? "Workspace")}
>
<dock.Viewport overlayBelow={640}>{children}</dock.Viewport>
</dock.Provider>

Every prop is read live. Defaults: limit 5, widths 400 (320–720) docked and 768 (up to 1280) expanded. At the limit, the least recently used clean, unguarded panel is evicted.

Forms and guards

useDockForm gives a panel draft state, dirty tracking and a close guard in one hook. The guard only exists while the form is dirty, and a failed onSubmit keeps the draft.

import { DockFormFooter, useDockForm } from "@inklu/dock";
function RecordPanel({ payload }: { payload: { id: string; title: string } }) {
const form = useDockForm({
initial: { title: payload.title },
onSubmit: async (values) => saveRecord(payload.id, values),
});
return (
<>
<input
data-dock-autofocus=""
value={form.values.title}
onChange={(e) => form.set("title", e.target.value)}
/>
<DockFormFooter form={form} />
</>
);
}

open moves focus into the panel it opened. Mark the field that should receive it with data-dock-autofocus: unlike autoFocus, the mark is used every time open lands on the panel, not only when it mounts. Without a mark, the panel itself is focused.

Any other panel can veto closing with useDockLifecycle. A guarded panel is never evicted at the tab limit.

import { useDockLifecycle } from "@inklu/dock";
function DraftPanel({ payload }: { payload: { id: string } }) {
const [dirty, setDirty] = useState(false);
useDockLifecycle({
dirty,
beforeClose: () => !dirty || window.confirm("Discard unsaved changes?"),
});
// ...
}

URL state

The selected panel is mirrored into the URL, so a link or a reload reopens it, and back/forward move between panels. The url shorthand takes string-valued payload keys only; for anything else, write serialize and deserialize:

definePanel<{ id: number }>({
title: (payload) => `Record ${payload.id}`,
component: RecordPanel,
serialize: (payload) => ({ id: String(payload.id) }),
deserialize: (params) => ({ id: Number(params.id) }),
});

Pass session to the provider to reopen every tab on reload, or url={null} to turn URL state off.

Styling

Every rule in styles.css is wrapped in :where(), so one class overrides it. Theme it with CSS variables:

:root {
--dock-surface-bg: var(--background, #ffffff);
--dock-surface-fg: var(--foreground, #111827);
--dock-border-color: var(--border, #e5e7eb);
--dock-primary-color: var(--primary, #2563eb);
--dock-surface-radius: var(--radius, 0.5rem);
/* Motion (defaults shown). Expanded mode uses 250ms / 150ms. */
--dock-transition-ease: cubic-bezier(0.22, 1, 0.36, 1);
--dock-enter-duration: 400ms;
--dock-exit-duration: 300ms;
--dock-resize-duration: 400ms;
}

Target parts by attribute: data-dock-surface, data-dock-tab, data-dock-panel and so on. data-dock-state moves through entering, present and exiting, so a CSS transition on it is a custom animation.

API

dock.use(): DockHandle
// open(type, payload?, options?) → string | null (options: { id?, focus? })
// replace(type, payload?, options?) → Promise<string | null>
// close(panelId) → Promise<boolean>
// closeAll() → Promise<boolean>
// closeOthers(panelId) → Promise<boolean>
// select(panelId) → void
// update(panelId, patch) → void
// exists(type, key?) → boolean
// expand() / collapse() → void
// setWidth(width) → void
// .api → untyped DockApi
useDockSelector((s) => s.panels.length) // → a slice; re-renders only when it changes
useDockState() // → { panels, activeId, mode, width, expandedWidth } — every change
useDockTabs() // → DockTab[] — what <dock.TabStrip> renders
useActivePanel() // → PanelInstance | undefined
useDockLifecycle({ dirty, beforeClose }) // called inside a panel's component

Identity rules, composing Surface and TabStrip yourself, persistence adapters and the lower-level store are in the README, also served for agents as /dock/llms-full.txt.