Workspace
Resizable tab groups with drag-to-split layouts and state-preserving tab moves.
Installation
npx shadcn@latest add @redpanda/workspacepnpm dlx shadcn@latest add @redpanda/workspaceyarn dlx shadcn@latest add @redpanda/workspacebunx shadcn@latest add @redpanda/workspacenubx shadcn@latest add @redpanda/workspaceaube dlx shadcn@latest add @redpanda/workspaceBuilt on react-resizable-panels 4.16. 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 for fixed panel arrangements that only need resizing, or Tabs for a single tab list without docking.
Usage
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
Workspace├── Split (horizontal or vertical)│ ├── Tabset│ │ ├── Header: tab list + optional toolbar│ │ └── Selected tab content│ ├── ResizableHandle│ └── Tabset or nested split└── Drag preview + drop indicatorTabs 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.
defaultModelis 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
defaultModelon a new mount, orworkspaceRef.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:
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.
Custom tabs and toolbars
classNamesmerges classes fortabset,header,tabList,tab,toolbar,tabPanels,tabContent,dropIndicator, anddragPreview.renderTab(tab, props)replaces the tab renderer. Spread all supplied props onto a non-button host such as adiv; 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. SetkeepWhenEmpty: trueon a tabset to retain it when its last tab leaves.renderSeparatorreplaces the default registryResizableHandle.
The default renderer and custom-tab demo follow upstream’s TabCloseButton pattern inside each tab.
The ARIA tab role 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
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.
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.