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,062 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/ caseroom/ r/ toolbar.json - Astro
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ astro/ toolbar.json
Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS and the script.
// 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;
}
});
}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;/** 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',
};---
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 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;
}
});
}/** 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',
};<div class="grid justify-items-center gap-8">
<div role="toolbar" aria-label="View" class="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)]">
<div class="flex items-center gap-1 [&+&]:ml-2">
<button type="button" data-toggle aria-pressed="true" aria-label="Grid" class="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">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 2.5h4.5v4.5h-4.5zM9 2.5h4.5v4.5h-4.5zM2.5 9h4.5v4.5h-4.5zM9 9h4.5v4.5h-4.5z"></path></svg>
</button>
<button type="button" data-toggle aria-pressed="false" aria-label="List" class="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">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4h11M2.5 8h11M2.5 12h11"></path></svg>
</button>
</div>
<div class="flex items-center gap-1 [&+&]:ml-2">
<button type="button" class="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">
Share
</button>
<button type="button" disabled class="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">
Export
</button>
</div>
</div>
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-2 text-sm">
<span class="font-medium" aria-hidden="true">Was this page helpful?</span>
<div role="toolbar" aria-label="Was this page helpful?" data-feedback class="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)]">
<button type="button" class="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">
Yes
</button>
<button type="button" class="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">
No
</button>
</div>
<p data-feedback-status role="status" class="text-muted-foreground empty:hidden"></p>
</div>
</div><script type="module" src="/caseroom/r/html/toolbar.js"></script>