Skip to main content
Nexus Design System
GitHub

Toolbar

Toolbar groups related controls behind one Tab stop. Arrow keys move between controls, and Home and End jump to the first and last. For actions that each need their own Tab stop, use a row of Buttons.

  • Label the toolbar with aria-label or aria-labelledby.
  • Build it from ToolbarButton, ToolbarLink, ToolbarToggleGroup and ToolbarToggleItem so every control joins the arrow-key order.
  • For tools with options, use ToolbarToolGroup, ToolbarTool and ToolbarToolPanel. Choosing another tool swaps the open panel. Escape returns focus to the tool.
  • ToolbarButton and ToolbarLink take Button variant and size, but not loading or icon slots.
  • Use variant="floating" for a toolbar that sits over content, like a canvas.
  • Use asChild to make a toolbar control a menu trigger.
  • Keep text fields outside the toolbar, because arrow keys would conflict with typing.
  • In narrow containers, controls wrap in DOM order and arrow keys still reach every row.
  • Your app owns the commands, selection, visibility and placement.

Select formatting to update this preview.

'use client';

import * as React from 'react';

import {
  ArrowUUpLeftIcon,
  TextBIcon,
  TextItalicIcon,
  TextUnderlineIcon,
} from '@phosphor-icons/react/ssr';

import {
  Toolbar,
  ToolbarButton,
  ToolbarLink,
  ToolbarSeparator,
  ToolbarToggleGroup,
  ToolbarToggleItem,
} from '@/components/nxds/components/toolbar/toolbar';
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/nxds/components/tooltip/tooltip';

function EditorToolbar() {
  const previewId = React.useId();
  const [formatting, setFormatting] = React.useState<string[]>([]);
  return (
    <div className="nx:grid nx:gap-4">
      <Toolbar aria-label="Text formatting">
        <Tooltip>
          <TooltipTrigger asChild>
            <span className="nx:inline-flex">
              <ToolbarButton
                size="icon-sm"
                aria-label="Undo formatting"
                onClick={() => setFormatting([])}
                className="nx:text-muted-foreground"
              >
                <ArrowUUpLeftIcon aria-hidden="true" />
              </ToolbarButton>
            </span>
          </TooltipTrigger>
          <TooltipContent>Reset formatting</TooltipContent>
        </Tooltip>
        <ToolbarSeparator className="nx:data-[orientation=vertical]:h-4" />
        <ToolbarToggleGroup
          type="multiple"
          value={formatting}
          onValueChange={setFormatting}
          aria-label="Text style"
        >
          <Tooltip>
            <TooltipTrigger asChild>
              <span className="nx:inline-flex">
                <ToolbarToggleItem
                  value="bold"
                  aria-label="Bold"
                  size="icon-sm"
                >
                  <TextBIcon aria-hidden="true" />
                </ToolbarToggleItem>
              </span>
            </TooltipTrigger>
            <TooltipContent>Bold</TooltipContent>
          </Tooltip>
          <Tooltip>
            <TooltipTrigger asChild>
              <span className="nx:inline-flex">
                <ToolbarToggleItem
                  value="italic"
                  aria-label="Italic"
                  size="icon-sm"
                >
                  <TextItalicIcon aria-hidden="true" />
                </ToolbarToggleItem>
              </span>
            </TooltipTrigger>
            <TooltipContent>Italic</TooltipContent>
          </Tooltip>
          <Tooltip>
            <TooltipTrigger asChild>
              <span className="nx:inline-flex">
                <ToolbarToggleItem
                  value="underline"
                  aria-label="Underline"
                  size="icon-sm"
                >
                  <TextUnderlineIcon aria-hidden="true" />
                </ToolbarToggleItem>
              </span>
            </TooltipTrigger>
            <TooltipContent>Underline</TooltipContent>
          </Tooltip>
        </ToolbarToggleGroup>
        <ToolbarSeparator className="nx:data-[orientation=vertical]:h-4" />
        <ToolbarLink
          className="nx:text-muted-foreground"
          href={`#${previewId}`}
        >
          Preview
        </ToolbarLink>
      </Toolbar>
      <p
        id={previewId}
        className="nx:typography-body-default nx:text-muted-foreground"
        style={{
          fontWeight: formatting.includes('bold') ? 700 : undefined,
          fontStyle: formatting.includes('italic') ? 'italic' : undefined,
          textDecoration: formatting.includes('underline')
            ? 'underline'
            : undefined,
        }}
      >
        Select formatting to update this preview.
      </p>
    </div>
  );
}

export default function ToolbarDefault() {
  return <EditorToolbar />;
}

Installation

npx nxds-cli@latest add toolbar

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import {
  Toolbar,
  ToolbarButton,
  ToolbarGroup,
  ToolbarLink,
  ToolbarSeparator,
  ToolbarToggleGroup,
  ToolbarToggleItem,
  ToolbarTool,
  ToolbarToolGroup,
  ToolbarToolPanel,
} from '@/components/nxds/components/toolbar/toolbar';

Composition

Toolbar
├── ToolbarButton
├── ToolbarSeparator
├── ToolbarToggleGroup
│   └── ToolbarToggleItem
├── ToolbarLink
├── ToolbarGroup
│   └── ToolbarButton
└── ToolbarToolGroup
    ├── ToolbarTool
    └── ToolbarToolPanel

Examples

File actions

Group file actions and move extra ones into a "More" dropdown menu.

'use client';

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/nxds/components/dropdown-menu/dropdown-menu';
import {
  Toolbar,
  ToolbarButton,
  ToolbarGroup,
  ToolbarSeparator,
} from '@/components/nxds/components/toolbar/toolbar';
import { IconDots } from '@/components/nxds/lib/icons';

function FileToolbar() {
  return (
    <Toolbar aria-label="File actions">
      <ToolbarGroup aria-label="Selection actions">
        <ToolbarButton variant="outline">Download</ToolbarButton>
        <ToolbarButton>Move</ToolbarButton>
        <ToolbarButton disabled>Share</ToolbarButton>
      </ToolbarGroup>
      <ToolbarSeparator />
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <ToolbarButton size="icon-sm" aria-label="More file actions">
            <IconDots />
          </ToolbarButton>
        </DropdownMenuTrigger>
        <DropdownMenuContent>
          <DropdownMenuItem>Rename</DropdownMenuItem>
          <DropdownMenuItem>Duplicate</DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    </Toolbar>
  );
}

export default function ToolbarFileActions() {
  return <FileToolbar />;
}

This example also needs: DropdownMenu.

Calendar

Pair previous and next buttons with a single-select view switcher.

'use client';

import * as React from 'react';

import {
  Toolbar,
  ToolbarButton,
  ToolbarGroup,
  ToolbarToggleGroup,
  ToolbarToggleItem,
} from '@/components/nxds/components/toolbar/toolbar';
import { IconChevronLeft, IconChevronRight } from '@/components/nxds/lib/icons';

function CalendarToolbar() {
  const [view, setView] = React.useState('week');
  function handleViewChange(value: string) {
    if (value) setView(value);
  }
  return (
    <Toolbar aria-label="Calendar controls">
      <ToolbarGroup aria-label="Date navigation">
        <ToolbarButton size="icon-sm" aria-label="Previous period">
          <IconChevronLeft />
        </ToolbarButton>
        <ToolbarButton>Today</ToolbarButton>
        <ToolbarButton size="icon-sm" aria-label="Next period">
          <IconChevronRight />
        </ToolbarButton>
      </ToolbarGroup>
      <ToolbarToggleGroup
        className="nx:ms-auto"
        type="single"
        value={view}
        onValueChange={handleViewChange}
        aria-label="Calendar view"
      >
        <ToolbarToggleItem value="day">Day</ToolbarToggleItem>
        <ToolbarToggleItem value="week">Week</ToolbarToggleItem>
        <ToolbarToggleItem value="month">Month</ToolbarToggleItem>
      </ToolbarToggleGroup>
    </Toolbar>
  );
}

export default function ToolbarCalendar() {
  return <CalendarToolbar />;
}

Vertical

Set orientation="vertical" to stack the controls in a column.

'use client';

import {
  Toolbar,
  ToolbarButton,
  ToolbarSeparator,
} from '@/components/nxds/components/toolbar/toolbar';

export default function ToolbarVertical() {
  return (
    <Toolbar orientation="vertical" aria-label="Drawing actions">
      <ToolbarButton>Select</ToolbarButton>
      <ToolbarButton>Draw</ToolbarButton>
      <ToolbarSeparator />
      <ToolbarButton>Erase</ToolbarButton>
    </Toolbar>
  );
}

Floating

'use client';

import {
  ArrowUUpLeftIcon,
  CursorIcon,
  EraserIcon,
  PencilSimpleIcon,
} from '@phosphor-icons/react/ssr';

import {
  Toolbar,
  ToolbarButton,
  ToolbarSeparator,
  ToolbarTool,
  ToolbarToolGroup,
} from '@/components/nxds/components/toolbar/toolbar';

export default function ToolbarFloating() {
  return (
    <div className="nx:flex nx:min-h-40 nx:items-end nx:justify-center">
      <Toolbar variant="floating" aria-label="Canvas">
        <ToolbarToolGroup aria-label="Drawing tools" defaultValue="select">
          <ToolbarTool value="select" size="icon-sm" aria-label="Select">
            <CursorIcon aria-hidden="true" />
          </ToolbarTool>
          <ToolbarTool value="draw" size="icon-sm" aria-label="Draw">
            <PencilSimpleIcon aria-hidden="true" />
          </ToolbarTool>
          <ToolbarTool value="erase" size="icon-sm" aria-label="Erase">
            <EraserIcon aria-hidden="true" />
          </ToolbarTool>
        </ToolbarToolGroup>
        <ToolbarSeparator />
        <ToolbarButton size="icon-sm" aria-label="Undo">
          <ArrowUUpLeftIcon aria-hidden="true" />
        </ToolbarButton>
      </Toolbar>
    </div>
  );
}

Disabled toggles

Disable a toggle group while the rest of the toolbar stays usable.

'use client';

import {
  Toolbar,
  ToolbarButton,
  ToolbarToggleGroup,
  ToolbarToggleItem,
} from '@/components/nxds/components/toolbar/toolbar';

export default function ToolbarDisabledToggles() {
  return (
    <Toolbar aria-label="Formatting availability">
      <ToolbarButton>Undo</ToolbarButton>
      <ToolbarToggleGroup
        type="multiple"
        disabled
        aria-label="Unavailable formatting"
      >
        <ToolbarToggleItem value="bold">Bold</ToolbarToggleItem>
        <ToolbarToggleItem value="italic">Italic</ToolbarToggleItem>
      </ToolbarToggleGroup>
      <ToolbarButton>Preview</ToolbarButton>
    </Toolbar>
  );
}

Inline

Restyle the toolbar as a flush row above a list, with a view filter.

Documents

Keep your working files close at hand.

  • Project brief · Today
  • Research notes · Last week
'use client';

import * as React from 'react';

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/nxds/components/dropdown-menu/dropdown-menu';
import {
  Toolbar,
  ToolbarButton,
  ToolbarToggleGroup,
  ToolbarToggleItem,
} from '@/components/nxds/components/toolbar/toolbar';
import { IconChevronDown } from '@/components/nxds/lib/icons';

function InlineExample() {
  const [view, setView] = React.useState('all');
  return (
    <section className="nx:grid nx:gap-4">
      <div>
        <h2 className="nx:typography-heading-small">Documents</h2>
        <p className="nx:typography-body-default nx:text-muted-foreground">
          Keep your working files close at hand.
        </p>
      </div>
      <Toolbar
        aria-label="Document view"
        className="nx:w-full nx:rounded-none nx:border-0 nx:border-b-default nx:shadow-none nx:bg-transparent nx:p-0 nx:pb-3"
      >
        <ToolbarToggleGroup
          type="single"
          value={view}
          onValueChange={(value) => {
            if (value) setView(value);
          }}
          aria-label="Show documents"
        >
          <ToolbarToggleItem value="all">All documents</ToolbarToggleItem>
          <ToolbarToggleItem value="recent">Recent</ToolbarToggleItem>
        </ToolbarToggleGroup>
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <ToolbarButton className="nx:ms-auto">
              Show <IconChevronDown aria-hidden="true" />
            </ToolbarButton>
          </DropdownMenuTrigger>
          <DropdownMenuContent>
            <DropdownMenuItem onSelect={() => setView('all')}>
              All documents
            </DropdownMenuItem>
            <DropdownMenuItem onSelect={() => setView('recent')}>
              Recent documents
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </Toolbar>
      <ul className="nx:grid nx:gap-3 nx:typography-body-default">
        <li>
          Project brief{' '}
          <span className="nx:text-muted-foreground">· Today</span>
        </li>
        {view === 'all' && (
          <li>
            Research notes{' '}
            <span className="nx:text-muted-foreground">· Last week</span>
          </li>
        )}
      </ul>
    </section>
  );
}

export default function ToolbarInline() {
  return <InlineExample />;
}

This example also needs: DropdownMenu.

Attached

Attach the toolbar to the top of a note field to format its text.

Draft a note

Formatting applies to the whole note.

'use client';

import * as React from 'react';

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/nxds/components/dropdown-menu/dropdown-menu';
import {
  Toolbar,
  ToolbarButton,
  ToolbarSeparator,
  ToolbarToggleGroup,
  ToolbarToggleItem,
} from '@/components/nxds/components/toolbar/toolbar';
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/nxds/components/tooltip/tooltip';
import { IconChevronDown } from '@/components/nxds/lib/icons';

function AttachedExample() {
  const [formatting, setFormatting] = React.useState<string[]>([]);
  const [text, setText] = React.useState(
    'Good tools stay close to the work. Draft your next idea here.'
  );
  return (
    <section className="nx:grid nx:gap-3">
      <h2 className="nx:typography-heading-small">Draft a note</h2>
      <div className="nx:rounded-lg nx:border-default nx:border-border-default nx:bg-container">
        <Toolbar
          aria-label="Note formatting"
          className="nx:w-full nx:rounded-none nx:rounded-t-lg nx:border-0 nx:border-b-default nx:border-border-default nx:shadow-none nx:bg-background nx:p-2"
        >
          <ToolbarToggleGroup
            type="multiple"
            value={formatting}
            onValueChange={setFormatting}
            aria-label="Note style"
          >
            <Tooltip>
              <TooltipTrigger asChild>
                <span className="nx:inline-flex">
                  <ToolbarToggleItem
                    value="bold"
                    size="icon-sm"
                    aria-label="Bold note"
                  >
                    <span className="nx:font-bold">B</span>
                  </ToolbarToggleItem>
                </span>
              </TooltipTrigger>
              <TooltipContent>Bold</TooltipContent>
            </Tooltip>
            <Tooltip>
              <TooltipTrigger asChild>
                <span className="nx:inline-flex">
                  <ToolbarToggleItem
                    value="italic"
                    size="icon-sm"
                    aria-label="Italic note"
                  >
                    <span className="nx:italic">I</span>
                  </ToolbarToggleItem>
                </span>
              </TooltipTrigger>
              <TooltipContent>Italic</TooltipContent>
            </Tooltip>
            <Tooltip>
              <TooltipTrigger asChild>
                <span className="nx:inline-flex">
                  <ToolbarToggleItem
                    value="underline"
                    size="icon-sm"
                    aria-label="Underline note"
                  >
                    <span className="nx:underline">U</span>
                  </ToolbarToggleItem>
                </span>
              </TooltipTrigger>
              <TooltipContent>Underline</TooltipContent>
            </Tooltip>
          </ToolbarToggleGroup>
          <ToolbarSeparator />
          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <ToolbarButton>
                More <IconChevronDown aria-hidden="true" />
              </ToolbarButton>
            </DropdownMenuTrigger>
            <DropdownMenuContent>
              <DropdownMenuItem onSelect={() => setFormatting([])}>
                Clear formatting
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
        </Toolbar>
        <textarea
          aria-label="Note"
          value={text}
          onChange={(event) => setText(event.target.value)}
          className="nx:block nx:min-h-32 nx:w-full nx:resize-y nx:rounded-b-lg nx:bg-transparent nx:p-4 nx:typography-body-default nx:text-foreground nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default"
          style={{
            fontWeight: formatting.includes('bold') ? 700 : undefined,
            fontStyle: formatting.includes('italic') ? 'italic' : undefined,
            textDecoration: formatting.includes('underline')
              ? 'underline'
              : undefined,
          }}
        />
      </div>
      <p className="nx:typography-body-small nx:text-muted-foreground">
        Formatting applies to the whole note.
      </p>
    </section>
  );
}

export default function ToolbarAttached() {
  return <AttachedExample />;
}

This example also needs: DropdownMenu.

Selection

Show an action bar for the documents a user has selected.

Select documents

Actions appear when documents are selected.

'use client';

import * as React from 'react';

import { ArchiveIcon } from '@phosphor-icons/react/ssr';

import { Checkbox } from '@/components/nxds/components/checkbox/checkbox';
import { Label } from '@/components/nxds/components/label/label';
import {
  Toolbar,
  ToolbarButton,
  ToolbarSeparator,
} from '@/components/nxds/components/toolbar/toolbar';
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/nxds/components/tooltip/tooltip';
import { IconX } from '@/components/nxds/lib/icons';

const selectionFiles = ['Project brief', 'Research notes', 'Launch checklist'];

function SelectionExample() {
  const selectionId = React.useId();
  const [selected, setSelected] = React.useState<string[]>([
    'Project brief',
    'Research notes',
  ]);
  const [message, setMessage] = React.useState('');
  const [archived, setArchived] = React.useState<string[]>([]);
  const firstCheckbox = React.useRef<HTMLButtonElement>(null);
  const headingRef = React.useRef<HTMLHeadingElement>(null);
  function clearSelection() {
    setSelected([]);
    firstCheckbox.current?.focus();
  }
  function archiveSelection() {
    setMessage(
      `${selected.length} document${selected.length === 1 ? '' : 's'} archived.`
    );
    setArchived((current) => [...current, ...selected]);
    setSelected([]);
    headingRef.current?.focus();
  }
  function toggleSelection(name: string, checked: boolean) {
    setSelected((current) =>
      checked ? [...current, name] : current.filter((item) => item !== name)
    );
    setMessage('');
  }
  return (
    <section className="nx:grid nx:gap-4">
      <div>
        <h2
          ref={headingRef}
          tabIndex={-1}
          className="nx:typography-heading-small nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default"
        >
          Select documents
        </h2>
        <p className="nx:typography-body-default nx:text-muted-foreground">
          Actions appear when documents are selected.
        </p>
      </div>
      <div className="nx:rounded-lg nx:border-default nx:border-border-default nx:bg-container nx:p-3">
        {selectionFiles
          .filter((name) => !archived.includes(name))
          .map((name, index) => (
            <Label
              htmlFor={`${selectionId}-${index}`}
              key={name}
              className="nx:flex nx:min-h-10 nx:cursor-pointer nx:items-center nx:gap-3 nx:px-2 nx:typography-body-default"
            >
              <Checkbox
                id={`${selectionId}-${index}`}
                ref={index === 0 ? firstCheckbox : undefined}
                checked={selected.includes(name)}
                onCheckedChange={(checked) =>
                  toggleSelection(name, checked === true)
                }
              />
              {name}
            </Label>
          ))}
        {archived.length === selectionFiles.length && (
          <p className="nx:p-2 nx:typography-body-default nx:text-muted-foreground">
            No documents remaining.
          </p>
        )}
      </div>
      <div className="nx:flex nx:min-h-16 nx:justify-center">
        {selected.length > 0 && (
          <Toolbar
            aria-label="Selected document actions"
            className="nx:h-fit nx:justify-center nx:gap-0 nx:bg-popover nx:p-0"
          >
            <Tooltip>
              <TooltipTrigger asChild>
                <ToolbarButton
                  size="icon-sm"
                  aria-label="Clear selection"
                  className="nx:text-muted-foreground"
                  onClick={clearSelection}
                >
                  <IconX aria-hidden="true" />
                </ToolbarButton>
              </TooltipTrigger>
              <TooltipContent>Clear selection</TooltipContent>
            </Tooltip>
            <span
              className="nx:px-2 nx:typography-label-default nx:whitespace-nowrap"
              aria-live="polite"
            >
              {selected.length} selected
            </span>
            <ToolbarSeparator />
            <ToolbarButton onClick={archiveSelection}>
              <ArchiveIcon aria-hidden="true" />
              Archive
            </ToolbarButton>
          </Toolbar>
        )}
      </div>
      <p
        role="status"
        className="nx:typography-body-small nx:text-muted-foreground"
      >
        {message}
      </p>
    </section>
  );
}

export default function ToolbarSelection() {
  return <SelectionExample />;
}

This example also needs: Checkbox, Label.

Tools with panels

'use client';

import * as React from 'react';

import {
  ArrowUUpLeftIcon,
  CursorIcon,
  EraserIcon,
  PencilSimpleIcon,
} from '@phosphor-icons/react/ssr';

import { Checkbox } from '@/components/nxds/components/checkbox/checkbox';
import { Label } from '@/components/nxds/components/label/label';
import {
  Toolbar,
  ToolbarButton,
  ToolbarSeparator,
  ToolbarTool,
  ToolbarToolGroup,
  ToolbarToolPanel,
} from '@/components/nxds/components/toolbar/toolbar';

export default function ToolbarToolsWithPanels() {
  const id = React.useId();
  return (
    <div className="nx:flex nx:min-h-64 nx:items-end">
      <Toolbar aria-label="Canvas">
        <ToolbarToolGroup aria-label="Drawing tools" defaultValue="select">
          <ToolbarTool value="select" size="icon-sm" aria-label="Select">
            <CursorIcon aria-hidden="true" />
          </ToolbarTool>
          <ToolbarTool value="draw" size="icon-sm" aria-label="Draw">
            <PencilSimpleIcon aria-hidden="true" />
          </ToolbarTool>
          <ToolbarTool value="erase" size="icon-sm" aria-label="Erase">
            <EraserIcon aria-hidden="true" />
          </ToolbarTool>
          <ToolbarToolPanel value="draw" side="top">
            <div className="nx:flex nx:items-center nx:gap-2">
              <Checkbox id={`${id}-smooth`} defaultChecked />
              <Label htmlFor={`${id}-smooth`}>Smooth strokes</Label>
            </div>
          </ToolbarToolPanel>
          <ToolbarToolPanel value="erase" side="top">
            <div className="nx:flex nx:items-center nx:gap-2">
              <Checkbox id={`${id}-whole`} />
              <Label htmlFor={`${id}-whole`}>Erase whole strokes</Label>
            </div>
          </ToolbarToolPanel>
        </ToolbarToolGroup>
        <ToolbarSeparator />
        <ToolbarButton size="icon-sm" aria-label="Undo">
          <ArrowUUpLeftIcon aria-hidden="true" />
        </ToolbarButton>
      </Toolbar>
    </div>
  );
}

This example also needs: Checkbox, Label.

Props

Toolbar

Toolbar props
PropTypeDefaultDescription
variant"default" | "floating""default"

Floating lifts the toolbar above the content it sits over, like a canvas.

ToolbarButton

ToolbarButton props
PropTypeDefaultDescription
size"default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null"sm"No description
variant"link" | "default" | "error" | "destructive" | "outline" | "dashed" | "secondary" | "ghost" | null"ghost"No description

ToolbarGroup

ToolbarGroup adds no props of its own; it accepts the props of the element or primitive it wraps.

ToolbarLink props
PropTypeDefaultDescription
size"default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null"sm"No description
variant"link" | "default" | "error" | "destructive" | "outline" | "dashed" | "secondary" | "ghost" | null"ghost"No description

ToolbarSeparator

ToolbarSeparator adds no props of its own; it accepts the props of the element or primitive it wraps.

ToolbarToggleGroup

ToolbarToggleGroup adds no props of its own; it accepts the props of the element or primitive it wraps.

ToolbarToggleItem

ToolbarToggleItem props
PropTypeDefaultDescription
size"default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null"sm"No description

ToolbarTool

ToolbarTool props
PropTypeDefaultDescription
size"default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null"sm"No description
valueRequiredstringNo default documented

Matches the tool's ToolbarToolPanel, if it has one.

ToolbarToolGroup

ToolbarToolGroup props
PropTypeDefaultDescription
defaultOpenbooleanfalseNo description
defaultValuestring""No description
onOpenChange((open: boolean) => void)No default documentedNo description
onValueChange((value: string) => void)No default documentedNo description
openbooleanNo default documented

Whether the selected tool's panel is open.

valuestringNo default documented

The selected tool.

ToolbarToolPanel

ToolbarToolPanel props
PropTypeDefaultDescription
valueRequiredstringNo default documented

The ToolbarTool this panel belongs to.

<script setup lang="ts">
import {
  PhArrowUUpLeft,
  PhTextB,
  PhTextItalic,
  PhTextUnderline,
} from '@phosphor-icons/vue';
import { ref, useId } from 'vue';

import {
  Toolbar,
  ToolbarButton,
  ToolbarLink,
  ToolbarSeparator,
  ToolbarToggleGroup,
  ToolbarToggleItem,
} from '@/components/toolbar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/tooltip';

const previewId = useId();
const formatting = ref<string[]>([]);
</script>

<template>
  <div class="nx:grid nx:gap-4">
    <Toolbar aria-label="Text formatting">
      <Tooltip>
        <TooltipTrigger as-child>
          <span class="nx:inline-flex">
            <ToolbarButton
              size="icon-sm"
              aria-label="Undo formatting"
              class="nx:text-muted-foreground"
              @click="formatting = []"
            >
              <PhArrowUUpLeft aria-hidden="true" />
            </ToolbarButton>
          </span>
        </TooltipTrigger>
        <TooltipContent>Reset formatting</TooltipContent>
      </Tooltip>
      <ToolbarSeparator class="nx:data-[orientation=vertical]:h-4" />
      <ToolbarToggleGroup
        v-model="formatting"
        type="multiple"
        aria-label="Text style"
      >
        <Tooltip>
          <TooltipTrigger as-child>
            <span class="nx:inline-flex">
              <ToolbarToggleItem value="bold" aria-label="Bold" size="icon-sm">
                <PhTextB aria-hidden="true" />
              </ToolbarToggleItem>
            </span>
          </TooltipTrigger>
          <TooltipContent>Bold</TooltipContent>
        </Tooltip>
        <Tooltip>
          <TooltipTrigger as-child>
            <span class="nx:inline-flex">
              <ToolbarToggleItem
                value="italic"
                aria-label="Italic"
                size="icon-sm"
              >
                <PhTextItalic aria-hidden="true" />
              </ToolbarToggleItem>
            </span>
          </TooltipTrigger>
          <TooltipContent>Italic</TooltipContent>
        </Tooltip>
        <Tooltip>
          <TooltipTrigger as-child>
            <span class="nx:inline-flex">
              <ToolbarToggleItem
                value="underline"
                aria-label="Underline"
                size="icon-sm"
              >
                <PhTextUnderline aria-hidden="true" />
              </ToolbarToggleItem>
            </span>
          </TooltipTrigger>
          <TooltipContent>Underline</TooltipContent>
        </Tooltip>
      </ToolbarToggleGroup>
      <ToolbarSeparator class="nx:data-[orientation=vertical]:h-4" />
      <ToolbarLink class="nx:text-muted-foreground" :href="'#' + previewId"
        >Preview</ToolbarLink
      >
    </Toolbar>
    <p
      :id="previewId"
      class="nx:typography-body-default nx:text-muted-foreground"
      :style="{
        fontWeight: formatting.includes('bold') ? 700 : undefined,
        fontStyle: formatting.includes('italic') ? 'italic' : undefined,
        textDecoration: formatting.includes('underline')
          ? 'underline'
          : undefined,
      }"
    >
      Select formatting to update this preview.
    </p>
  </div>
</template>

This example also needs: Tooltip.

Installation

  1. Install these components first:

    Button, ButtonGroup, Popover, Spinner, ToggleGroup, Tooltip.

  2. Copy and paste the following code into your project.

    components/toolbar/Toolbar.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import { ToolbarRoot, type ToolbarRootProps, useForwardProps } from 'reka-ui';
    import { type FunctionalComponent, type HTMLAttributes, inject } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { TooltipGroupKey } from '../tooltip/tooltip-group';
    import TooltipProvider from '../tooltip/TooltipProvider.vue';
    
    import { toolbarVariants } from '.';
    
    type ToolbarVariant = 'default' | 'floating';
    
    export interface ToolbarProps extends ToolbarRootProps {
      /** Floating lifts the toolbar above the content it sits over, like a canvas. */
      variant?: ToolbarVariant;
      class?: HTMLAttributes['class'];
    }
    
    defineOptions({ inheritAttrs: false });
    
    // Radix wraps arrow-key focus by default; Reka doesn't.
    const props = withDefaults(defineProps<ToolbarProps>(), {
      loop: true,
      variant: 'default',
    });
    
    const delegated = reactiveOmit(props, 'class', 'variant');
    const forwarded = useForwardProps(delegated);
    
    const Passthrough: FunctionalComponent = (_, { slots }) => slots.default?.();
    const Group = inject(TooltipGroupKey, false) ? Passthrough : TooltipProvider;
    
    // Reka drops the toolbar's own tab stop on Shift+Tab at the next render, so a
    // focus move in the same task would land on the toolbar. Radix drops it at once.
    function dropTabStop(event: KeyboardEvent) {
      if (event.key !== 'Tab' || !event.shiftKey) return;
      (event.currentTarget as HTMLElement).tabIndex = -1;
    }
    </script>
    
    <template>
      <Group>
        <ToolbarRoot
          v-bind="{ ...$attrs, ...forwarded }"
          data-slot="toolbar"
          :data-variant="variant"
          :class="cn(toolbarVariants({ variant }), props.class)"
          @keydown.capture="dropTabStop"
        >
          <slot />
        </ToolbarRoot>
      </Group>
    </template>
    
    components/toolbar/ToolbarButton.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      ToolbarButton,
      type ToolbarButtonProps as RekaToolbarButtonProps,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { type ButtonVariants, buttonVariants } from '../button';
    
    export interface ToolbarButtonProps extends RekaToolbarButtonProps {
      variant?: ButtonVariants['variant'];
      size?: ButtonVariants['size'];
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<ToolbarButtonProps>(), {
      as: 'button',
      variant: 'ghost',
      size: 'sm',
    });
    
    const delegated = reactiveOmit(props, 'class', 'variant', 'size');
    </script>
    
    <template>
      <ToolbarButton
        v-bind="delegated"
        data-slot="toolbar-button"
        :data-variant="variant"
        :data-size="size"
        :class="cn(buttonVariants({ variant, size }), props.class)"
      >
        <slot />
      </ToolbarButton>
    </template>
    
    components/toolbar/ToolbarGroup.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { toolbarGroupLayout } from '.';
    
    export interface ToolbarGroupProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<ToolbarGroupProps>();
    </script>
    
    <template>
      <div
        role="group"
        data-slot="toolbar-group"
        :class="cn(toolbarGroupLayout, props.class)"
      >
        <slot />
      </div>
    </template>
    
    components/toolbar/ToolbarLink.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      ToolbarLink,
      type ToolbarLinkProps as RekaToolbarLinkProps,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { type ButtonVariants, buttonVariants } from '../button';
    
    export interface ToolbarLinkProps extends RekaToolbarLinkProps {
      variant?: ButtonVariants['variant'];
      size?: ButtonVariants['size'];
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<ToolbarLinkProps>(), {
      as: 'a',
      variant: 'ghost',
      size: 'sm',
    });
    
    const delegated = reactiveOmit(props, 'class', 'variant', 'size');
    </script>
    
    <template>
      <ToolbarLink
        v-bind="delegated"
        data-slot="toolbar-link"
        :data-variant="variant"
        :data-size="size"
        :class="cn(buttonVariants({ variant, size }), props.class)"
      >
        <slot />
      </ToolbarLink>
    </template>
    
    components/toolbar/ToolbarSeparator.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      injectToolbarRootContext,
      Separator,
      type ToolbarSeparatorProps as RekaToolbarSeparatorProps,
    } from 'reka-ui';
    import { computed, type HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface ToolbarSeparatorProps extends RekaToolbarSeparatorProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<ToolbarSeparatorProps>();
    
    const delegated = reactiveOmit(props, 'class');
    
    // A separator runs across the toolbar, as Radix draws it; Reka's
    // ToolbarSeparator takes the toolbar's own orientation.
    const toolbar = injectToolbarRootContext();
    const orientation = computed(() =>
      toolbar.orientation.value === 'horizontal' ? 'vertical' : 'horizontal'
    );
    </script>
    
    <template>
      <Separator
        v-bind="delegated"
        data-slot="toolbar-separator"
        :orientation="orientation"
        :class="
          cn(
            'nx:shrink-0 nx:bg-border-default nx:data-[orientation=vertical]:my-1.5 nx:data-[orientation=vertical]:w-px nx:data-[orientation=vertical]:self-stretch nx:data-[orientation=horizontal]:h-px nx:data-[orientation=horizontal]:w-full',
            props.class
          )
        "
      />
    </template>
    
    components/toolbar/ToolbarToggleGroup.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      type AcceptableValue,
      type ToggleGroupRootProps,
      ToolbarToggleGroup,
      useForwardProps,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { toolbarGroupLayout } from '.';
    
    export interface ToolbarToggleGroupProps
      extends
        Required<Pick<ToggleGroupRootProps<string | string[]>, 'type'>>,
        Omit<ToggleGroupRootProps<string | string[]>, 'type'> {
      class?: HTMLAttributes['class'];
    }
    
    defineOptions({ inheritAttrs: false });
    
    const props = defineProps<ToolbarToggleGroupProps>();
    // Clearing a single selection emits undefined.
    const emits = defineEmits<{
      'update:modelValue': [value: string | string[] | undefined];
    }>();
    
    const delegated = reactiveOmit(props, 'class');
    const forwarded = useForwardProps(delegated);
    
    // Every item's value is a string, so Reka reports only strings.
    function update(value: AcceptableValue | AcceptableValue[]) {
      emits('update:modelValue', value as string | string[] | undefined);
    }
    </script>
    
    <!-- Reka's group sets role="group" on its own element, so it renders this div,
    whose role wins. -->
    <template>
      <ToolbarToggleGroup v-bind="forwarded" as-child @update:model-value="update">
        <div
          v-bind="$attrs"
          data-slot="toolbar-toggle-group"
          :role="type === 'single' ? 'radiogroup' : 'group'"
          :class="cn(toolbarGroupLayout, props.class)"
        >
          <slot />
        </div>
      </ToolbarToggleGroup>
    </template>
    
    components/toolbar/ToolbarToggleItem.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      injectToggleGroupRootContext,
      ToolbarToggleItem,
      type ToolbarToggleItemProps as RekaToolbarToggleItemProps,
      useForwardProps,
    } from 'reka-ui';
    import { computed, type HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { type ButtonVariants, buttonVariants } from '../button';
    import { useToggleGroupSelectionAttrs } from '../toggle-group/toggle-group-selection';
    
    export interface ToolbarToggleItemProps extends Omit<
      RekaToolbarToggleItemProps,
      'value'
    > {
      /** Value the group reports while this item is pressed. */
      value: string;
      size?: ButtonVariants['size'];
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<ToolbarToggleItemProps>(), {
      size: 'sm',
    });
    
    const selectionAttrs = useToggleGroupSelectionAttrs(() => props.value);
    
    // Reka's toolbar wrapper reads only the item's own disabled, so an item of a
    // disabled group stays in the arrow-key order without data-disabled.
    const group = injectToggleGroupRootContext();
    const disabled = computed(() => group.disabled?.value || props.disabled);
    
    const delegated = reactiveOmit(props, 'class', 'size');
    const forwarded = useForwardProps(delegated);
    </script>
    
    <template>
      <ToolbarToggleItem
        v-bind="{ ...forwarded, ...selectionAttrs }"
        data-slot="toolbar-toggle-item"
        :disabled="disabled"
        :data-size="size"
        :class="
          cn(
            buttonVariants({ variant: 'ghost', size }),
            'nx:data-[state=on]:bg-primary-subtle nx:data-[state=on]:text-primary-subtle-foreground nx:data-[state=on]:hover:bg-primary-subtle-hover nx:data-[state=on]:active:bg-primary-subtle-active',
            props.class
          )
        "
      >
        <slot />
      </ToolbarToggleItem>
    </template>
    
    components/toolbar/ToolbarTool.vue
    <script setup lang="ts">
    import { reactiveOmit, unrefElement } from '@vueuse/core';
    import {
      ToolbarButton,
      type ToolbarButtonProps as RekaToolbarButtonProps,
    } from 'reka-ui';
    import {
      computed,
      type HTMLAttributes,
      useId,
      useTemplateRef,
      watchEffect,
    } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { type ButtonVariants, buttonVariants } from '../button';
    
    import { injectToolbarToolGroup } from './toolbar-tool-group';
    
    export interface ToolbarToolProps extends RekaToolbarButtonProps {
      /** Matches the tool's ToolbarToolPanel, if it has one. */
      value: string;
      size?: ButtonVariants['size'];
      class?: HTMLAttributes['class'];
    }
    
    defineOptions({ inheritAttrs: false });
    
    const props = withDefaults(defineProps<ToolbarToolProps>(), {
      as: 'button',
      size: 'sm',
    });
    
    const tools = injectToolbarToolGroup();
    const button = useTemplateRef('button');
    const id = useId();
    const selected = computed(() => props.value === tools.value.value);
    const hasPanel = computed(() => tools.panels.has(props.value));
    const expanded = computed(() => selected.value && tools.open.value);
    
    const delegated = reactiveOmit(props, 'class', 'size', 'value');
    
    watchEffect(
      (onCleanup) => {
        const element = unrefElement(button);
        if (!selected.value || !(element instanceof HTMLElement)) return;
        tools.setActiveTool(element);
        onCleanup(() => {
          // The next selected tool may already have reported itself.
          if (tools.activeTool.value === element) tools.setActiveTool(null);
        });
      },
      { flush: 'post' }
    );
    
    // @click is bound after $attrs, so your click runs first and can prevent this.
    function handleClick(event: MouseEvent) {
      if (!event.defaultPrevented) tools.select(props.value);
    }
    </script>
    
    <template>
      <ToolbarButton
        :id="id"
        ref="button"
        data-slot="toolbar-tool"
        :data-size="size"
        :class="
          cn(
            buttonVariants({ variant: 'ghost', size }),
            'nx:data-[state=on]:bg-primary-background nx:data-[state=on]:text-primary-foreground nx:data-[state=on]:hover:bg-primary-background-hover nx:data-[state=on]:active:bg-primary-background-active',
            props.class
          )
        "
        v-bind="{ ...delegated, ...$attrs }"
        :data-state="selected ? 'on' : 'off'"
        :aria-pressed="selected"
        :aria-haspopup="hasPanel ? 'dialog' : undefined"
        :aria-expanded="hasPanel ? expanded : undefined"
        :aria-controls="expanded ? tools.panelId : $attrs['aria-controls']"
        @click="handleClick"
      >
        <slot />
      </ToolbarButton>
    </template>
    
    components/toolbar/ToolbarToolGroup.vue
    <script setup lang="ts">
    import { PopoverAnchor, PopoverRoot } from 'reka-ui';
    import {
      computed,
      type HTMLAttributes,
      provide,
      ref,
      shallowReactive,
      shallowRef,
      useId,
      useTemplateRef,
    } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { ToolbarToolGroupKey } from './toolbar-tool-group';
    
    import { toolbarGroupLayout } from '.';
    
    export interface ToolbarToolGroupProps {
      /** The selected tool. */
      value?: string;
      defaultValue?: string;
      /** Whether the selected tool's panel is open. */
      open?: boolean;
      defaultOpen?: boolean;
      class?: HTMLAttributes['class'];
    }
    
    defineOptions({ inheritAttrs: false });
    
    const props = withDefaults(defineProps<ToolbarToolGroupProps>(), {
      defaultValue: '',
      open: undefined,
      defaultOpen: false,
    });
    const emit = defineEmits<{
      'value-change': [value: string];
      'open-change': [open: boolean];
    }>();
    
    const group = useTemplateRef<HTMLDivElement>('group');
    const panelId = useId();
    const localValue = ref(props.defaultValue);
    const localOpen = ref(props.defaultOpen);
    const panels = shallowReactive(new Set<string>());
    const activeTool = shallowRef<HTMLElement | null>(null);
    let switchingTo: string | null = null;
    let restoreFocus = true;
    
    const value = computed(() => props.value ?? localValue.value);
    const open = computed(
      () =>
        (props.open ?? localOpen.value) &&
        panels.has(value.value) &&
        activeTool.value !== null
    );
    
    function setActiveTool(tool: HTMLElement | null) {
      activeTool.value = tool;
    }
    
    function registerPanel(panelValue: string) {
      panels.add(panelValue);
      return () => {
        panels.delete(panelValue);
      };
    }
    
    function setOpen(next: boolean) {
      if (props.open === undefined) localOpen.value = next;
      emit('open-change', next);
    }
    
    function select(next: string) {
      const wasOpen = open.value;
      const nextOpen = panels.has(next) && (next !== value.value || !wasOpen);
      switchingTo = wasOpen && nextOpen ? next : null;
      if (next !== value.value) {
        if (props.value === undefined) localValue.value = next;
        emit('value-change', next);
      }
      if (nextOpen !== wasOpen) setOpen(nextOpen);
    }
    
    function consumeSwitch(panelValue: string) {
      const switched = switchingTo === panelValue;
      switchingTo = null;
      return switched;
    }
    
    // Reka reports the close while the panel still holds focus, so wait a task
    // for focus to leave it, as Radix does.
    function handlePanelClose() {
      const restore = restoreFocus;
      restoreFocus = true;
      setTimeout(() => {
        const tool = activeTool.value;
        if (!restore || !tool) return;
        // Focus falls to the body only when it was inside the closed panel.
        if (tool.ownerDocument.activeElement !== tool.ownerDocument.body) return;
        tool.focus({ preventScroll: true });
      });
    }
    
    function isInGroup(target: Node) {
      return group.value?.contains(target) ?? false;
    }
    
    function handlePressOutside() {
      restoreFocus = false;
    }
    
    provide(ToolbarToolGroupKey, {
      value,
      open,
      panelId,
      panels,
      activeTool,
      setActiveTool,
      registerPanel,
      select,
      consumeSwitch,
      handlePanelClose,
      isInGroup,
      handlePressOutside,
    });
    </script>
    
    <!-- The anchor renders nothing. It places the open panel at the selected tool. -->
    <template>
      <PopoverRoot :open="open" @update:open="setOpen">
        <div
          ref="group"
          role="group"
          data-slot="toolbar-tool-group"
          v-bind="$attrs"
          :class="cn(toolbarGroupLayout, props.class)"
        >
          <slot />
        </div>
        <PopoverAnchor as-child :reference="activeTool ?? undefined" />
      </PopoverRoot>
    </template>
    
    components/toolbar/ToolbarToolPanel.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      type FocusOutsideEvent,
      injectPopoverRootContext,
      type PointerDownOutsideEvent,
      type PopoverContentEmits,
      type PopoverContentProps as RekaPopoverContentProps,
      useForwardPropsEmits,
    } from 'reka-ui';
    import { computed, type HTMLAttributes, useAttrs, watchEffect } from 'vue';
    
    import { PopoverContent } from '../popover';
    
    import { injectToolbarToolGroup } from './toolbar-tool-group';
    
    // vue-docgen-api fails on an interface that extends one from a local .vue
    // file, so this repeats nxds PopoverContentProps: Reka's props and class.
    export interface ToolbarToolPanelProps extends RekaPopoverContentProps {
      /** The ToolbarTool this panel belongs to. */
      value: string;
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<ToolbarToolPanelProps>();
    const emits = defineEmits<PopoverContentEmits>();
    
    const tools = injectToolbarToolGroup();
    const popover = injectPopoverRootContext();
    const attrs = useAttrs();
    const shown = computed(() => props.value === tools.value.value);
    
    const delegated = reactiveOmit(props, 'value');
    const forwarded = useForwardPropsEmits(delegated, emits);
    
    watchEffect((onCleanup) => onCleanup(tools.registerPanel(props.value)));
    
    // Reka sets the panel's id and aria-labelledby from these, over its attributes.
    popover.contentId = tools.panelId;
    
    /** The panel's own aria-labelledby, else its tool unless it has an aria-label. */
    function labelledBy() {
      const own = attrs['aria-labelledby'];
      if (typeof own === 'string') return own;
      if (attrs['aria-label']) return undefined;
      return tools.activeTool.value?.id;
    }
    
    watchEffect(() => {
      // Reka types triggerId as a string, but leaving it unset omits the attribute.
      if (shown.value) popover.triggerId = labelledBy() as string;
    });
    
    function handleOpenAutoFocus(event: Event) {
      emits('openAutoFocus', event);
      // Switching tools keeps focus on the tool that was just chosen.
      if (tools.consumeSwitch(props.value)) event.preventDefault();
    }
    
    function handleCloseAutoFocus(event: Event) {
      emits('closeAutoFocus', event);
      if (event.defaultPrevented) return;
      event.preventDefault();
      tools.handlePanelClose();
    }
    
    function handleInteractOutside(
      event: PointerDownOutsideEvent | FocusOutsideEvent
    ) {
      emits('interactOutside', event);
      if (event.defaultPrevented) return;
      // Tools handle their own clicks, so the panel can stay open while switching.
      if (event.target instanceof Node && tools.isInGroup(event.target))
        event.preventDefault();
      else tools.handlePressOutside();
    }
    
    const bound = computed(() => ({
      ...forwarded.value,
      onOpenAutoFocus: handleOpenAutoFocus,
      onCloseAutoFocus: handleCloseAutoFocus,
      onInteractOutside: handleInteractOutside,
    }));
    </script>
    
    <template>
      <PopoverContent v-if="shown" data-slot="toolbar-tool-panel" v-bind="bound">
        <slot />
      </PopoverContent>
    </template>
    
    components/toolbar/index.ts
    import { cva } from 'class-variance-authority';
    
    export { default as Toolbar, type ToolbarProps } from './Toolbar.vue';
    export {
      default as ToolbarButton,
      type ToolbarButtonProps,
    } from './ToolbarButton.vue';
    export {
      default as ToolbarGroup,
      type ToolbarGroupProps,
    } from './ToolbarGroup.vue';
    export {
      default as ToolbarLink,
      type ToolbarLinkProps,
    } from './ToolbarLink.vue';
    export {
      default as ToolbarSeparator,
      type ToolbarSeparatorProps,
    } from './ToolbarSeparator.vue';
    export {
      default as ToolbarToggleGroup,
      type ToolbarToggleGroupProps,
    } from './ToolbarToggleGroup.vue';
    export {
      default as ToolbarToggleItem,
      type ToolbarToggleItemProps,
    } from './ToolbarToggleItem.vue';
    export {
      default as ToolbarTool,
      type ToolbarToolProps,
    } from './ToolbarTool.vue';
    export {
      default as ToolbarToolGroup,
      type ToolbarToolGroupProps,
    } from './ToolbarToolGroup.vue';
    export {
      default as ToolbarToolPanel,
      type ToolbarToolPanelProps,
    } from './ToolbarToolPanel.vue';
    
    export const toolbarVariants = cva(
      'nx:group/toolbar nx:flex nx:w-fit nx:max-w-full nx:min-w-0 nx:flex-wrap nx:items-center nx:border-default nx:border-border-default nx:bg-container nx:data-[orientation=vertical]:flex-col nx:data-[orientation=vertical]:items-stretch',
      {
        variants: {
          variant: {
            default: 'nx:gap-0.5 nx:rounded-lg nx:p-0.5 nx:shadow-sm',
            floating: 'nx:gap-2 nx:rounded-2xl nx:p-2 nx:shadow-lg',
          },
        },
        defaultVariants: { variant: 'default' },
      }
    );
    
    export const toolbarGroupLayout =
      'nx:flex nx:min-w-0 nx:flex-wrap nx:items-center nx:gap-[inherit] nx:group-data-[orientation=vertical]/toolbar:flex-col nx:group-data-[orientation=vertical]/toolbar:items-stretch';
    
    components/toolbar/toolbar-tool-group.ts
    import { inject, type InjectionKey, type Ref } from 'vue';
    
    /** The state a ToolbarToolGroup shares with its tools and panels. */
    export interface ToolbarToolGroupContext {
      value: Readonly<Ref<string>>;
      open: Readonly<Ref<boolean>>;
      panelId: string;
      panels: ReadonlySet<string>;
      activeTool: Readonly<Ref<HTMLElement | null>>;
      setActiveTool: (tool: HTMLElement | null) => void;
      registerPanel: (value: string) => () => void;
      select: (value: string) => void;
      /** Returns true when the panel opened because the user switched to its tool. */
      consumeSwitch: (value: string) => boolean;
      handlePanelClose: () => void;
      isInGroup: (target: Node) => boolean;
      handlePressOutside: () => void;
    }
    
    export const ToolbarToolGroupKey: InjectionKey<ToolbarToolGroupContext> =
      Symbol('toolbar-tool-group');
    
    export function injectToolbarToolGroup() {
      const context = inject(ToolbarToolGroupKey, null);
      if (!context)
        throw new Error(
          'ToolbarTool and ToolbarToolPanel must be inside ToolbarToolGroup.'
        );
      return context;
    }
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { Toolbar, ToolbarButton, ToolbarGroup, ToolbarLink, ToolbarSeparator, ToolbarToggleGroup, ToolbarToggleItem, ToolbarTool, ToolbarToolGroup, ToolbarToolPanel } from '@/components/toolbar';

Examples

File actions

Group file actions and move extra ones into a "More" dropdown menu.

<script setup lang="ts">
import { PhDotsThree } from '@phosphor-icons/vue';

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/dropdown-menu';
import {
  Toolbar,
  ToolbarButton,
  ToolbarGroup,
  ToolbarSeparator,
} from '@/components/toolbar';
</script>

<template>
  <Toolbar aria-label="File actions">
    <ToolbarGroup aria-label="Selection actions">
      <ToolbarButton variant="outline">Download</ToolbarButton>
      <ToolbarButton>Move</ToolbarButton>
      <ToolbarButton disabled>Share</ToolbarButton>
    </ToolbarGroup>
    <ToolbarSeparator />
    <DropdownMenu>
      <DropdownMenuTrigger as-child>
        <ToolbarButton size="icon-sm" aria-label="More file actions">
          <PhDotsThree />
        </ToolbarButton>
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuItem>Rename</DropdownMenuItem>
        <DropdownMenuItem>Duplicate</DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  </Toolbar>
</template>

This example also needs: DropdownMenu.

Calendar

Pair previous and next buttons with a single-select view switcher.

<script setup lang="ts">
import { PhCaretLeft, PhCaretRight } from '@phosphor-icons/vue';
import { ref } from 'vue';

import {
  Toolbar,
  ToolbarButton,
  ToolbarGroup,
  ToolbarToggleGroup,
  ToolbarToggleItem,
} from '@/components/toolbar';

const view = ref('week');
</script>

<template>
  <Toolbar aria-label="Calendar controls">
    <ToolbarGroup aria-label="Date navigation">
      <ToolbarButton size="icon-sm" aria-label="Previous period">
        <PhCaretLeft />
      </ToolbarButton>
      <ToolbarButton>Today</ToolbarButton>
      <ToolbarButton size="icon-sm" aria-label="Next period">
        <PhCaretRight />
      </ToolbarButton>
    </ToolbarGroup>
    <ToolbarToggleGroup
      class="nx:ms-auto"
      type="single"
      :model-value="view"
      aria-label="Calendar view"
      @update:model-value="(value) => value && (view = value)"
    >
      <ToolbarToggleItem value="day">Day</ToolbarToggleItem>
      <ToolbarToggleItem value="week">Week</ToolbarToggleItem>
      <ToolbarToggleItem value="month">Month</ToolbarToggleItem>
    </ToolbarToggleGroup>
  </Toolbar>
</template>

Vertical

Set orientation="vertical" to stack the controls in a column.

<script setup lang="ts">
import { Toolbar, ToolbarButton, ToolbarSeparator } from '@/components/toolbar';
</script>

<template>
  <Toolbar orientation="vertical" aria-label="Drawing actions">
    <ToolbarButton>Select</ToolbarButton>
    <ToolbarButton>Draw</ToolbarButton>
    <ToolbarSeparator />
    <ToolbarButton>Erase</ToolbarButton>
  </Toolbar>
</template>

Floating

<script setup lang="ts">
import {
  PhArrowUUpLeft,
  PhCursor,
  PhEraser,
  PhPencilSimple,
} from '@phosphor-icons/vue';

import {
  Toolbar,
  ToolbarButton,
  ToolbarSeparator,
  ToolbarTool,
  ToolbarToolGroup,
} from '@/components/toolbar';
</script>

<template>
  <div class="nx:flex nx:min-h-40 nx:items-end nx:justify-center">
    <Toolbar variant="floating" aria-label="Canvas">
      <ToolbarToolGroup aria-label="Drawing tools" default-value="select">
        <ToolbarTool value="select" size="icon-sm" aria-label="Select">
          <PhCursor aria-hidden="true" />
        </ToolbarTool>
        <ToolbarTool value="draw" size="icon-sm" aria-label="Draw">
          <PhPencilSimple aria-hidden="true" />
        </ToolbarTool>
        <ToolbarTool value="erase" size="icon-sm" aria-label="Erase">
          <PhEraser aria-hidden="true" />
        </ToolbarTool>
      </ToolbarToolGroup>
      <ToolbarSeparator />
      <ToolbarButton size="icon-sm" aria-label="Undo">
        <PhArrowUUpLeft aria-hidden="true" />
      </ToolbarButton>
    </Toolbar>
  </div>
</template>

Disabled toggles

Disable a toggle group while the rest of the toolbar stays usable.

<script setup lang="ts">
import {
  Toolbar,
  ToolbarButton,
  ToolbarToggleGroup,
  ToolbarToggleItem,
} from '@/components/toolbar';
</script>

<template>
  <Toolbar aria-label="Formatting availability">
    <ToolbarButton>Undo</ToolbarButton>
    <ToolbarToggleGroup
      type="multiple"
      disabled
      aria-label="Unavailable formatting"
    >
      <ToolbarToggleItem value="bold">Bold</ToolbarToggleItem>
      <ToolbarToggleItem value="italic">Italic</ToolbarToggleItem>
    </ToolbarToggleGroup>
    <ToolbarButton>Preview</ToolbarButton>
  </Toolbar>
</template>

Inline

Restyle the toolbar as a flush row above a list, with a view filter.

<script setup lang="ts">
import { PhCaretDown } from '@phosphor-icons/vue';
import { ref } from 'vue';

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/dropdown-menu';
import {
  Toolbar,
  ToolbarButton,
  ToolbarToggleGroup,
  ToolbarToggleItem,
} from '@/components/toolbar';

const view = ref('all');
</script>

<template>
  <section class="nx:grid nx:gap-4">
    <div>
      <h2 class="nx:typography-heading-small">Documents</h2>
      <p class="nx:typography-body-default nx:text-muted-foreground">
        Keep your working files close at hand.
      </p>
    </div>
    <Toolbar
      aria-label="Document view"
      class="nx:w-full nx:rounded-none nx:border-0 nx:border-b-default nx:shadow-none nx:bg-transparent nx:p-0 nx:pb-3"
    >
      <ToolbarToggleGroup
        type="single"
        :model-value="view"
        aria-label="Show documents"
        @update:model-value="(value) => value && (view = value)"
      >
        <ToolbarToggleItem value="all">All documents</ToolbarToggleItem>
        <ToolbarToggleItem value="recent">Recent</ToolbarToggleItem>
      </ToolbarToggleGroup>
      <DropdownMenu>
        <DropdownMenuTrigger as-child>
          <ToolbarButton class="nx:ms-auto"
            >Show <PhCaretDown aria-hidden="true"
          /></ToolbarButton>
        </DropdownMenuTrigger>
        <DropdownMenuContent>
          <DropdownMenuItem @select="view = 'all'"
            >All documents</DropdownMenuItem
          >
          <DropdownMenuItem @select="view = 'recent'"
            >Recent documents</DropdownMenuItem
          >
        </DropdownMenuContent>
      </DropdownMenu>
    </Toolbar>
    <ul class="nx:grid nx:gap-3 nx:typography-body-default">
      <li>
        Project brief <span class="nx:text-muted-foreground">· Today</span>
      </li>
      <li v-if="view === 'all'">
        Research notes <span class="nx:text-muted-foreground">· Last week</span>
      </li>
    </ul>
  </section>
</template>

This example also needs: DropdownMenu.

Attached

Attach the toolbar to the top of a note field to format its text.

<script setup lang="ts">
import { PhCaretDown } from '@phosphor-icons/vue';
import { ref } from 'vue';

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/dropdown-menu';
import {
  Toolbar,
  ToolbarButton,
  ToolbarSeparator,
  ToolbarToggleGroup,
  ToolbarToggleItem,
} from '@/components/toolbar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/tooltip';

const formatting = ref<string[]>([]);
const text = ref(
  'Good tools stay close to the work. Draft your next idea here.'
);
</script>

<template>
  <section class="nx:grid nx:gap-3">
    <h2 class="nx:typography-heading-small">Draft a note</h2>
    <div
      class="nx:rounded-lg nx:border-default nx:border-border-default nx:bg-container"
    >
      <Toolbar
        aria-label="Note formatting"
        class="nx:w-full nx:rounded-none nx:rounded-t-lg nx:border-0 nx:border-b-default nx:border-border-default nx:shadow-none nx:bg-background nx:p-2"
      >
        <ToolbarToggleGroup
          v-model="formatting"
          type="multiple"
          aria-label="Note style"
        >
          <Tooltip>
            <TooltipTrigger as-child>
              <span class="nx:inline-flex">
                <ToolbarToggleItem
                  value="bold"
                  size="icon-sm"
                  aria-label="Bold note"
                >
                  <span class="nx:font-bold">B</span>
                </ToolbarToggleItem>
              </span>
            </TooltipTrigger>
            <TooltipContent>Bold</TooltipContent>
          </Tooltip>
          <Tooltip>
            <TooltipTrigger as-child>
              <span class="nx:inline-flex">
                <ToolbarToggleItem
                  value="italic"
                  size="icon-sm"
                  aria-label="Italic note"
                >
                  <span class="nx:italic">I</span>
                </ToolbarToggleItem>
              </span>
            </TooltipTrigger>
            <TooltipContent>Italic</TooltipContent>
          </Tooltip>
          <Tooltip>
            <TooltipTrigger as-child>
              <span class="nx:inline-flex">
                <ToolbarToggleItem
                  value="underline"
                  size="icon-sm"
                  aria-label="Underline note"
                >
                  <span class="nx:underline">U</span>
                </ToolbarToggleItem>
              </span>
            </TooltipTrigger>
            <TooltipContent>Underline</TooltipContent>
          </Tooltip>
        </ToolbarToggleGroup>
        <ToolbarSeparator />
        <DropdownMenu>
          <DropdownMenuTrigger as-child>
            <ToolbarButton
              >More <PhCaretDown aria-hidden="true"
            /></ToolbarButton>
          </DropdownMenuTrigger>
          <DropdownMenuContent>
            <DropdownMenuItem @select="formatting = []"
              >Clear formatting</DropdownMenuItem
            >
          </DropdownMenuContent>
        </DropdownMenu>
      </Toolbar>
      <textarea
        v-model="text"
        aria-label="Note"
        class="nx:block nx:min-h-32 nx:w-full nx:resize-y nx:rounded-b-lg nx:bg-transparent nx:p-4 nx:typography-body-default nx:text-foreground nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default"
        :style="{
          fontWeight: formatting.includes('bold') ? 700 : undefined,
          fontStyle: formatting.includes('italic') ? 'italic' : undefined,
          textDecoration: formatting.includes('underline')
            ? 'underline'
            : undefined,
        }"
      />
    </div>
    <p class="nx:typography-body-small nx:text-muted-foreground">
      Formatting applies to the whole note.
    </p>
  </section>
</template>

This example also needs: DropdownMenu.

Selection

Show an action bar for the documents a user has selected.

<script setup lang="ts">
import { PhArchive, PhX } from '@phosphor-icons/vue';
import { ref, useId } from 'vue';

import { Checkbox } from '@/components/checkbox';
import { Label } from '@/components/label';
import { Toolbar, ToolbarButton, ToolbarSeparator } from '@/components/toolbar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/tooltip';

const selectionId = useId();
const files = ['Project brief', 'Research notes', 'Launch checklist'];
const selected = ref<string[]>(['Project brief', 'Research notes']);
const archived = ref<string[]>([]);
const message = ref('');
const heading = ref<HTMLElement | null>(null);
function clearSelection() {
  selected.value = [];
  document.getElementById(`${selectionId}-0`)?.focus();
}
function archiveSelection() {
  const count = selected.value.length;
  message.value = `${count} document${count === 1 ? '' : 's'} archived.`;
  archived.value = [...archived.value, ...selected.value];
  selected.value = [];
  heading.value?.focus();
}
function toggleSelection(name: string, checked: boolean | 'indeterminate') {
  selected.value =
    checked === true
      ? [...selected.value, name]
      : selected.value.filter((item) => item !== name);
  message.value = '';
}
</script>

<template>
  <section class="nx:grid nx:gap-4">
    <div>
      <h2
        ref="heading"
        tabindex="-1"
        class="nx:typography-heading-small nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default"
      >
        Select documents
      </h2>
      <p class="nx:typography-body-default nx:text-muted-foreground">
        Actions appear when documents are selected.
      </p>
    </div>
    <div
      class="nx:rounded-lg nx:border-default nx:border-border-default nx:bg-container nx:p-3"
    >
      <Label
        v-for="(name, index) in files.filter(
          (file) => !archived.includes(file)
        )"
        :key="name"
        :for="selectionId + '-' + index"
        class="nx:flex nx:min-h-10 nx:cursor-pointer nx:items-center nx:gap-3 nx:px-2 nx:typography-body-default"
      >
        <Checkbox
          :id="selectionId + '-' + index"
          :model-value="selected.includes(name)"
          @update:model-value="(checked) => toggleSelection(name, checked)"
        />{{ name }}</Label
      >
      <p
        v-if="archived.length === files.length"
        class="nx:p-2 nx:typography-body-default nx:text-muted-foreground"
      >
        No documents remaining.
      </p>
    </div>
    <div class="nx:flex nx:min-h-16 nx:justify-center">
      <Toolbar
        v-if="selected.length > 0"
        aria-label="Selected document actions"
        class="nx:h-fit nx:justify-center nx:gap-0 nx:bg-popover nx:p-0"
      >
        <Tooltip>
          <TooltipTrigger as-child>
            <ToolbarButton
              size="icon-sm"
              aria-label="Clear selection"
              class="nx:text-muted-foreground"
              @click="clearSelection"
            >
              <PhX aria-hidden="true" />
            </ToolbarButton>
          </TooltipTrigger>
          <TooltipContent>Clear selection</TooltipContent>
        </Tooltip>
        <span
          class="nx:px-2 nx:typography-label-default nx:whitespace-nowrap"
          aria-live="polite"
          >{{ selected.length }} selected</span
        >
        <ToolbarSeparator />
        <ToolbarButton @click="archiveSelection">
          <PhArchive aria-hidden="true" />Archive</ToolbarButton
        >
      </Toolbar>
    </div>
    <p role="status" class="nx:typography-body-small nx:text-muted-foreground">
      {{ message }}
    </p>
  </section>
</template>

This example also needs: Checkbox, Label.

Tools with panels

<script setup lang="ts">
import {
  PhArrowUUpLeft,
  PhCursor,
  PhEraser,
  PhPencilSimple,
} from '@phosphor-icons/vue';
import { useId } from 'vue';

import { Checkbox } from '@/components/checkbox';
import { Label } from '@/components/label';
import {
  Toolbar,
  ToolbarButton,
  ToolbarSeparator,
  ToolbarTool,
  ToolbarToolGroup,
  ToolbarToolPanel,
} from '@/components/toolbar';

const id = useId();
</script>

<template>
  <div class="nx:flex nx:min-h-64 nx:items-end">
    <Toolbar aria-label="Canvas">
      <ToolbarToolGroup aria-label="Drawing tools" default-value="select">
        <ToolbarTool value="select" size="icon-sm" aria-label="Select">
          <PhCursor aria-hidden="true" />
        </ToolbarTool>
        <ToolbarTool value="draw" size="icon-sm" aria-label="Draw">
          <PhPencilSimple aria-hidden="true" />
        </ToolbarTool>
        <ToolbarTool value="erase" size="icon-sm" aria-label="Erase">
          <PhEraser aria-hidden="true" />
        </ToolbarTool>
        <ToolbarToolPanel value="draw" side="top">
          <div class="nx:flex nx:items-center nx:gap-2">
            <Checkbox :id="id + '-smooth'" :default-value="true" />
            <Label :for="id + '-smooth'">Smooth strokes</Label>
          </div>
        </ToolbarToolPanel>
        <ToolbarToolPanel value="erase" side="top">
          <div class="nx:flex nx:items-center nx:gap-2">
            <Checkbox :id="id + '-whole'" />
            <Label :for="id + '-whole'">Erase whole strokes</Label>
          </div>
        </ToolbarToolPanel>
      </ToolbarToolGroup>
      <ToolbarSeparator />
      <ToolbarButton size="icon-sm" aria-label="Undo">
        <PhArrowUUpLeft aria-hidden="true" />
      </ToolbarButton>
    </Toolbar>
  </div>
</template>

This example also needs: Checkbox, Label.

Props

Toolbar

Toolbar props
PropTypeDefaultDescription
variant"default" | "floating""default"

Floating lifts the toolbar above the content it sits over, like a canvas.

ToolbarButton

ToolbarButton props
PropTypeDefaultDescription
size"default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null"sm"No description
variant"default" | "destructive" | "error" | "outline" | "dashed" | "secondary" | "ghost" | "link" | null"ghost"No description

ToolbarGroup

ToolbarGroup adds no props of its own; it accepts the props of the element or primitive it wraps.

ToolbarLink props
PropTypeDefaultDescription
size"default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null"sm"No description
variant"default" | "destructive" | "error" | "outline" | "dashed" | "secondary" | "ghost" | "link" | null"ghost"No description

ToolbarSeparator

ToolbarSeparator adds no props of its own; it accepts the props of the element or primitive it wraps.

ToolbarToggleGroup

ToolbarToggleGroup adds no props of its own; it accepts the props of the element or primitive it wraps.

ToolbarToggleItem

ToolbarToggleItem props
PropTypeDefaultDescription
size"default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null"sm"No description
valueRequiredstringNo default documented

Value the group reports while this item is pressed.

ToolbarTool

ToolbarTool props
PropTypeDefaultDescription
size"default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null"sm"No description
valueRequiredstringNo default documented

Matches the tool's ToolbarToolPanel, if it has one.

ToolbarToolGroup

ToolbarToolGroup props
PropTypeDefaultDescription
defaultOpenbooleanfalseNo description
defaultValuestring""No description
openbooleanNo default documented

Whether the selected tool's panel is open.

valuestringNo default documented

The selected tool.

ToolbarToolPanel

ToolbarToolPanel props
PropTypeDefaultDescription
valueRequiredstringNo default documented

The ToolbarTool this panel belongs to.