# @inklu/dock Headless, accessible workspace dock and multi-panel system for React. Docs: [inklu.saddy.me/dock](https://inklu.saddy.me/dock) · For agents: [llms.txt](https://inklu.saddy.me/dock/llms.txt) · Agent skill: `npx skills add nkurunziza-saddy/inklu --skill inklu-dock` - **Dual Presentation**: Sits beside the app (`docked`) reserving width or expands over it (`expanded`) as a full modal with backdrop, focus trap, background inerting, and scroll lock. - **Tabbed Navigation**: WAI-ARIA tabs pattern, roving `tabIndex`, keyboard navigation, auto-condensing tabs, wheel scroll redirection, and delete/middle-click close. - **State & Lifecycles**: Tab limit with clean LRU eviction, dirty tracking, veto callbacks (`beforeClose`), and active tab management. - **Forms**: `useDockForm` layers draft state, dirty tracking, and a close guard onto a panel's payload, so leaving a panel with unsaved edits requires confirmation for free. - **Dynamic Resize**: Symmetric pointer drag and keyboard step adjustment with live ARIA values. - **Persistence**: SSR-safe URL synchronization (History API / query params) and width memory (`localStorage`). - **Headless & Decoupled**: Zero design-system dependencies. Fully customizable via standard data attributes (`data-dock-*`) and CSS variables (`--dock-*`). Requires React 19. ## Installation ```bash pnpm add @inklu/dock # or npm install @inklu/dock ``` ## Quick Start ```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({ title: (payload) => `${payload.kind} #${payload.id}`, component: ({ payload }) =>
Content for {payload.id}
, url: ["kind", "id"], }), }); function OpenTaskButton() { // `dock.use()` is a hook — call it during render, not inside the handler. const { open } = dock.use(); return ; } export function App() { return (

Workspace

); } ``` `createDock` returns a self-contained `dock` object: `dock.Provider`, `dock.Viewport`, lower-level `dock.Surface` / `dock.TabStrip`, the `dock.use()` hook, and `dock.definitions` (the normalized panel registry, rarely needed directly). Everything is scoped to that one `createDock` call, so a page can host more than one independent dock by calling it more than once — see [Several docks on one page](#several-docks-on-one-page). `open` returns the panel id, or `null` when the type is not registered or the dock is at its limit with nothing evictable. `replace` is async for the same reason `close` is — it consults the outgoing panel's `beforeClose` guard before swapping. ## Defining panels `definePanel(spec)` is an identity function — it exists purely so the payload type can be inferred at the call site instead of annotated twice. Each field on `spec`: | Field | Required | Purpose | | --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- | | `component` | yes | `React.ComponentType<{ payload, panelId, isActive }>` rendered as the panel's body. | | `title` | yes | Tab label — a string, or `(payload) => string` for a derived title. | | `icon` | no | Tab glyph — a node, or `(payload) => ReactNode`. | | `key` | no | `(payload) => string \| undefined`. See [Panel identity](#panel-identity-and-deduplication) below. | | `url` | no | Payload keys to round-trip through the URL. String-valued keys only — see [URL persistence](#url-persistence). | | `serialize` / `deserialize` | no | Escape hatch for `url` when a payload has non-string fields. | `component` receives `isActive`, so a panel can pause expensive work (polling, media) while backgrounded without unmounting — the DOM node stays alive to preserve scroll position and any local state. ## Panel identity and deduplication Every open panel gets an id. `open`'s dedup behavior depends on how that id is derived, checked in this order: 1. **`options.id`** — an explicit id you pass to `open`. Once set this way, the panel is "pinned": later `update` calls never re-derive its id even if `key` would produce a different one. 2. **`key(payload)`** — if you declare `key`, its return value (when not `undefined`) becomes the id as `` `${type}:${key}` ``. Opening a payload that produces a key matching an already-open panel re-selects that tab instead of duplicating it. 3. **A derived `serialize`/`url` key** — with no `key` but a `serialize` (or the `url` shorthand, which implies one), the key defaults to a fingerprint of the serialized params, so two payloads that serialize to the same URL state count as the same panel. 4. **A conventional `id` field** — with none of the above, a payload's own `payload.id` is used the same way, if present. 5. **Otherwise** — a fresh sequential id, `` `${type}#${n}` ``. Every `open` call opens a new tab. `DockApi.exists(type, key?)` checks the same identity: `exists("record")` for "is any record panel open," `exists("record", "101")` for "is _this_ record open." A panel opened with an explicit `options.id` still matches on the key its payload derives. `update(panelId, patch)` shallow-merges `patch` into a panel's payload. If the panel's key is derived from a patched field, its id changes to match — keeping `exists` and open-deduplication accurate — unless the panel was pinned via `options.id`. ## Panel API `dock.use()` is a hook that returns a `DockHandle` scoped to your panel map — `open` and `replace` are typed to the registered panel types and their payloads, `update` narrows `patch` to `Partial`. It also exposes the untyped `.api` (`DockApi`) if you need to pass the dock down to code that shouldn't know your panel map. | Method | Signature | Behavior | | --------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `open` | `(type, payload?, options?) => string \| null` | Opens or re-selects a panel. `null` when `type` isn't registered, or the dock is at its limit and nothing is evictable (`onLimit` fires). | | `replace` | `(type, payload?, options?) => Promise` | Closes the active panel and opens a new one in its place. `null` if `type` isn't registered (nothing closes) or the outgoing guard vetoes. | | `close` | `(panelId) => Promise` | Closes one panel. `false` if its guard vetoes. | | `closeAll` | `() => Promise` | Closes every open panel. `false` (and none close) if any guard vetoes. | | `closeOthers` | `(panelId) => Promise` | Closes every panel except `panelId`. | | `select` | `(panelId) => void` | Activates a panel without opening or closing anything. | | `update` | `(panelId, patch) => void` | Shallow-merges `patch` into the panel's payload. | | `exists` | `(type, key?) => boolean` | See [Panel identity](#panel-identity-and-deduplication). | | `expand` / `collapse` | `() => void` | Switches `DockMode` between `expanded` and `docked`. | | `setWidth` | `(width) => void` | Sets the width for the current mode, clamped to its configured bounds. | A panel id carries no type information, so `update` cannot infer which payload `patch` belongs to: by default it accepts a partial of any registered payload. Name the type to check it against one — `update<"record">(id, { kind: "order" })`. `options?: { id?: string; focus?: boolean }` on `open`/`replace`: `id` pins the panel's id explicitly — see [Panel identity](#panel-identity-and-deduplication). `focus` is covered next. ### Focus Every `open` and `replace` moves focus into the panel it opened, whether that's the first panel (which also mounts the dock) or a later one, or an already-open panel it re-selects. Mark the element that should receive it with `data-dock-autofocus`: ```tsx ``` Unlike React's `autoFocus`, which fires once when the element mounts, the mark is used every time `open` lands on the panel, and once a lazy panel's content has loaded. With no mark, or a mark on something that can't take focus (disabled, hidden), the panel itself is focused. Content that focuses itself on mount (an `autoFocus` field) keeps that focus. Pass `{ focus: false }` for opens the user didn't ask for, like a panel opened from a server event. Restores from the URL or a saved session never take focus. Selecting a tab from the strip keeps focus on the tab. Closing never drops focus to the top of the page. When a panel closes with focus inside it, the panel that takes its place takes the focus too. Closing a tab from the strip, with Delete or its close button, moves to the neighbouring tab. Closing the last panel returns focus to whatever opened the dock as the close starts, and the surface is `inert` while its exit plays. ### Keyboard | Where | Keys | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | Tab strip | Arrow keys select the previous / next tab (following reading direction), Home / End the first / last, Delete or Backspace closes the focused tab. | | Resize handle | Arrow keys step the width by 16px, Home / End jump to the minimum / maximum, Enter returns to the default width (as a double click does). | | Expanded mode | Tab stays inside the dock, Escape collapses it back to `docked`. | Reactive state (`useDockState`, `useDockTabs`, `useActivePanel`) is read separately from the API described above — see [Hooks](#hooks). ## Guards and the tab limit A panel registers a guard with `useDockLifecycle({ dirty, beforeClose })`. - `beforeClose` is consulted by `close`, `closeAll`, `closeOthers`, and `replace`, whether or not the panel is dirty. - Registering a guard also makes the panel **un-evictable** at the tab limit. `open` is synchronous and cannot await an answer, so it declines to evict anything that asked to be consulted. `useDockForm` registers its guard only while dirty, for this reason. - When nothing can be evicted, `open` returns `null` and `onLimit` fires with `{ type, limit, allDirty: true }`. - The limit itself evicts by least-recently-active first among panels that are both clean and unguarded — the active panel is never evicted. - A panel marked `dirty` but with no `beforeClose` (and no provider-level `confirmClose`, below) still closes — a console warning fires in development so the gap doesn't go unnoticed silently in production. ## Forms `useDockForm` gives a panel draft state, dirty tracking, and a close guard in one hook — the pattern `useDockLifecycle` was built for, wired up: ```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 ( <> form.set("title", e.target.value)} /> ); } ``` - Edited fields live in a draft overlay on top of `initial`; `form.values` is the merge, `form.changed` lists the fields that differ from `initial`, and `form.isDirty` is `changed.length > 0`. A field typed back to its starting value is no longer a change, so the form reads as clean again. - The dock lifecycle guard is registered **only while dirty**, so a clean form doesn't block LRU eviction at the tab limit. - `confirmDiscard` is consulted by the guard when the panel would close with unsaved changes — return `false` to keep it open. Omit it to allow silent discard. - `submit` calls `onSubmit(values)`, clears the draft on success, and — unless `closeOnSubmit: false` — closes the panel. A thrown or rejected `onSubmit` is caught: the draft is kept (it's the user's only copy of the edit), `form.error` is set, and `onError` fires. - `form.cancel` closes the panel through the same guarded path as any other close. `DockFormFooter` renders a status string (`"3 unsaved changes"` / `"No changes yet"`, both overridable via `labels`) plus Cancel/Save buttons wired to `form.cancel` / `form.submit`. Supply `renderSave` / `renderCancel` to swap in your own button components while keeping the wiring; `children` inserts extra actions between the status and the built-in buttons. ## URL persistence The `url` shorthand round-trips the listed payload keys through query params, so it accepts **string-valued keys only** — a `number` would come back as a string and quietly break the payload's declared type. For anything richer, supply `serialize` and `deserialize`: ```tsx definePanel<{ id: number }>({ title: (payload) => `Record ${payload.id}`, component: RecordPanel, serialize: (payload) => ({ id: String(payload.id) }), deserialize: (params) => ({ id: Number(params.id) }), }); ``` Only the selected panel is mirrored into the URL — the rest of the open tabs are session-local. On mount, `` reads the URL once and `restore`s that panel before anything else renders into the address bar; the restore is idempotent for keyed panels, so a React Strict Mode double-invoke or a remount re-selects the existing panel rather than opening a duplicate. Back/forward navigation is also handled: a `popstate` that changes the persisted panel closes the current one (subject to its guard — a vetoed navigation is corrected by writing the dock's actual state back to the URL) and restores the new one. To bring back every open tab on reload, not just the selected one, turn on the provider's `session` prop: ```tsx … ``` This saves the open tabs to `sessionStorage` (per browser tab) as they change and reopens them in order on mount, with the same tab selected. If the URL names a panel, that panel is selected instead. Only panels that can serialize (via `url` or `serialize`) are saved. Pass your own `DockSessionStore` in place of `true` to store the set elsewhere; see [Persistence adapters](#persistence-adapters). Routers push their own history entries, which don't carry the dock's params. Where the browser supports the Navigation API, the default adapter adds the params back into such an entry (with `replaceState`) while the dock stays open. Without that, going forward to the entry would read as "no panel" and close the dock. If the dock unmounts with that navigation, the params are removed again. Browsers without the Navigation API leave router entries as they are. Pass `url={null}` to `` to disable persistence entirely, or your own `DockUrlSync` (see [Persistence adapters](#persistence-adapters)) to customize where and how it's stored. ## Provider configuration `` wraps ``, pre-bound to your `definitions`. Every other prop passes through: | Prop | Default | Purpose | | ----------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------ | | `name` | — | Namespaces the default adapters — see [Several docks on one page](#several-docks-on-one-page). | | `confirmClose` | — | `(panel) => boolean \| Promise`. Fallback guard for a dirty panel with no `beforeClose` registered. | | `url` | `createUrlSync()` | A `DockUrlSync` adapter, or `null` to disable URL persistence. | | `widthStore` | `createWidthStore()` | A `DockWidthStore` adapter, or `null` to disable width memory. | | `defaultWidth` | `400` | Initial `docked` width, in pixels. | | `minWidth` / `maxWidth` | `320` / `720` | Clamp range for `docked` width. | | `expandedWidth` | `768` | Initial `expanded` width. | | `maxExpandedWidth` | `1280` | Clamp max for `expanded` width (shares `minWidth`). | | `limit` | `5` | Max simultaneously open panels before eviction/refusal kicks in. | | `onLimit` | — | `(info: DockLimitInfo) => void`, called when `open` is refused for being at the limit. | | `session` | off | `true` or a `DockSessionStore`: save every open tab and reopen them on reload. | | `onOpen` / `onClose` | — | `(panel) => void` when a panel opens (restores included) or closes (however it closes, eviction included). | | `onActiveChange` | — | `(panel \| null) => void` when the selected panel changes; `null` once the last one closes. | The dock's store is re-pointed at the current props on every render (including `definitions`, if you render `DockProvider` directly), so changing `confirmClose`, `limit`, or the width bounds takes effect immediately without recreating its state. The URL is read for restoration once, on mount. A remembered width is applied in a layout effect, before the first paint — not during render, so server-rendered markup (which has no storage) still hydrates cleanly. ## Several docks on one page Each `createDock` call is independent, and each `` scopes the DOM ids it generates for tabs and panels, so ids never collide. What two docks _can_ share is the default persistence: both would read and write the `?dock=` URL param and the `dock:width` storage key. Give each dock a distinct `name` — ```tsx … … ``` — which moves its URL state to `?inspector=` / `inspector.*` and its width to `dock:inspector:width`. Passing each its own `url` / `widthStore` (or `null`) works too. In development, a warning fires when two mounted providers share the default adapters. ## Composition `` is the batteries-included path: it exposes the dock's current `--dock-width` / `--dock-expanded-width` as CSS custom properties, reserves layout space in `docked` mode via `padding`, and mounts `` for as long as any panel is open or still playing its close transition — unmounting it entirely once the dock is empty, rather than leaving an empty surface in the DOM. Its own props (`side`, `header`, `actions`, `tabCloseIcon`, `labels`, `surfaceClassName`) forward straight to ``. On a narrow screen, reserving the dock's width would squeeze the page down to a sliver. `overlayBelow={640}` makes the docked dock lay over the content instead whenever the viewport element is narrower than 640px. It sets `data-dock-overlay` on the viewport and gives the surface the raised shadow. It stays a non-modal side panel; use `expand()` if you want a modal. `labels` covers every string the surface says: `region`, `tabs`, `closeTabNamed`, `dirty`, `resize`, and `resizeValue` (`(width) => string`, the resize handle's spoken value, default `" pixels"`). `` and `` are exported separately for layouts that don't fit the viewport's model — say, a tab strip rendered in a different DOM location than the panel body, or a surface with no reserved layout at all. `DockSurface` owns the resize handle, the header (tab strip + `actions`, or your `header` render prop), modal behavior in `expanded` mode, and the panel bodies themselves; `DockTabStrip` is just the `role="tablist"` row and can be used on its own if you're building a fully custom surface, driven by the `DockTab[]` from `useDockTabs()`. A custom surface wiring its own `tabpanel`s should use `tabDomId(id, scope)` / `panelDomId(id, scope)` with the `scope` from `useDockContext()`, which is what the tab strip uses — the helpers encode any panel id into a valid, per-dock id token. ## Hooks Beyond the imperative methods on `dock.use()`, reactive state is read through separate hooks. `dock.use()` itself never re-renders its component: the API is stable, so a button that only opens panels stays put while the dock changes. The state hooks each subscribe to a slice, and re-render only when that slice changes: | Hook | Returns | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `useDockSelector(fn, eq?)` | `fn(state)`, re-rendering only when it changes by `eq` (default `Object.is`). Pass an `eq` when `fn` builds a new array or object. | | `useDockState()` | The full `DockState`: `panels`, `activeId`, `mode`, `width`, `expandedWidth`. Re-renders on every change, including each step of a resize drag. | | `useDockTabs()` | `DockTab[]` — id, type, title, icon, `isDirty`, `isActive`, plus bound `select`/`close`. What `` renders. | | `useActivePanel()` | The active `PanelInstance`, or `undefined` when nothing is open. | | `useDockLifecycle(options)` | Registers `dirty` / `beforeClose` for the panel it's called in — see [Guards](#guards-and-the-tab-limit). Must be called from inside a panel's `component`. | ```tsx // Re-renders when the dock opens or closes, not on every tab switch or resize. const isOpen = useDockSelector((state) => state.panels.length > 0); ``` `useDock()` is the untyped equivalent of `dock.use().api` — the same `DockApi` without going through a `createDock` map, for code that's decoupled from a specific dock instance. ## Layering Expanded mode renders inside the `` container rather than portalling to `document.body`, so it lives in that container's stacking context. App chrome painted above it (a `position: fixed` header, say) can still overlap it visually — give the viewport a stacking context that wins, or render the dock above that chrome. Assistive technology is unaffected: the rest of the document is made inert while expanded. ## Styling Import `@inklu/dock/styles.css` and customize with CSS variables: ```css :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); } ``` Every rule in the stylesheet is written with `:where()`, so it carries zero specificity and a single class or inline style on any element overrides it without `!important`. The full variable set: `--dock-surface-bg`, `--dock-surface-fg`, `--dock-border-color`, `--dock-backdrop-color`, `--dock-primary-color`, `--dock-surface-radius`, `--dock-transition-ease`, `--dock-surface-gap`, `--dock-surface-shadow`, `--dock-expanded-shadow`, plus the tab-state colors `--dock-tab-fg`, `--dock-tab-hover-bg`, `--dock-tab-selected-bg`. No component takes a `className`-per-element API; instead every structural node carries a stable `data-dock-*` attribute (and a matching `data-slot` for shadcn-style targeting) to select against: | Attribute | Where | Notes | | ----------------------------------------------------------------------------------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `data-dock-viewport` | `` root | `data-dock-open` when reserving layout width in `docked` mode; `data-dock-overlay` below `overlayBelow`. | | `data-dock-layer` | Positioning layer | `data-dock-mode="docked"\|"expanded"`, `data-dock-side="left"\|"right"`. | | `data-dock-backdrop` | Expanded-mode scrim | `data-dock-state="entering"\|"present"\|"exiting"`. | | `data-dock-surface` | The dock panel itself | Same `data-dock-state`, and `inert` while `exiting`; `data-dock-resizing` while a drag or keyboard step is live (suppresses the width transition). | | `data-dock-resize-handle` | Drag handle | `"start"` (right-docked) or `"end"` (left-docked). | | `data-dock-header` | Tab strip + actions row | | | `data-dock-tablist` | The tab row | `data-overflow-start` / `data-overflow-end` toggle the scroll fade masks. | | `data-dock-tab` | One tab | `data-selected` when active. | | `data-dock-tab-button`, `data-dock-tab-icon`, `data-dock-tab-title`, `data-dock-tab-dirty`, `data-dock-tab-close` | Tab internals | | | `data-dock-body` | Panel body container | | | `data-dock-panel` | One panel's content wrapper | `data-dock-selected` when active; the rest are `inert`. | | `data-dock-autofocus` | Yours, on panel content | The element `open` focuses in that panel. See [Focus](#focus). | | `data-dock-actions` | The `actions` prop's wrapper | | | `data-dock-form-footer`, `data-dock-form-footer-status`, `data-dock-form-footer-actions` | `` | | The `entering` / `present` / `exiting` states come from `usePresence`, which resolves `exiting` once every animation the surface is actually running has finished (via `element.getAnimations()`), or on a timeout of the longest declared transition plus a little slack, for browsers without `getAnimations` and backgrounded tabs — so a custom exit animation just needs a CSS `transition` on `[data-dock-state]`. ## Persistence adapters `createUrlSync(options?)` and `createWidthStore(options?)` build the default adapters `` uses; pass a customized instance via the `url` / `widthStore` props, or write your own against the `DockUrlSync` / `DockWidthStore` interfaces (e.g. to back width memory with `sessionStorage`, or URL state with a router's own APIs). ```ts createUrlSync({ typeParam: "panel", // default: "dock" (or the provider's `name`) prefix: "panel.", // default: "dock." (or ".") history: "push", // default: "replace" }); createWidthStore({ key: "myapp:dock-width", // default: "dock:width" ("dock::width" with `name`) storage: sessionStorage, // default: window.localStorage }); createSessionStore({ key: "myapp:dock-tabs", // default: "dock:session" ("dock::session" with `name`) storage: localStorage, // default: window.sessionStorage }); ``` Pass a session store as `session={createSessionStore(…)}`; `session` (`true`) builds the default one. ## Advanced: lower-level composition `createDock` is a thin factory over primitives that are themselves exported, for building your own wrapper instead of using `createDock`'s shape directly: - `createDockStore(options)` — the framework-agnostic state machine `` wraps in React context. Useful mainly for tests, or a non-React host. - `DockContext` / `useDockContext` / `PanelIdContext` / `usePanelId` — the context a custom surface or panel component would need to read the live store and its own id. - `toDefinition(type, spec)` — normalizes a `PanelSpec` (author-facing, with optional fields) into a `PanelDefinition` (fully-resolved, what the store consumes). These are under semver like the rest of the package, but sit a layer below `createDock` — reach for that first. ## License MIT © [nkurunziza-saddy](https://github.com/nkurunziza-saddy)