Workspace
Self-contained multi-pane workspace with tab drag/drop, cross-pane transfers, and snap-zone splitting. Exposes a `useWorkspace` context hook and a `WorkspaceHandle` ref for programmatic control.
Dashboard
Settings
Installation
pnpm dlx shadcn@latest add @syncblocks/workspaceUsage
import {
Workspace,
useWorkspace,
type WorkspaceHandle,
} from "@/components/ui/workspace"<Workspace
initialPanes={[
{
id: "editor",
tabs: [{ id: "home", title: "Home", pinned: true }],
},
]}
renderTabContent={(_paneId, tabId) => (
<div className="p-4">{tabId} content</div>
)}
/>Opening tabs at runtime
renderTabContent is called on every render for the active tab of each pane, so
content lives in a lookup keyed by tab id — not inside the tabs. To open tabs
programmatically (e.g. from a sidebar), attach a WorkspaceHandle ref and call
openTabInPane. Reopening an already-open tab just focuses it.
import * as React from "react"
import { LayoutDashboard, FileText, Settings } from "lucide-react"
import { Workspace, type WorkspaceHandle } from "@/components/ui/workspace"
// Content is looked up by tab id, defined once outside the component.
const TAB_CONTENT: Record<string, React.ReactNode> = {
dashboard: <DashboardView />,
documents: <DocumentsView />,
settings: <SettingsView />,
}
export function App() {
const workspace = React.useRef<WorkspaceHandle>(null)
function openTab(id: string, title: string, icon: React.ReactNode) {
// "main" is the target pane id from initialPanes; the tab is created if missing,
// or focused if it already exists.
workspace.current?.openTabInPane("main", { id, title, icon })
}
return (
<div className="flex h-full">
<nav className="w-48 shrink-0 border-r p-2">
<button onClick={() => openTab("documents", "Documents", <FileText />)}>
Documents
</button>
<button onClick={() => openTab("settings", "Settings", <Settings />)}>
Settings
</button>
</nav>
<Workspace
ref={workspace}
className="flex-1"
initialPanes={[
{
id: "main",
tabs: [
{
id: "dashboard",
title: "Dashboard",
icon: <LayoutDashboard />,
pinned: true,
},
],
},
]}
renderTabContent={(_paneId, tabId) =>
TAB_CONTENT[tabId] ?? <div className="p-4">{tabId}</div>
}
/>
</div>
)
}Components rendered inside a pane can reach the same methods without a ref via
the useWorkspace() hook — use the ref from outside <Workspace>,
the hook from within it. Both expose openTabInPane, closeTab, activateTab,
updateTab (e.g. to set a tab's badge), openPane, and closePane.
Examples
Single pane, multiple tabs
One pane with several tabs. Tabs are closable and reorderable by drag; the pinned tab has no close button.
Home
Split panes
Two side-by-side panes sized with `defaultSize`. Drag a tab across the divider to move it between panes, or drag it to an edge to split further.
Dashboard
Settings
Tab badges
Set `badge` (a number) on a tab to show a count next to its title.
Inbox
Add-tab button
Provide `onAddTab` on a pane to render a "+" button in its tab strip. Open the new tab through the `WorkspaceHandle` ref.
Press + to open a tab
Programmatic control (sidebar)
Drive the workspace from outside with a `WorkspaceHandle` ref. Each sidebar button calls `openTabInPane` — reopening an existing tab just focuses it.
dashboard
Control from inside a pane
Components rendered inside a pane reach the same methods via the `useWorkspace()` hook — no ref required.
Unsaved changes
Mark a tab dirty and its close button becomes a dot until you hover it. Return
false from onBeforeCloseTab to veto the close — returning a Promise<boolean>
lets you await a confirmation dialog. Type in draft.md, then try to close it.
Because closePane discards every tab at once, the guard runs for each tab it
would drop — pinned ones included — and a single veto aborts the whole pane close.
Saving and restoring the layout
serialize() captures the column, pane, and tab structure; restore() rebuilds
it. The snapshot holds ids only, so restore takes a resolveTab callback to
look each tab back up — icons and onAddTab handlers live in your lookup, not in
the snapshot. Split a pane by dragging a tab to an edge, save, rearrange, restore.
Split a pane by dragging a tab to an edge, then save.
Home
Custom fallback
The `fallback` prop configures what shows when every pane is closed. Close the tab below to reveal it.
scratch
Composition
Use the following composition to build a workspace:
Features
- Cross-pane drag and drop — drag a tab from one pane and drop it into another to transfer it.
- Snap-zone splitting — drag a tab to an edge to split: left/right adds a new column, top/bottom adds a new row within the column.
- Escape to cancel — pressing Escape during a drag cancels the operation without committing a drop.
- Tab menu — each tab strip opens a dropdown listing every tab in that pane, with the active one checked; picking one activates it. Handy once tabs overflow and scroll out of view. Below the list it offers Split right, Split down, and Move to pane for the active tab. Always available — there's no prop to configure it.
- Keyboard rearranging — splitting and moving a tab are reachable without a pointer: the tab menu's split and move items run the same operations as a drag. Panes have no name of their own, so move targets are labelled by each pane's active tab. Split is offered only when the pane holds more than one tab, and neither is offered for a
pinnedtab — matching the drag rules. - Keyboard tab close — press Delete or Backspace on a focused tab to close it, subject to
onBeforeCloseTab. Pinned tabs ignore it. - Resizable panels — panel columns are resizable via drag handles, with configurable min sizes.
- Unsaved-change guard — mark a tab
dirtyto swap its close button for a dot, and returnfalsefromonBeforeCloseTabto veto the close. - Layout persistence —
serialize()/restore()on theWorkspaceHandleround-trip the column, pane, and tab structure through storage. - Imperative handle — attach a
WorkspaceHandleref to control panes and tabs programmatically from outside the component. - Context hook — any component rendered inside
<Workspace>can calluseWorkspace()to read state or open/close panes and tabs. - Custom fallback — configure the view shown when all panes are closed via the
fallbackprop. - Announced layout changes — splits, cross-pane moves, and reorders are reported through an
aria-liveregion, since rearranging panes moves no focus and changes no visible text a screen reader would otherwise notice. Fires for both the drag and keyboard paths.
API Reference
Workspace
| Prop | Type | Default | Description |
|---|---|---|---|
initialPanes | WorkspacePaneDef[] | [] | Initial pane/tab configuration rendered on mount. |
renderTabContent | (paneId: string, tabId: string) => ReactNode | - | Renders content for the active tab in each pane. Called on every render — keep it fast. (required) |
onBeforeCloseTab | (paneId: string, tabId: string, tab: WorkspaceTabDef) => boolean | Promise<boolean> | - | Return false to veto a tab close — use it to prompt on unsaved changes. Consulted for every tab a closePane would discard; one veto aborts the whole pane close. |
fallback | ReactNode | - | Shown when all panes are closed. Defaults to a built-in placeholder. |
className | string | - | Additional CSS classes applied to the root element. |
WorkspacePaneDef
Used in initialPanes and openPane().
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | - | Unique identifier for this pane. (required) |
tabs | WorkspaceTabDef[] | - | Initial tabs for this pane. (required) |
defaultActiveTabId | string | - | Which tab is active on mount. Defaults to the first tab. |
defaultSize | number | - | Percentage width (0–100) for horizontal panel groups. |
minSize | number | - | Minimum percentage size. Defaults to 20. |
onAddTab | () => void | - | Renders a "+" button in the tab strip when provided. |
WorkspaceTabDef
A single tab. Re-exported alias of WorkspaceTab from workspace-tabs.
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | - | Unique identifier for this tab. (required) |
title | string | - | Label shown in the tab strip. (required) |
icon | ReactNode | - | Rendered before the title, sized to 14px. |
badge | number | - | Unread count. Values above 99 render as "99+"; 0 and undefined render nothing. |
pinned | boolean | false | Pinned tabs cannot be closed or dragged, and render no close button. |
dirty | boolean | false | Unsaved changes: the tab shows a dot instead of its close button until hovered. Pair with onBeforeCloseTab to prompt before discarding. |
useWorkspace()
Context hook available to any component rendered inside <Workspace>.
| Key | Type | Description |
|---|---|---|
panes | readonly PaneState[] | Current flat list of all open panes. |
isShowingFallback | boolean | True when all panes are closed and the fallback is visible. |
lastActivePaneId | string | null | ID of the most recently focused pane. |
openTabInPane | (paneId, tab) => void | Opens or focuses a tab in the specified pane. |
closeTab | (paneId, tabId) => void | Closes a tab, subject to onBeforeCloseTab. Removes the pane if it was the last tab. |
activateTab | (paneId, tabId) => void | Switches the active tab in a pane. |
updateTab | (paneId, tabId, patch) => void | Patches tab properties (e.g. update badge count). |
openPane | (pane: WorkspacePaneDef) => void | Adds a new pane column. No-op if the pane ID already exists. |
closePane | (paneId) => void | Removes a pane and all its tabs, subject to onBeforeCloseTab. |
WorkspaceHandle
Imperative ref API. Attach via ref on <Workspace>. Same methods as useWorkspace(), minus read-only pane state and plus layout persistence.
| Key | Type | Description |
|---|---|---|
lastActivePaneId | string | null | ID of the most recently focused pane. |
openTabInPane | (paneId, tab) => void | Opens or focuses a tab in the specified pane. |
closeTab | (paneId, tabId) => void | Closes a tab, subject to onBeforeCloseTab. Removes the pane if it was the last tab. |
activateTab | (paneId, tabId) => void | Switches the active tab in a pane. |
updateTab | (paneId, tabId, patch) => void | Patches tab properties (e.g. update badge count). |
openPane | (pane: WorkspacePaneDef) => void | Adds a new pane column. No-op if the pane ID already exists. |
closePane | (paneId) => void | Removes a pane and all its tabs, subject to onBeforeCloseTab. |
serialize | () => WorkspaceSnapshot | Captures the current column/pane/tab structure for persistence. |
restore | (snapshot, resolveTab) => void | Rebuilds the layout from a snapshot. resolveTab maps a tab id back to a full tab. |
WorkspaceSnapshot
A JSON-safe view of the layout: column, pane, and tab ids only. A tab's
icon is a ReactNode and a pane's onAddTab a function, so neither survives
a round-trip through storage — restore takes a resolveTab callback to
rehydrate each tab from its id. Tabs that don't resolve are dropped, along with
any pane they'd leave empty. Panel sizes aren't part of the snapshot.
const workspace = React.useRef<WorkspaceHandle>(null)
React.useEffect(() => {
const saved = localStorage.getItem("workspace")
if (saved) workspace.current?.restore(JSON.parse(saved), (id) => TABS[id])
const save = () =>
localStorage.setItem(
"workspace",
JSON.stringify(workspace.current?.serialize())
)
window.addEventListener("beforeunload", save)
return () => window.removeEventListener("beforeunload", save)
}, [])