Qure UI
Components

Toolbar

A strip of controls that costs one tab stop, not twelve.

'use client'import * as React from 'react'import {  ContrastIcon, MoveIcon, RotateCwIcon, RulerIcon, SunIcon,  ZoomInIcon, ZoomOutIcon,} from 'lucide-react'import { Toolbar, ToolbarButton, ToolbarGroup, ToolbarSeparator } from '@/registry/qure/ui/toolbar'const tools = [  { id: 'pan', label: 'Pan', icon: MoveIcon },  { id: 'zoom', label: 'Zoom', icon: ZoomInIcon },  { id: 'window', label: 'Window level', icon: ContrastIcon },  { id: 'measure', label: 'Measure', icon: RulerIcon },]export default function ToolbarDemo() {  const [tool, setTool] = React.useState('pan')  return (    <Toolbar aria-label="Image tools">      <ToolbarGroup aria-label="Tool">        {tools.map(({ id, label, icon: Icon }) => (          <ToolbarButton            key={id}            aria-label={label}            aria-pressed={tool === id}            onClick={() => setTool(id)}          >            <Icon />          </ToolbarButton>        ))}      </ToolbarGroup>      <ToolbarSeparator />      <ToolbarGroup aria-label="View">        <ToolbarButton aria-label="Zoom out"><ZoomOutIcon /></ToolbarButton>        <ToolbarButton aria-label="Rotate"><RotateCwIcon /></ToolbarButton>        <ToolbarButton aria-label="Invert"><SunIcon /></ToolbarButton>      </ToolbarGroup>    </Toolbar>  )}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/toolbar.json

Usage

import {
  Toolbar, ToolbarButton, ToolbarGroup, ToolbarSeparator,
} from '@/components/ui/toolbar'
<Toolbar aria-label="Image tools">
  <ToolbarButton aria-label="Pan" aria-pressed><MoveIcon /></ToolbarButton>
  <ToolbarSeparator />
  <ToolbarButton aria-label="Zoom"><ZoomInIcon /></ToolbarButton>
</Toolbar>

The point of a toolbar is the roving tab stop. Twelve buttons in a row are twelve presses of Tab to get past; inside a Toolbar they are one, and the arrow keys move between them. A radiologist tabbing from the study list to the report should not travel through the window-level control on the way.

Give it an aria-label. role="toolbar" with no name announces as "toolbar" and nothing else, which is worse than no role at all.

Composition

<Toolbar>              {/* role="toolbar", owns the roving focus */}
  <ToolbarGroup>       {/* a labelled cluster — related tools */}
    <ToolbarButton>    {/* one control; aria-pressed for a sticky tool */}
  </ToolbarGroup>
  <ToolbarSeparator /> {/* orientation is the toolbar's, flipped */}
  <ToolbarLink>        {/* an <a>, kept in the same roving ring */}
  <ToolbarInput>       {/* an <input>; arrows inside it move the caret */}
</Toolbar>

ToolbarGroup is not decoration — give it an aria-label and the tools inside it are announced as a set, which is how a screen-reader user learns that four buttons are four choices of one thing rather than four separate actions.

Imaging tools

'use client'import * as React from 'react'import {  ContrastIcon, MoveIcon, RotateCwIcon, RulerIcon, SunIcon,  ZoomInIcon, ZoomOutIcon,} from 'lucide-react'import { Toolbar, ToolbarButton, ToolbarGroup, ToolbarSeparator } from '@/registry/qure/ui/toolbar'const tools = [  { id: 'pan', label: 'Pan', icon: MoveIcon },  { id: 'zoom', label: 'Zoom', icon: ZoomInIcon },  { id: 'window', label: 'Window level', icon: ContrastIcon },  { id: 'measure', label: 'Measure', icon: RulerIcon },]export default function ToolbarDemo() {  const [tool, setTool] = React.useState('pan')  return (    <Toolbar aria-label="Image tools">      <ToolbarGroup aria-label="Tool">        {tools.map(({ id, label, icon: Icon }) => (          <ToolbarButton            key={id}            aria-label={label}            aria-pressed={tool === id}            onClick={() => setTool(id)}          >            <Icon />          </ToolbarButton>        ))}      </ToolbarGroup>      <ToolbarSeparator />      <ToolbarGroup aria-label="View">        <ToolbarButton aria-label="Zoom out"><ZoomOutIcon /></ToolbarButton>        <ToolbarButton aria-label="Rotate"><RotateCwIcon /></ToolbarButton>        <ToolbarButton aria-label="Invert"><SunIcon /></ToolbarButton>      </ToolbarGroup>    </Toolbar>  )}

A sticky tool is aria-pressed, and the tinted look is styled off that attribute rather than a class — the state a screen reader announces and the state an eye sees cannot drift apart.

Vertical

'use client'import * as React from 'react'import { CircleIcon, MoveIcon, PencilIcon, RulerIcon, SquareIcon, TypeIcon } from 'lucide-react'import { Toolbar, ToolbarButton, ToolbarSeparator } from '@/registry/qure/ui/toolbar'const tools = [  { id: 'pan', label: 'Pan', icon: MoveIcon },  { id: 'length', label: 'Length', icon: RulerIcon },  { id: 'ellipse', label: 'Ellipse ROI', icon: CircleIcon },  { id: 'rect', label: 'Rectangle ROI', icon: SquareIcon },]export default function ToolbarVertical() {  const [tool, setTool] = React.useState('length')  return (    <Toolbar orientation="vertical" aria-label="Annotation tools" className="w-fit">      {tools.map(({ id, label, icon: Icon }) => (        <ToolbarButton          key={id}          size="sm"          aria-label={label}          aria-pressed={tool === id}          onClick={() => setTool(id)}        >          <Icon />        </ToolbarButton>      ))}      {/* A vertical toolbar gets a horizontal separator — Base UI flips it          for you, so nothing is passed here. */}      <ToolbarSeparator />      <ToolbarButton size="sm" aria-label="Freehand"><PencilIcon /></ToolbarButton>      <ToolbarButton size="sm" aria-label="Annotate" disabled><TypeIcon /></ToolbarButton>    </Toolbar>  )}

orientation="vertical" down the side of a viewport. Up and Down move between items, and the separator flips to horizontal on its own. The disabled item stays focusable — Base UI's focusableWhenDisabled defaults to true, which is right: a tool that is off for this series is information, and skipping it silently hides that.

Floating over an image

Axial 1mm · slice 68/214
'use client'import * as React from 'react'import {  ChevronLeftIcon, ChevronRightIcon, LayersIcon, Maximize2Icon, PlayIcon,} from 'lucide-react'import { Toolbar, ToolbarButton, ToolbarSeparator } from '@/registry/qure/ui/toolbar'export default function ToolbarFloating() {  const [slice, setSlice] = React.useState(68)  return (    <div className="relative grid h-64 w-full max-w-md place-items-center overflow-hidden rounded-lg border bg-neutral-900">      <span className="font-mono text-xs text-neutral-500">Axial 1mm · slice {slice}/214</span>      <Toolbar        variant="floating"        aria-label="Series navigation"        className="absolute bottom-3 left-1/2 -translate-x-1/2"      >        <ToolbarButton size="sm" aria-label="Previous slice" onClick={() => setSlice(s => Math.max(1, s - 1))}>          <ChevronLeftIcon />        </ToolbarButton>        <ToolbarButton size="sm" aria-label="Cine"><PlayIcon /></ToolbarButton>        <ToolbarButton size="sm" aria-label="Next slice" onClick={() => setSlice(s => Math.min(214, s + 1))}>          <ChevronRightIcon />        </ToolbarButton>        <ToolbarSeparator />        <ToolbarButton size="sm" aria-label="Overlays" aria-pressed><LayersIcon /></ToolbarButton>        <ToolbarButton size="sm" aria-label="Full screen"><Maximize2Icon /></ToolbarButton>      </Toolbar>    </div>  )}

variant="floating" swaps the border for a shadow, for a toolbar that sits over the image rather than beside it.

With our Button

'use client'import { DownloadIcon, PrinterIcon, Share2Icon } from 'lucide-react'import { Button } from '@/registry/qure/ui/button'import { Toolbar, ToolbarButton, ToolbarLink, ToolbarSeparator } from '@/registry/qure/ui/toolbar'export default function ToolbarWithButton() {  return (    <Toolbar variant="plain" aria-label="Report actions" className="w-full max-w-lg">      {/* render hands the toolbar's roving-focus behaviour to our Button, so          the look comes from Button and the keyboard behaviour from Toolbar.          Do not nest a Button inside a ToolbarButton — that is two buttons. */}      <ToolbarButton variant="bare" render={<Button variant="tertiary" size="sm" />}>        <PrinterIcon />        Print      </ToolbarButton>      <ToolbarButton variant="bare" render={<Button variant="tertiary" size="sm" />}>        <DownloadIcon />        Download      </ToolbarButton>      <ToolbarButton variant="bare" render={<Button variant="tertiary" size="sm" />}>        <Share2Icon />        Share      </ToolbarButton>      <ToolbarSeparator />      <ToolbarLink href="#audit">Audit trail</ToolbarLink>      <ToolbarButton variant="bare" render={<Button variant="primary" size="sm" />} className="ml-auto">        Sign report      </ToolbarButton>    </Toolbar>  )}

For a toolbar of labelled actions rather than icon tools, hand the rendering to Button:

<ToolbarButton variant="bare" render={<Button variant="tertiary" size="sm" />}>
  Print
</ToolbarButton>

variant="bare" drops the toolbar's own look so Button's wins; the roving focus stays with the toolbar. Do not nest a Button inside a ToolbarButton — that is two buttons, and the inner one is not in the roving ring.

ToolbarButton maps size to 32 and 40, the same numbers as Button's icon-sm and icon-md — so a toolbar of icons lines up with a row of buttons beneath it without either being nudged.

API Reference

Prop

Type

On ToolbarButton:

Prop

Type

The rest is Base UI's Toolbar.

On this page