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/workspace

Usage

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

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:

Workspace
├─ WorkspacePaneDef (×n via initialPanes prop)
└─ WorkspaceTabDef (×n via tabs prop)
├─ icon?
├─ badge?
└─ pinned?
├─ renderTabContent(paneId, tabId) → your content
└─ fallback (shown when all panes closed)

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 pinned tab — 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 dirty to swap its close button for a dot, and return false from onBeforeCloseTab to veto the close.
  • Layout persistenceserialize() / restore() on the WorkspaceHandle round-trip the column, pane, and tab structure through storage.
  • Imperative handle — attach a WorkspaceHandle ref to control panes and tabs programmatically from outside the component.
  • Context hook — any component rendered inside <Workspace> can call useWorkspace() to read state or open/close panes and tabs.
  • Custom fallback — configure the view shown when all panes are closed via the fallback prop.
  • Announced layout changes — splits, cross-pane moves, and reorders are reported through an aria-live region, 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

PropTypeDefaultDescription
initialPanesWorkspacePaneDef[][]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.
fallbackReactNode-Shown when all panes are closed. Defaults to a built-in placeholder.
classNamestring-Additional CSS classes applied to the root element.

WorkspacePaneDef

Used in initialPanes and openPane().

PropTypeDefaultDescription
idstring-Unique identifier for this pane. (required)
tabsWorkspaceTabDef[]-Initial tabs for this pane. (required)
defaultActiveTabIdstring-Which tab is active on mount. Defaults to the first tab.
defaultSizenumber-Percentage width (0–100) for horizontal panel groups.
minSizenumber-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.

PropTypeDefaultDescription
idstring-Unique identifier for this tab. (required)
titlestring-Label shown in the tab strip. (required)
iconReactNode-Rendered before the title, sized to 14px.
badgenumber-Unread count. Values above 99 render as "99+"; 0 and undefined render nothing.
pinnedbooleanfalsePinned tabs cannot be closed or dragged, and render no close button.
dirtybooleanfalseUnsaved 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>.

KeyTypeDescription
panesreadonly PaneState[]Current flat list of all open panes.
isShowingFallbackbooleanTrue when all panes are closed and the fallback is visible.
lastActivePaneIdstring | nullID of the most recently focused pane.
openTabInPane(paneId, tab) => voidOpens or focuses a tab in the specified pane.
closeTab(paneId, tabId) => voidCloses a tab, subject to onBeforeCloseTab. Removes the pane if it was the last tab.
activateTab(paneId, tabId) => voidSwitches the active tab in a pane.
updateTab(paneId, tabId, patch) => voidPatches tab properties (e.g. update badge count).
openPane(pane: WorkspacePaneDef) => voidAdds a new pane column. No-op if the pane ID already exists.
closePane(paneId) => voidRemoves 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.

KeyTypeDescription
lastActivePaneIdstring | nullID of the most recently focused pane.
openTabInPane(paneId, tab) => voidOpens or focuses a tab in the specified pane.
closeTab(paneId, tabId) => voidCloses a tab, subject to onBeforeCloseTab. Removes the pane if it was the last tab.
activateTab(paneId, tabId) => voidSwitches the active tab in a pane.
updateTab(paneId, tabId, patch) => voidPatches tab properties (e.g. update badge count).
openPane(pane: WorkspacePaneDef) => voidAdds a new pane column. No-op if the pane ID already exists.
closePane(paneId) => voidRemoves a pane and all its tabs, subject to onBeforeCloseTab.
serialize() => WorkspaceSnapshotCaptures the current column/pane/tab structure for persistence.
restore(snapshot, resolveTab) => voidRebuilds 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)
}, [])