# Toolbar (Caseroom component)

A row of controls that is one Tab stop; arrows move. Toggle buttons, and a "Was this helpful?" variant. Black and white, dark mode, shadcn-compatible; React, Astro and plain HTML.

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

## Install

- React: `npx shadcn@latest add https://www.themes.dev/caseroom/r/toolbar.json`
- Astro: `npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/toolbar.json`
- HTML: https://www.themes.dev/caseroom/r/html/toolbar.html (with https://www.themes.dev/caseroom/r/html/caseroom.css)

Page: https://www.themes.dev/caseroom/components/toolbar/ · Rules for agents: https://www.themes.dev/caseroom/AGENT.md

## Code

### toolbar.astro

```astro
---
import { cn } from '@/lib/utils';
import { toolbar as c } from './toolbar.variants';

interface Props {
  /** 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;
  class?: string;
}
const { label, feedback, class: className } = Astro.props;
---

{
  feedback ? (
    <div class={cn(c.feedback, className)}>
      <span class={c.question} aria-hidden="true">{label}</span>
      <div role="toolbar" aria-label={label} data-feedback class={c.root}><slot /></div>
      <p data-feedback-status role="status" class={c.thanks}></p>
    </div>
  ) : (
    <div role="toolbar" aria-label={label} class={cn(c.root, className)}><slot /></div>
  )
}

<script>
  import './toolbar.client';
</script>
```

### toolbar.client.ts

```tsx
// 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

```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

```tsx
/** 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',
};
```
