Skip to content

Toolbar

<Toolbar> is a vertical (or horizontal) strip that hosts action buttons, mutually-exclusive radio tool groups, and independent toggle modifiers. Its state lives library-wide inside DockableDesktopProvider, so any panel can read or change the active tool via the useToolbar() hook.

Basic usage

Place <Toolbar> as a sibling of <Sidebar> and <WindowManager> inside your workspace container. Both components sit side-by-side — neither wraps the other.

tsx
import {
  DockableDesktopProvider,
  WindowManager,
  Sidebar,
  Toolbar,
  type ToolbarItem,
} from 'react-dockable-desktop';
import { useState } from 'react';

const toolbarItems: ToolbarItem[] = [
  {
    type: 'radio', id: 'tool-cursor', group: 'tool', label: 'Select',
    icon: <CursorIcon />,
  },
  {
    type: 'radio', id: 'tool-pen', group: 'tool', label: 'Draw',
    icon: <PenIcon />,
  },
  { type: 'separator' },
  {
    type: 'toggle', id: 'snap', label: 'Snap to Grid',
    icon: <SnapIcon />,
  },
  { type: 'separator' },
  {
    type: 'action', id: 'layers', label: 'Open Layers',
    icon: <LayersIcon />,
    onClick: () => openPanel('layertree-main', 'layertree'),
  },
];

export default function App() {
  return (
    <DockableDesktopProvider client={workspace}>
      <div style={{ display: 'flex', width: '100vw', height: '100vh', overflow: 'hidden' }}>
        <Toolbar position="left" items={toolbarItems} />
        <Sidebar position="right" tabs={sidebarTabs}>
          <WindowManager />
        </Sidebar>
      </div>
    </DockableDesktopProvider>
  );
}

Item types

typeExtra propsDescription
'action'onClickOne-shot action button. Click fires onClick and does not change radio/toggle state.
'radio'group, onActivate?Mutually-exclusive within a named group. Only one item per group can be active at a time.
'toggle'onToggle?Independent on/off modifier. Multiple toggles can be active simultaneously.
'group'defaultIcon, items, activeItemId?, onActiveItemChange?Collapsed tool-family that opens a flyout listing sub-tools with radio semantics. See Group items below.
'separator'A thin visual divider between button groups.

Every item except 'separator' also accepts:

PropTypeDescription
idstringUnique key for this item.
labelstringTooltip / accessible label.
iconReactNodeIcon displayed in the button. Recommended: 16×16 SVG, stroke="currentColor".
disabled?booleanDisables the button; renders at 35% opacity.

Group items

A 'group' item is a single button that expands into a flyout panel listing mutually-exclusive sub-tools. The button's icon morphs to show whichever sub-tool is currently active. Sub-tools have the same radio semantics as 'radio' items — only one can be active at a time.

tsx
const toolbarItems: ToolbarItem[] = [
  {
    type: 'group',
    id: 'brush-tool',
    label: 'Brush tools',
    defaultIcon: <BrushIcon />,     // shown when nothing is selected
    items: [
      { id: 'brush-pencil', label: 'Pencil', icon: <PencilIcon />, shortcut: 'B',
        onActivate: id => console.log('activated', id) },
      { id: 'brush-eraser', label: 'Eraser', icon: <EraserIcon />, shortcut: 'E' },
      { type: 'separator' },
      { id: 'brush-fill',   label: 'Fill',   icon: <FillIcon /> },
    ],
  },
];

ToolbarGroupItem props

PropTypeRequiredDescription
type'group'Item type discriminant.
idstringButton ID and radio group key in ToolbarContext.
labelstringTooltip shown when no sub-item is active.
defaultIconReactNodeIcon shown when no sub-item is active.
itemsToolbarGroupEntry[]Sub-items and separators in the flyout.
disabled?booleanDisables the entire group button.
activeItemId?string | nullControlled mode. When provided (even as null), the component reads this prop instead of ToolbarContext and calls onActiveItemChange on click without updating context. Omit for uncontrolled.
onActiveItemChange?(id: string) => voidControlled mode. Called when the user selects a sub-item. You must update activeItemId in response.

Controlled vs uncontrolled

Uncontrolled (default): omit activeItemId. The active sub-item is stored in ToolbarContext and readable via getActiveInGroup(id).

tsx
// Uncontrolled — read via useToolbar()
const { getActiveInGroup } = useToolbar();
const activeBrush = getActiveInGroup('brush-tool'); // 'brush-pencil' | 'brush-eraser' | ... | null

Controlled: provide activeItemId and wire onActiveItemChange to your state. The Toolbar never modifies context — you are the single source of truth.

tsx
const [activeBrush, setActiveBrush] = useState<string | null>(null);

<Toolbar items={[{
  type: 'group', id: 'brush-tool', label: 'Brush tools',
  defaultIcon: <BrushIcon />,
  items: brushSubItems,
  activeItemId: activeBrush,
  onActiveItemChange: setActiveBrush,
}]} />

ToolbarProps reference

PropTypeDefaultDescription
itemsToolbarItem[]Required. Ordered list of items to render.
position'left' | 'right' | 'top' | 'bottom''left'Side the strip attaches to. Controls orientation (vertical vs horizontal) and border direction.
visiblebooleantrueCollapse the strip to zero width/height via CSS transition. State is preserved — no unmount.
onVisibilityChange(visible: boolean) => voidCalled when show/hide/toggle is invoked on the imperative handle. Wire to your state setter.
classNamestringAdditional CSS class applied to the root div.
styleReact.CSSPropertiesInline style merged after the collapse style.

visible + ToolbarHandle

visible is a controlled prop — the consumer owns the boolean via useState:

tsx
const [showToolbar, setShowToolbar] = useState(true);

<Toolbar
  position="left"
  items={toolbarItems}
  visible={showToolbar}
  onVisibilityChange={setShowToolbar}
/>

// Anywhere in the UI:
<button onClick={() => setShowToolbar(v => !v)}>Toggle Toolbar</button>

For imperative control, use forwardRef and ToolbarHandle:

tsx
import { useRef } from 'react';
import type { ToolbarHandle } from 'react-dockable-desktop';

const toolbarRef = useRef<ToolbarHandle>(null);

<Toolbar ref={toolbarRef} ... visible={showToolbar} onVisibilityChange={setShowToolbar} />

// Imperative methods delegate to onVisibilityChange:
toolbarRef.current?.show();
toolbarRef.current?.hide();
toolbarRef.current?.toggle();
MethodDescription
show()Calls onVisibilityChange(true).
hide()Calls onVisibilityChange(false).
toggle()Calls onVisibilityChange(!current).

useToolbar() hook

Read and write toolbar state from any component inside <DockableDesktopProvider> — including floating panels and docked panels far removed from the <Toolbar> component itself:

tsx
import { useToolbar } from 'react-dockable-desktop';

function MapPanel() {
  const { getActiveInGroup, isModifierActive } = useToolbar();
  const activeTool = getActiveInGroup('tool'); // 'tool-cursor' | 'tool-pen' | null
  const snapActive  = isModifierActive('snap');

  // React to toolbar state in your map controller
}
ValueTypeDescription
getActiveInGroup(group)(group: string) => string | nullReturns the active radio item id in a group, or null if none selected.
setActiveInGroup(group, id)(group: string, id: string | null) => voidProgrammatically activate a radio item (or deselect with null).
isModifierActive(id)(id: string) => booleanReturns true if a toggle modifier is active.
setModifierActive(id, active)(id: string, active: boolean) => voidExplicitly set a toggle modifier's state.
toggleModifier(id)(id: string) => voidFlip a toggle modifier.

TIP

useToolbar() returns a no-op object (and logs a warning) when called outside <DockableDesktopProvider>. This makes it safe to use in reusable components that might be rendered in tests or outside the provider.

External control pattern

Toolbars often need to reflect state changes that originate outside the UI — for example, a map SDK that fires a controllerchanged event when the user activates a tool programmatically. Use setActiveInGroup to sync back:

tsx
import { useToolbar } from 'react-dockable-desktop';

function MapController() {
  const { setActiveInGroup } = useToolbar();

  useEffect(() => {
    const unsubscribe = mapController.on('controllerchanged', ({ id }) => {
      // Mirror external tool change to the toolbar
      setActiveInGroup('tool', id);
    });
    return unsubscribe;
  }, [setActiveInGroup]);

  // ...
}

Position variants

tsx
// Vertical strips (width: 48px, height: 100%)
<Toolbar position="left"   items={items} />   // border on the right
<Toolbar position="right"  items={items} />   // border on the left

// Horizontal strips (height: 48px, width: 100%)
<Toolbar position="top"    items={items} />   // border on the bottom
<Toolbar position="bottom" items={items} />   // border on the top

The active accent border on radio items always faces the workspace (inward-facing edge). On a left toolbar, the accent bar is on the left edge of the button (flush with the workspace); on a top toolbar, it's on the bottom edge. On skins like slate and macos the bar is replaced entirely by a floating chip shape; on nord it becomes a short horizontal line drawn below the icon.

Theming CSS variables

All toolbar colors use CSS custom properties that cascade from [data-color-scheme] and [data-workspace-skin]. You can override them for your own skin without touching component CSS:

VariableLightDarkDescription
--toolbar-btn-hover-bgrgba(0,0,0,.05)rgba(255,255,255,.06)Button hover background.
--toolbar-btn-radio-active-bgrgba(0,102,204,.1)rgba(56,189,248,.14)Radio active button background tint.
--toolbar-btn-toggle-active-bgrgba(0,102,204,.06)rgba(56,189,248,.08)Toggle active button background tint.
--toolbar-separator-colorrgba(0,0,0,.1)rgba(255,255,255,.09)Separator line color.
--tab-icon-active#0066cc#38bdf8Accent color for active radio button icon and border. Shared with the Sidebar strip.
--toolbar-btn-active-shadownonenonebox-shadow on active radio/group buttons. Obsidian/Tokyo override with an inset ambient glow.
--toolbar-btn-active-glownonenonefilter on active radio/group buttons. Obsidian/Tokyo add drop-shadow() for icon glow.
--toolbar-accent-bar-width3px3pxWidth of the edge accent bar on active buttons. Set to 0px to replace the bar with a chip shape.

The accent variables are automatically overridden per skin — Nord, Tokyo Night, Obsidian, Chrome, Slate, and macOS each set their own --tab-icon-active and toolbar active-state tokens to match their signature visual language. See Per-skin active state design language → for details on customising this in your own skin.

See also

Released under the MIT License.