Skip to content
    Style: Wireframe

    Components · Actions

    Toolbar

    A row of controls that is one Tab stop; arrows move. Toggle buttons, and a "Was this helpful?" variant.

    3.7% of the 2,073 sites in The UI Index have a toolbar (76 sites).

    Both previews render the same markup; the HTML tab below is that markup without a framework.

    Install

    React
    npx shadcn@latest add https://www.themes.dev/ui/r/toolbar.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/toolbar.json

    Or copy the files below into your project. They need clsx, tailwind-merge, and the themes.dev UI theme. The HTML needs only the compiled CSS and the script.

    toolbar.client.ts
    // Toolbar behaviour, shared by the Astro, React and HTML versions (WAI-ARIA toolbar): one Tab stop (roving
    // tabindex), Left/Right wrap, Home/End; a [data-toggle] button flips aria-pressed; in a [data-feedback] toolbar a
    // press records the answer, says thanks in the status line and turns the buttons off.
    declare global {
      interface Window {
        __caseroomToolbar?: boolean;
      }
    }
    
    const controls = (bar: Element) => [...bar.querySelectorAll<HTMLButtonElement>('button')].filter((b) => !b.disabled);
    
    export function wireToolbar(bar: Element) {
      const list = controls(bar);
      if (!list.length || list.some((b) => b.tabIndex === 0 && b.getAttribute('tabindex') === '0')) return;
      list.forEach((b, k) => (b.tabIndex = k === 0 ? 0 : -1));
    }
    
    if (typeof window !== 'undefined' && !window.__caseroomToolbar) {
      window.__caseroomToolbar = true;
      const all = () => document.querySelectorAll('[role=toolbar]').forEach(wireToolbar);
      if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', all);
      else all();
      document.addEventListener('keydown', (e) => {
        const b = (e.target as Element).closest?.<HTMLButtonElement>('[role=toolbar] button');
        if (!b) return;
        const list = controls(b.closest('[role=toolbar]')!);
        const i = list.indexOf(b);
        const next = e.key === 'ArrowRight' ? list[(i + 1) % list.length] : e.key === 'ArrowLeft' ? list[(i - 1 + list.length) % list.length] : e.key === 'Home' ? list[0] : e.key === 'End' ? list.at(-1) : undefined;
        if (!next) return;
        e.preventDefault();
        for (const x of list) x.tabIndex = x === next ? 0 : -1;
        next.focus();
      });
      document.addEventListener('click', (e) => {
        const b = (e.target as Element).closest<HTMLButtonElement>('[role=toolbar] button');
        if (!b || e.defaultPrevented) return;
        const bar = b.closest('[role=toolbar]')!;
        for (const x of controls(bar)) x.tabIndex = x === b ? 0 : -1;
        if (b.matches('[data-toggle]')) b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed') !== 'true'));
        if (bar.matches('[data-feedback]')) {
          const status = bar.parentElement?.querySelector('[data-feedback-status]');
          if (status) status.textContent = 'Thanks, noted.';
          b.setAttribute('aria-pressed', 'true');
          for (const x of bar.querySelectorAll<HTMLButtonElement>('button')) if (x !== b) x.disabled = true;
        }
      });
    }
    toolbar.tsx
    import { useEffect, useRef, type ReactNode } from 'react';
    import { cn } from '@/lib/utils';
    import './toolbar.client';
    import { wireToolbar } from './toolbar.client';
    import { toolbar as c } from './toolbar.variants';
    
    export interface ToolbarProps {
      /** The toolbar's name, e.g. "Text formatting" or "Was this page helpful?". */
      label: string;
      /** Feedback: the label is shown as the question; a press thanks the reader in a status line. */
      feedback?: boolean;
      /** Buttons (className={toolbarButton}); add data-toggle for aria-pressed toggles. */
      children: ReactNode;
      className?: string;
    }
    
    /** A row of controls that is one Tab stop; arrow keys, Home and End move between them. */
    export function Toolbar({ label, feedback, children, className }: ToolbarProps) {
      const ref = useRef<HTMLDivElement>(null);
      useEffect(() => {
        if (ref.current) wireToolbar(ref.current);
      }, []);
      if (feedback)
        return (
          <div className={cn(c.feedback, className)}>
            <span className={c.question} aria-hidden="true">{label}</span>
            <div ref={ref} role="toolbar" aria-label={label} data-feedback="" className={c.root}>{children}</div>
            <p data-feedback-status="" role="status" className={c.thanks}></p>
          </div>
        );
      return <div ref={ref} role="toolbar" aria-label={label} className={cn(c.root, className)}>{children}</div>;
    }
    export const toolbarButton = c.button;
    export const toolbarGroup = c.group;
    toolbar.variants.ts
    /** A toolbar (WAI-ARIA): a row of controls that is one Tab stop; Left/Right (and Home/End) move between them.
     *  Groups are separated by space, not rules. Toggle buttons use aria-pressed. The feedback variant ("Was this
     *  page helpful?") answers in a status line once a button is pressed. */
    export const toolbar = {
      // One row; when it is wider than its space it scrolls (a toolbar that wraps breaks into uneven rows).
      root: 'inline-flex max-w-full items-center gap-1 overflow-x-auto rounded-lg border p-1 [scrollbar-width:none] [&>*]:shrink-0 [background:linear-gradient(to_right,var(--background)_50%,transparent)_left/2rem_100%_no-repeat_local,linear-gradient(to_left,var(--background)_50%,transparent)_right/2rem_100%_no-repeat_local,radial-gradient(farthest-side_at_0_50%,var(--tabs-shade,rgb(0_0_0/0.22)),transparent)_left/0.75rem_100%_no-repeat_scroll,radial-gradient(farthest-side_at_100%_50%,var(--tabs-shade,rgb(0_0_0/0.22)),transparent)_right/0.75rem_100%_no-repeat_scroll,var(--background)]',
      group: 'flex items-center gap-1 [&+&]:ml-2',
      button:
        'inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md px-2.5 text-sm font-medium text-muted-foreground transition-[background-color,color,transform] select-none hover:bg-accent hover:text-foreground active:scale-[0.97] active:bg-pressed aria-pressed:bg-accent aria-pressed:text-foreground aria-pressed:hover:bg-pressed aria-pressed:active:bg-[color-mix(in_oklab,var(--pressed)_82%,var(--foreground))] disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4',
      feedback: 'flex flex-wrap items-center justify-center gap-x-3 gap-y-2 text-sm',
      question: 'font-medium',
      thanks: 'text-muted-foreground empty:hidden',
    };