Modals & Side Panels
react-dockable-desktop includes a fully integrated overlay system: a modal stack, a left drawer, and a right drawer. All three share the same dirty-state and close-guard machinery as regular panels.
Setup
The overlay system is provided by PanelProvider. If you use DockableDesktopProvider (recommended), it is already included.
You must also place the two renderer components in the correct positions in your tree:
// App.tsx
import {
DockableDesktopProvider,
WindowManager,
ModalStackRenderer,
SidePanelRenderer,
} from 'react-dockable-desktop';
export default function App() {
return (
<DockableDesktopProvider client={workspace}>
<div style={{ width: '100vw', height: '100vh', overflow: 'hidden', position: 'relative' }}>
<WindowManager />
<SidePanelRenderer /> {/* inside the sized container — used for positioning drawers */}
</div>
<ModalStackRenderer /> {/* outside the sized container — full-screen overlay */}
</DockableDesktopProvider>
);
}Placement matters
SidePanelRenderermust be a sibling ofWindowManager, inside the positioned container. Drawers position themselves relative to this container.ModalStackRenderermust be outside that container so modals can overlay the entire viewport.
usePanelActions()
All overlay operations go through the usePanelActions() hook, available in any component inside the provider:
import { usePanelActions } from 'react-dockable-desktop';
function MyComponent() {
const { openModal, openLeftPanel, openRightPanel, close, closeAll } = usePanelActions();
}Opening a modal
const id = openModal(Component, props, options?);openModal pushes a new modal onto the stack and returns the instance ID. The modal appears on top of the workspace.
function LaunchButton() {
const { openModal, close } = usePanelActions();
const handleClick = () => {
const id = openModal(SettingsPanel, { section: 'general' }, {
title: 'Settings',
size: 'large',
});
// id can be used later: close(id), actions.getInstance(id), etc.
};
return <button onClick={handleClick}>Settings</button>;
}ModalOptions
| Option | Type | Default | Description |
|---|---|---|---|
title | string | — | Modal header title. |
icon | ReactNode | — | Icon displayed in the title bar. |
size | 'small' | 'medium' | 'large' | 'fullscreen' | 'auto' | 'medium' | Controls max-width of the modal. |
closable | boolean | true | When false, hides the × button and disables backdrop click-to-close. |
Opening a side drawer
Drawers slide in from the left or right edge of the workspace container.
const id = await openLeftPanel(Component, props, options?);
const id = await openRightPanel(Component, props, options?);const { openRightPanel, close } = usePanelActions();
const showDetails = async () => {
const id = await openRightPanel(DetailsPanel, { itemId: 'abc' }, {
title: 'Item Details',
width: 380,
});
};SidePanelOptions
| Option | Type | Default | Description |
|---|---|---|---|
title | string | — | Drawer header title. |
icon | ReactNode | — | Icon next to the title. |
width | number | string | '320px' | Drawer width. Numbers are treated as pixels; strings as CSS values (e.g. '40%'). |
Closing panels
// Close one instance by ID (works for modals and drawers):
close(id);
// Close everything — all modals and both drawers:
closeAll();
// Close only modals, leave drawers open:
closeAllModals();From inside a panel component, use useFormContainer().requestClose() instead:
const container = useFormContainer();
container.requestClose(); // respects dirty-state guard
container.requestClose({ force: true }); // bypasses all guardsDirty state in modals
The same dirty-state mechanism works inside modals. Call container.setDirty(true) inside your modal component and the user will see the confirmation dialog before the modal closes:
function EditModal() {
const container = useFormContainer();
const [saved, setSaved] = useState(false);
const handleInput = () => container.setDirty(true);
const handleSave = () => { save(); setSaved(true); container.setDirty(false); };
return (
<div>
<input onChange={handleInput} />
<button onClick={handleSave}>Save</button>
</div>
);
}Stacking modals
Multiple openModal calls stack visually. The topmost modal is active; pressing ESC or clicking the backdrop closes only the topmost.
const id1 = openModal(StepOneModal, {});
// User action opens a second modal on top:
const id2 = openModal(ConfirmationForm, {
message: 'Continue to step 2?',
onOK: () => { close(id2); advance(); },
onCancel: () => close(id2),
});ConfirmationForm — built-in yes/no dialog
Import and use ConfirmationForm directly in openModal for quick confirmations without writing a custom component:
import { ConfirmationForm } from 'react-dockable-desktop';
const { openModal, close } = usePanelActions();
const confirm = () => {
const id = openModal(ConfirmationForm, {
title: 'Delete item',
message: 'This will permanently delete the item.',
alert: 'This cannot be undone.',
alertType: 'danger',
useYesNoTitles: true,
onOK: () => { close(id); deleteItem(); },
onCancel: () => close(id),
});
};See Panel Lifecycle & Forms → for the full props reference.
Sidebar component
Sidebar is a composite layout component that renders a vertical tab strip and a collapsible drawer panel. It handles all open/close animation, keyboard navigation, and state preservation internally.
import { Sidebar, type SidebarHandle } from 'react-dockable-desktop';
import { useRef } from 'react';
const sidebarRef = useRef<SidebarHandle>(null);
<Sidebar
ref={sidebarRef}
position="right"
drawerWidth="280px"
tabs={[
{
id: 'layers',
label: 'Layers',
icon: <LayersIcon />,
renderContent: (tabId, onClose, onOpen) => (
<LayerTree onLayerSelect={() => onOpen()} />
),
},
{
id: 'properties',
label: 'Properties',
icon: <SettingsIcon />,
preserveState: true, // keep alive when not visible
renderContent: () => <PropertiesPanel />,
},
]}
>
<MainMapArea /> {/* rendered in the space beside the sidebar */}
</Sidebar>SidebarTab
| Prop | Type | Required | Description |
|---|---|---|---|
id | string | ✓ | Unique key for this tab. |
label | string | ✓ | Tooltip / accessible label for the tab icon button. |
icon | ReactNode | ✓ | Icon displayed in the tab strip. |
renderContent | (tabId, onClose, onOpen) => ReactNode | ✓ | Returns the drawer content. onClose collapses the drawer; onOpen expands it to this tab. |
eagerMount | boolean | — | Mount immediately on sidebar render (before the user clicks). Implies preserveState: true. Use when other parts of the app need to interact with the panel before the user opens it. |
preserveState | boolean | — | Keep the component alive in the DOM behind display: none when closed, instead of unmounting it. |
SidebarProps
| Prop | Type | Default | Description |
|---|---|---|---|
tabs | SidebarTab[] | — | Required. Tab definitions. |
position | 'left' | 'right' | 'right' | Side the tab strip and drawer appear on. |
defaultWidth | number | 220 | Initial drawer width in pixels. |
minWidth | number | 150 | Minimum drawer width in pixels during drag-resize. |
maxWidth | number | 600 | Maximum drawer width in pixels during drag-resize. |
onWidthChange | (px: number) => void | — | Called during drag-resize and when setWidth() is invoked. |
activeTabId | string | null | — | Controlled active tab. Use with onActiveTabChange for fully-controlled mode. |
onActiveTabChange | (tabId: string | null) => void | — | Called when the active tab changes. |
visible | boolean | true | Collapse the entire sidebar (strip + drawer) to zero width via CSS transition. State is preserved — no unmount. |
onVisibilityChange | (visible: boolean) => void | — | Called when show/hide/toggle is invoked on the imperative handle. Wire to your useState setter. |
stripVisible | boolean | true | Collapse only the activity bar strip, leaving the drawer unaffected. |
onStripVisibilityChange | (visible: boolean) => void | — | Called when showStrip/hideStrip is invoked on the imperative handle. |
children | ReactNode | — | Main content (rendered in the area beside the sidebar). |
drawerWidth | string | — | Deprecated. Use defaultWidth (number, pixels) instead. |
Active tab styling — The visual treatment of the active tab button (shape, fill, indicator) is controlled entirely by CSS design tokens and varies per skin. vscode uses a transparent fill with a 2 px accent bar; macos renders a floating glass chip; nord draws a short horizontal line below the icon. See Per-skin active state design language → to customise this in your own skin.
SidebarHandle imperative ref
Obtain with useRef<SidebarHandle>():
// Open a specific tab programmatically (e.g. when new data arrives):
sidebarRef.current?.openTab('layers');
// Collapse the drawer:
sidebarRef.current?.closeDrawer();
// Query current state:
const activeTab = sidebarRef.current?.getActiveTab(); // → string | null| Method | Returns | Description |
|---|---|---|
openTab(tabId) | void | Expand drawer and activate the specified tab. |
closeDrawer() | void | Collapse the drawer. |
getActiveTab() | string | null | Currently active tab ID, or null if collapsed. |
show() | void | Show the entire sidebar (calls onVisibilityChange(true)). |
hide() | void | Hide the entire sidebar (calls onVisibilityChange(false)). |
toggle() | void | Toggle sidebar visibility. |
showStrip() | void | Show only the activity bar strip (calls onStripVisibilityChange(true)). |
hideStrip() | void | Hide only the activity bar strip (calls onStripVisibilityChange(false)). |
setWidth(px) | void | Programmatically set the drawer width in pixels (respects minWidth/maxWidth). |
getWidth() | number | Returns the current drawer width in pixels. |
Sidebar hooks
Two hooks let panels control the Sidebar without a ref or prop drilling.
useSidebar()
Available to any component inside a <Sidebar> tree — including floating panels and docked panels rendered via {children}:
import { useSidebar } from 'react-dockable-desktop';
function LayerTree() {
const { openTab, closeDrawer, getActiveTab } = useSidebar();
return (
<button onClick={() => openTab('search')}>
Show Search Results
</button>
);
}| Value | Type | Description |
|---|---|---|
openTab(tabId) | (tabId: string) => void | Expand the drawer and activate the given tab. |
closeDrawer() | () => void | Collapse the drawer. |
getActiveTab() | () => string | null | Returns the current tab ID, or null if collapsed. |
TIP
useSidebar() returns a no-op object (and logs a warning) when called outside a <Sidebar> tree. This makes it safe to use in reusable panel components that may be rendered with or without a surrounding Sidebar.
useSidebarTab()
Available to components rendered inside a tab's renderContent tree. Provides both self-control and cross-tab navigation:
import { useSidebarTab } from 'react-dockable-desktop';
function SearchResultsPanel() {
const { tabId, onOpen, onClose, openTab } = useSidebarTab();
return (
<div>
<button onClick={onClose}>Collapse</button>
<button onClick={() => openTab('settings')}>Open Settings</button>
</div>
);
}
// In your tab config — the content must be a React component, not an inline arrow function,
// because useSidebarTab() uses React hooks internally:
{
id: 'search',
label: 'Search Results',
icon: <SearchIcon />,
renderContent: () => <SearchResultsPanel />,
}| Value | Type | Description |
|---|---|---|
tabId | string | The ID of this tab. |
onOpen() | () => void | Expand the drawer and activate this tab. |
onClose() | () => void | Collapse the drawer. |
openTab(tabId) | (tabId: string) => void | Switch to a different tab. |
Cross-panel pattern: floating window → sidebar tab
A floating panel can open a sidebar tab and broadcast data in a single action:
import { useSidebar, usePanelContext } from 'react-dockable-desktop';
function LayerTree() {
const { openTab } = useSidebar();
const { publish } = usePanelContext();
const handleSearch = (query: string) => {
const results = performSearch(query);
publish('search:results', results); // SearchResultsPanel subscribes to this
openTab('search'); // expand sidebar to show it
};
// ...
}For the reactive variant — where the sidebar tab opens itself when it receives data — see Event Bus & Communication →.
Opening a tab in response to data
Use eagerMount + onOpen when a background process needs to surface data in the sidebar before the user has clicked:
{
id: 'alerts',
label: 'Alerts',
icon: <AlertIcon />,
eagerMount: true, // mount immediately so the panel can receive events
renderContent: (tabId, onClose, onOpen) => (
<AlertsPanel
onNewAlert={() => onOpen()} // expand sidebar when a new alert arrives
/>
),
}usePanelContextMenu() hook
Inject custom items into a panel's right-click context menu from inside the panel component. The hook reads the panel ID internally — no prop needed. Items are dynamic: the array is re-read every time the menu opens, so state-driven changes (enable/disable, add/remove) take effect automatically.
import { usePanelContextMenu } from 'react-dockable-desktop';
function EditorPanel() {
const [dirty, setDirty] = useState(false);
usePanelContextMenu([
{ label: 'Save', action: () => save(), disabled: !dirty },
{ label: 'Revert', action: () => revert(), disabled: !dirty },
{ type: 'separator' },
{ label: 'Copy Panel Link', action: () => copyLink() },
]);
return <Editor onChange={() => setDirty(true)} />;
}The items array accepts ContextMenuItem entries exported from react-dockable-desktop:
| Shape | Description |
|---|---|
{ label, icon?, action, title? } | A clickable menu item. |
{ separator: true } | A visual divider. |
{ label, items: [...] } | A sub-menu (one level deep). |
{ label, checkbox: { active?, enabled, value }, action } | A checkbox item. |
See the Context Menus guide for the full type reference.
TIP
usePanelContextMenu is safe to call unconditionally — it is a no-op when the component renders outside a DockableDesktopProvider (e.g., in tests).
PanelActions reference
interface PanelActions {
openModal<P>(Component: ComponentType<P>, props: P, options?: ModalOptions): string;
openLeftPanel<P>(Component: ComponentType<P>, props: P, options?: SidePanelOptions): Promise<string | null>;
openRightPanel<P>(Component: ComponentType<P>, props: P, options?: SidePanelOptions): Promise<string | null>;
close(id: string): void;
closeAll(): void;
closeAllModals(): void;
getInstance(id: string): PanelInstance | undefined;
setDirty(id: string, dirty: boolean, options?: DirtyStateOptions): void;
}See also
- Panel Lifecycle & Forms → — dirty state, close guards,
useFormContainer - Event Bus & Communication → — panels communicating via pub/sub
- Quick Start → — where to place
ModalStackRendererandSidePanelRenderer