Skip to content

Workspace ​

Workspace arranges a toolbar, optional side regions and content. SplitView supplies nested, resizable panels with document tabs. Use either on its own, or compose them as below. Existing navigation Tabs are independent.

tsx
import { useState } from 'react';
import { FileText, Moon, PanelLeft, PanelRight, Sun } from 'lucide-react';
import { Button, Icon, IconButton, SplitView, Workspace, createSplitLayout, updateSplitLayout, type SplitLayout, type WorkspaceLayout } from '@adecore/ui';

function initialLayout(): SplitLayout {
    const first = createSplitLayout(['Brief', 'Notes'], 'documents');
    const second = updateSplitLayout(first, {
        type: 'split',
        paneId: 'documents',
        side: 'right',
        newPane: { type: 'pane', id: 'preview', views: ['Preview'], active: 'Preview' },
        splitId: 'columns'
    });
    return updateSplitLayout(second, {
        type: 'split',
        paneId: 'preview',
        side: 'bottom',
        newPane: { type: 'pane', id: 'output', views: ['Output'], active: 'Output' },
        splitId: 'rows'
    });
}

function Document({ name }: { name: string }) {
    const [text, setText] = useState(`Working notes for ${name}.\n\nEdit this text, then move its tab into another panel. The text stays with the view.`);
    return (
        <textarea
            aria-label={`${name} contents`}
            className="h-full w-full resize-none bg-surface p-4 text-sm text-text outline-none"
            value={text}
            onChange={(event) => setText(event.target.value)}
        />
    );
}

export default function WorkspaceDemo() {
    const [value, setValue] = useState(initialLayout);
    const [layout, setLayout] = useState<WorkspaceLayout>('roomy');
    const [appearance, setAppearance] = useState<'light' | 'dark'>('dark');
    const [sidebar, setSidebar] = useState(true);
    const [panel, setPanel] = useState(false);
    const [serial, setSerial] = useState(1);
    const split = (side: 'right' | 'bottom'): void => {
        const name = `Draft ${serial}`;
        setValue((current) =>
            updateSplitLayout(current, {
                type: 'split',
                paneId: current.focused,
                side,
                newPane: { type: 'pane', id: `draft-${serial}`, views: [name], active: name },
                splitId: `split-${serial}`
            })
        );
        setSerial(serial + 1);
    };
    return (
        <div data-theme={appearance} className="flex w-full flex-col gap-3 bg-bg p-4 text-text">
            <div className="flex flex-wrap items-center gap-2">
                <Button variant="secondary" onClick={() => setAppearance(appearance === 'dark' ? 'light' : 'dark')}>
                    <Icon icon={appearance === 'dark' ? Sun : Moon} size={14} />
                    {appearance === 'dark' ? 'Light appearance' : 'Dark appearance'}
                </Button>
                <Button variant="secondary" aria-pressed={layout === 'roomy'} onClick={() => setLayout(layout === 'roomy' ? 'standard' : 'roomy')}>
                    {layout === 'roomy' ? 'Roomy layout' : 'Standard layout'}
                </Button>
                <Button variant="secondary" onClick={() => split('right')}>
                    Split right
                </Button>
                <Button variant="secondary" onClick={() => split('bottom')}>
                    Split below
                </Button>
                <Button
                    onClick={() => {
                        setValue(initialLayout());
                        setSerial(1);
                    }}
                >
                    Reset layout
                </Button>
            </div>
            <div className="w-full overflow-x-auto rounded-lg border border-border">
                <div className="h-[480px] min-w-[640px]">
                    <Workspace
                        layout={layout}
                        sidebarWidth={120}
                        sidePanelWidth={140}
                        sidebar={
                            sidebar && (
                                <div className="p-3 text-xs text-text-muted">
                                    Project files
                                    <br />
                                    <br />
                                    Brief
                                    <br />
                                    Notes
                                    <br />
                                    Preview
                                </div>
                            )
                        }
                        sidePanel={panel && <div className="p-3 text-xs text-text-muted">Details</div>}
                        toolbar={
                            <div className="flex h-12 items-center gap-2 px-2">
                                <IconButton icon={PanelLeft} label="Toggle sidebar" active={sidebar} onClick={() => setSidebar(!sidebar)} />
                                <span className="min-w-0 grow truncate text-sm">Project workspace</span>
                                <IconButton icon={PanelRight} label="Toggle details" active={panel} onClick={() => setPanel(!panel)} />
                            </div>
                        }
                    >
                        <SplitView
                            value={value}
                            onValueChange={setValue}
                            getTab={(id) => ({ label: id, icon: <Icon icon={FileText} size={14} />, pinned: id === 'Brief' })}
                            renderView={(id) => <Document name={id} />}
                        />
                    </Workspace>
                </div>
            </div>
            <p className="text-xs text-text-muted">
                Drag a tab or the panel grip. Drop near an edge to split, on the tab strip to group tabs, or in the middle to swap. Drag dividers, double-click
                to balance, or use their arrow keys. Dividers snap near the middle. Alt mirrors a resize; Alt + double-click evenly distributes the chosen
                direction across the entire layout.
            </p>
        </div>
    );
}

Use the controls to compare standard and roomy spacing in light or dark appearance, open the side regions and add nested splits. Edit a document before moving its tab to see its contents stay with it. The appearance control applies only to this demo.

Layout and appearance ​

WorkspaceProps accepts layout, toolbar, sidebar, sidePanel, sidebarWidth, sidePanelWidth and children, along with className, ref, render and the native div props. Omit a region, or pass false or null, to close it. Side regions are fixed-width surfaces; applications can supply their own resize controls. The default widths are 248 and 320 pixels. Set a region's width to 'auto' when its contents already manage sizing, separators and animation, such as one or more SlidingColumn components. Auto-sized regions render their contents directly into the flex layout. Use sidebarOpen or sidePanelOpen to tell the workspace when those contents are closed; they stay mounted while toolbar corners and flush edges follow the open state.

WorkspaceLayout is 'standard' | 'roomy'. Standard is the default: adjacent surfaces have 1px separators, without extra spacing or rounded corners. Roomy uses 8px gaps and 8px corners. A corner touching any flush outer edge stays square. The toolbar gets space below it and rounds its bottom corners only beside open side regions. Both layouts use the opaque --separator token, and borders inside the workspace follow it. The mode does not select light or dark appearance.

Give the container a height. Both components fill their container and can appear several times on one page. Their drag state and IDs are isolated.

Controlled state ​

tsx
import { useState } from 'react';
import { SplitView, Workspace, createSplitLayout } from '@adecore/ui';

function EditorWorkspace() {
    const [value, setValue] = useState(() => createSplitLayout(['notes']));
    return (
        <Workspace layout="roomy" toolbar={<EditorToolbar />}>
            <SplitView
                value={value}
                onValueChange={setValue}
                getTab={(id) => ({ label: id })}
                renderView={(id) => <Editor documentId={id} />}
            />
        </Workspace>
    );
}

SplitLayout contains root, focused and maximized. A SplitNode is either a SplitPane or a SplitBranch:

  • A pane has type: 'pane', a stable id, ordered views and an active view ID or null.
  • A branch has type: 'split', a stable id, an axis, children and positive sizes that sum to one. SplitAxis is 'horizontal' | 'vertical'. Horizontal places children beside each other; vertical stacks them.
  • focused and maximized contain pane IDs. maximized is null when all panes are visible.

Every node ID is unique within a layout. Every view ID occurs in at most one pane. An application that opens the same document twice gives its two view instances different IDs. Views can contain any content; the layout stores no document payloads.

createSplitLayout(views?, paneId?) creates one pane. normalizeSplitLayout(unknown) repairs persisted data, deduplicates IDs, removes invalid branches, repairs selection, normalizes sizes and merges consecutive branches on the same axis. Call it when loading data, not on every render. The application owns serialization, versioning, storage and migration from older formats. splitPanes(root) lists panes in layout order.

Commands ​

updateSplitLayout(value, command) returns the next state without changing its input. SplitCommand is a discriminated union. Invalid targets and stale commands return the original state.

CommandFields and behavior
activatepaneId, viewId: activates the view and focuses the pane.
focuspaneId: changes focused pane without changing its active view.
maximizepaneId or null: maximizes or restores, keeping all views mounted.
insertpaneId, viewId, optional index: inserts a new view or moves an existing one.
splitpaneId, side, newPane, splitId: adds a new pane next to the target at any depth.
movesourceId, targetId, optional viewId, side, index, newPaneId, splitId: moves one tab or, without viewId, the whole pane. An edge drop needs a fresh splitId; moving one tab to an edge also needs a fresh newPaneId.
swapsourceId, targetId, optional viewId: exchanges whole panes, or one tab with the target active tab. A lone source tab moves its whole pane.
closeviewId: closes a view and removes its pane if empty, unless it is the last pane.
closePanepaneId: closes the whole group. The last pane becomes empty.
resizesplitId, sizes: updates positive shares.
equalizesplitId, optional index: balances the neighboring children at a divider, or all children when the index is omitted.
equalizeAxisaxis, optional length and gap: distributes tracks across the entire layout in one direction, including nested splits.

SplitSide is 'left' | 'right' | 'top' | 'bottom'. A move without a side groups tabs. Use swap for a center drop: whole panes exchange positions while split proportions stay put; a tab taken from a group exchanges places with the active target tab. No view is discarded. The optional insertion index denotes a gap in the original tab order; the reducer accounts for the source being lifted out. Moving a whole pane to an edge preserves its pane ID. Redundant branches collapse after moves and closes. Consecutive splits on the same axis share one branch, so their dividers resize neighboring panes. Splitting in the other direction creates a nested branch. Merging a branch preserves pane IDs and relative shares; its redundant branch ID is retired. The supplied splitId is used only when a new branch is needed.

equalizeAxis counts the tracks across nested splits. A panel above three columns spans all three, while a panel beside them occupies one track. The other direction keeps its existing sizes. SplitView supplies the current axis length and separator gap in pixels so spanning panels include their internal gaps. Without these measurements, the command uses proportional shares (length: 1, gap: 0). Minimum pane sizes still apply.

onValueChange(next, command) reports both the proposed state and its cause. The application can reject a change or apply its own layout limits. The core has no fixed limit on the number of rows, columns or nesting levels.

Rendering and view lifetime ​

SplitViewProps requires value, onValueChange and renderView. renderView(id, info) receives SplitViewInfo with paneId and active. getTab(id) supplies document metadata. renderPaneHeader(pane) replaces the default tab strip and controls; showTabs={false} removes the default header. headerHeight defaults to 36 pixels.

All view hosts remain siblings in a stable order under one parent. Moves update their position, without reparenting them. Inactive tabs and panels hidden by maximization remain mounted, hidden and inert. Closing a view removes it. Keep the rendered component type stable and do not key it by pane ID if its local state must survive moving.

onViewBoundsChange(bounds) receives SplitViewBounds entries with a viewId, paneId, active, viewport rect and CSS borderRadius. Native surfaces can stay in their own stable layer and follow these bounds. SplitRect contains x, y, width and height. Hide a native surface when active is false and apply the reported clipping. Unmounting the SplitView reports an empty bounds list. The host remains responsible for creating and disposing native surfaces.

minimumSize is a SplitMinimum (width, height) or a function of the pane. Defaults are 120 by 80 pixels, including the header. Nested minimums contribute to their ancestors. If a window is smaller than all minimums, panes shrink proportionally rather than overflow. Stored shares remain unchanged until a user resizes. minimumResizeShare optionally limits a dragged pane to a fraction of the neighbors being resized; it defaults to zero. For example, 0.15 keeps at least 15% of that span in each pane, subject to available space and pixel minimums.

A standalone SplitView can set layout and flushEdges. SplitEdges marks which outer edges touch the window: top, right, bottom, left. All are flush by default. When composed inside Workspace, the appropriate edges and layout are inherited.

Dragging, closing and keyboard access ​

The built-in drag interactions reorder tabs, move tabs between panes, move whole groups using the grip, and split near a pane edge. Dropping on the tab strip groups tabs; dropping in the middle of the content replaces what is shown there by swapping it with the source. The displaced pane or active view moves back to the source position, so it remains open. An outline previews the resulting tab and pane.

canDrop(drag, target) can reject internal moves. SplitDrag identifies the source pane and optional view; SplitDropTarget identifies the target pane, optional side and original tab gap. side: 'center' denotes a replacement drop in the body; an omitted side denotes the tab strip. External drops can apply an application-specific replacement policy. onViewDragStart(id, event) can write application-specific drag payloads. Other workspace instances do not claim this internal drag automatically.

External payloads belong to the application. Supply both canDropExternal(event, target) and onExternalDrop(target, event). The former recognizes supported payload types during dragover; the latter reads their data and updates the controlled state. A child that calls preventDefault() on its drop owns that drop, so a text editor or composer can consume files itself.

Tabs close immediately by default. Supply onClose(id) to request confirmation, save changes or close an application resource. With this callback, the component does not remove the tab; update the state after approval. onTabDoubleClick and onTabContextMenu likewise let the application implement pinning and menus.

Tab arrows, Home and End change the active tab; Delete requests a close. Alt + arrow keys focus an adjacent pane from a panel header or the panel itself; keys inside an editor remain with that editor. Focusable resize separators support arrow keys, Shift for larger steps, Home/End for limits and Enter for equal distribution. Dragging within 8px of an even split snaps the two neighboring panes to equal sizes. Double-click balances those neighbors immediately. Alt mirrors the divider when a matching divider exists; Alt + Enter or Alt + double-click distributes the chosen direction across the entire layout, including ancestors and nested splits. For example, triggering it between two small panels can also resize larger panels beside their ancestors. Heights stay unchanged when distributing widths, and widths stay unchanged when distributing heights. Resize feedback is the same 2px accent line in standard and roomy layouts. Pointer cancellation and lost capture end the resize.

Floating windows, pop-outs and cross-window drag coordination are outside this module.

Licenses and third-party notices are listed in each package.