---
title: Workspace
description: Resizable tab groups with drag-to-split layouts and state-preserving tab moves.
icon: panels-top-left
mode: wide
author:
  name: bvaughn
  url: https://github.com/bvaughn
search:
  keywords: [split pane, editor, docking, tabs]
---

<ComponentPreview
  name="workspace-demo"
  description="Edit a draft, move tabs between groups, split at an edge, and save or restore a layout."
/>

## Installation

```package-install
npx shadcn@latest add @redpanda/workspace
```

Built on [react-resizable-panels 4.16](https://github.com/bvaughn/react-resizable-panels/releases/tag/4.16.0).
The registry supplies theme-aware tabs, close buttons, drop indicators, and resizable separators.

## When to use

Use Workspace for editor-style interfaces where people rearrange tabbed content and split groups.
Use [Resizable](/docs/resizable) for fixed panel arrangements that only need resizing,
or [Tabs](/docs/tabs) for a single tab list without docking.

## Usage

```tsx
import { Workspace, type WorkspaceModelInput } from '@/components/redpanda-ui/components/workspace';

const model: WorkspaceModelInput<string> = {
  version: 1,
  root: {
    type: 'tabset',
    tabs: [
      { id: 'readme', title: 'Readme', data: 'Welcome to the workspace.' },
      { id: 'notes', title: 'Notes', data: 'Your notes' },
    ],
  },
};

function renderTabContent(tab: { data: string }) {
  return <p>{tab.data}</p>;
}

export function Editor() {
  return (
    <div className="h-96 w-full">
      <Workspace defaultModel={model} renderTabContent={renderTabContent} />
    </div>
  );
}
```

Give the containing element an explicit height. `TabData` and `TabsetData` generics preserve
application data types throughout models, render callbacks, drop rules, and the imperative API.

## Anatomy

```text
Workspace
├── Split (horizontal or vertical)
│   ├── Tabset
│   │   ├── Header: tab list + optional toolbar
│   │   └── Selected tab content
│   ├── ResizableHandle
│   └── Tabset or nested split
└── Drag preview + drop indicator
```

Tabs move within a group or between groups. Dropping onto a content edge creates a split;
dropping in the center adds the tab to that group. Drag separators to resize groups.

## Layout models and persistence

Layouts are serializable objects with `version: 1` and a `root` split or tabset. Splits and tabsets
may omit their IDs in `defaultModel`; give them stable IDs when targeting them with the API.
All tab, tabset, and split IDs must be unique within the workspace. Store serializable application data in `tab.data`,
not React elements or component functions.

- `defaultModel` is read once on mount, not a controlled prop.
- `onModelChanged(model, meta)` reports completed changes; save the model to your application's persistence layer.
- Restore a saved model through `defaultModel` on a new mount, or `workspaceRef.current.setModel(model)` on an existing workspace.
- Validate persisted data before passing it to Workspace. Handle storage and parse errors visibly in the host application.
- Layout persistence does not save local state or uncontrolled input values inside tab content. Save documents separately.

For example, a host can accept a typed persistence callback:

```tsx
import { Workspace, type WorkspaceModel, type WorkspaceProps } from '@/components/redpanda-ui/components/workspace';

export function PersistentEditor({
  saveModel,
  ...props
}: WorkspaceProps<string> & { saveModel: (model: WorkspaceModel<string>) => void }) {
  return <Workspace {...props} onModelChanged={saveModel} />;
}
```

## Imperative API

`useWorkspaceRef()` accepts the `TabData` and `TabsetData` type parameters. Pass its return value to
`workspaceRef` to call `addTab`, `selectTab`,
`closeTab`, `moveTab`, `getModel`, `setModel`, `maximizeTabset`, or `restoreTabset`.
Adding an existing tab ID selects that tab rather than creating a duplicate.
Import these hooks directly from `react-resizable-panels`. The main demo uses this API to open notes and save or restore layout snapshots.

Content and toolbars can access their nearest workspace, tabset, and tab through
`useParentWorkspace`, `useParentTabset`, and `useParentTab` hooks. Their corresponding state hooks
subscribe to selection, placement, and maximization changes.

## Tab placement

Use `tabPlacement="top"`, `"bottom"`, `"left"`, or `"right"`. Each tabset can override the
workspace default through its model's `tabPlacement` field. Newly split tabsets use the workspace default.

<ComponentPreview
  name="workspace-tab-placement-demo"
  description="Tabs on all four sides, with orientation-aware keyboard navigation."
/>

## Custom tabs and toolbars

<ComponentPreview
  name="workspace-custom-tabs-demo"
  description="Custom file tabs with icons and close actions, plus a context-aware toolbar that opens a changelog."
/>

- `classNames` merges classes for `tabset`, `header`, `tabList`, `tab`, `toolbar`, `tabPanels`, `tabContent`, `dropIndicator`, and `dragPreview`.
- `renderTab(tab, props)` replaces the tab renderer. Spread all supplied props onto a non-button host such as a `div`; they carry its ref, ARIA attributes, keyboard handling, and drag behavior. Keep close actions out of the tab sequence.
- `renderTabsetToolbar(tabset)` adds actions next to the tab list. The main demo uses this to maximize and restore groups.
- `renderEmptyTabset(tabset)` supplies an empty state. Set `keepWhenEmpty: true` on a tabset to retain it when its last tab leaves.
- `renderSeparator` replaces the default registry `ResizableHandle`.

The default renderer and custom-tab demo follow upstream's `TabCloseButton` pattern inside each tab.
The [ARIA tab role](https://www.w3.org/TR/wai-aria-1.2/#tab) has presentational children, so assistive
technologies may not expose the nested close icon as a separate control. Do not rely on that icon's
announcement: focus the tab and press Delete or Backspace to close it. If your application needs a
separately discoverable close control, place a labeled action in `renderTabsetToolbar` outside the tab.
VoiceOver and NVDA exposure has not been manually verified for these demos.

Keep render functions at module scope where practical. Do not define a new component type inside a render callback;
that would remount its content on parent renders.

## Pinned tabs and drop rules

<ComponentPreview
  name="workspace-drop-rules-demo"
  description="Move report tabs into a restricted group while document drops and edge splits are rejected."
/>

Set both `disableClose: true` and `disableDrag: true` on a tab to keep it in place and hide its close action.
`disableDrop` can reject all drops or inspect each proposed target. `disabled` locks rearrangement and closing
while still allowing selection and maximization.

The drop-rule demo reads typed tab and tabset data to restrict a reports-only group. Edge splits are disabled so new groups cannot bypass its designated layout. Empty groups remain valid drop targets through `keepWhenEmpty`.

These flags restrict user interactions, not imperative commands. Enforce application permissions separately.

## State, keyboard, and server rendering

Tab content remains mounted by default, including inactive tabs. Moving a tab between groups or splitting it
does not remount React content. The underlying DOM nodes move, so browser-managed state such as iframe
navigation can still reset. `hiddenTabBehavior="unmount"` explicitly opts out of preserving inactive content.

Tabs follow the ARIA tabs pattern: arrow keys move focus along the tab list's orientation, Home and End jump
to its ends, Enter or Space selects, and Delete or Backspace closes an unpinned tab. Separators retain the
keyboard resizing behavior of [Resizable](/docs/resizable).

The layout can render on the server. Portal-based tab content is client-rendered by default.
Opt into `serverRenderTabContent` for selected content that needs server HTML; it remounts once when moved
into its portal after hydration. Avoid this option for content whose initial state must survive hydration.

## Credits

Workspace behavior comes from [react-resizable-panels](https://react-resizable-panels.vercel.app/).
