All componentsdisplay
Toolbar
Focus a button and use ArrowLeft/ArrowRight (or Home/End) to move. Only one button is ever a Tab stop.
Installation
$ npx shadcn@latest add https://deepmoon.dev/r/toolbar.jsonProps
ToolbarProps
| Prop | Type | Required |
|---|---|---|
| aria-label | string | Yes |
ToolbarButtonProps
| Prop | Type | Required |
|---|---|---|
| id | string | Yes |
Code
toolbar.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
interface ToolbarContextValue {
activeId: string | null;
setActiveId: (id: string) => void;
}
const ToolbarContext = React.createContext<ToolbarContextValue | null>(null);
function useToolbarContext() {
const context = React.useContext(ToolbarContext);
if (!context) {
throw new Error("<ToolbarButton> must be used within <Toolbar>.");
}
return context;
}
export interface ToolbarProps extends React.ComponentPropsWithoutRef<"div"> {
"aria-label": string;
}
/**
* role="toolbar" with roving tabindex: only one button is ever a Tab stop
* (activeId); ArrowLeft/Right/Home/End move both focus and that stop
* across every [data-toolbar-item] found via DOM query, so it composes
* correctly with ToolbarGroup/ToolbarSeparator without needing them to
* participate in any registration logic.
*/
export function Toolbar({ className, children, onKeyDown, ...props }: ToolbarProps) {
const [activeId, setActiveId] = React.useState<string | null>(null);
const rootRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (activeId !== null) return;
const first = rootRef.current?.querySelector<HTMLElement>("[data-toolbar-item]:not(:disabled)");
if (first?.dataset.toolbarItem) setActiveId(first.dataset.toolbarItem);
}, [activeId]);
return (
<ToolbarContext.Provider value={{ activeId, setActiveId }}>
<div
ref={rootRef}
role="toolbar"
className={cn("flex items-center gap-1", className)}
onKeyDown={(event) => {
onKeyDown?.(event);
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
const items = Array.from(
rootRef.current?.querySelectorAll<HTMLButtonElement>("[data-toolbar-item]:not(:disabled)") ?? [],
);
if (items.length === 0) return;
const currentIndex = items.indexOf(document.activeElement as HTMLButtonElement);
let nextIndex = currentIndex;
if (event.key === "ArrowRight") nextIndex = (currentIndex + 1) % items.length;
else if (event.key === "ArrowLeft") nextIndex = (currentIndex - 1 + items.length) % items.length;
else if (event.key === "Home") nextIndex = 0;
else if (event.key === "End") nextIndex = items.length - 1;
if (nextIndex === currentIndex) return;
event.preventDefault();
const next = items[nextIndex];
next.focus();
if (next.dataset.toolbarItem) setActiveId(next.dataset.toolbarItem);
}}
{...props}
>
{children}
</div>
</ToolbarContext.Provider>
);
}
export function ToolbarGroup({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
return <div role="group" className={cn("flex items-center gap-1", className)} {...props} />;
}
export function ToolbarSeparator({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
return (
<div
role="separator"
aria-orientation="vertical"
className={cn("mx-1 h-5 w-px shrink-0 bg-border", className)}
{...props}
/>
);
}
export interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<"button"> {
/** Stable identity used to track which button is the current roving tab stop. */
id: string;
}
export function ToolbarButton({ id, className, ...props }: ToolbarButtonProps) {
const { activeId, setActiveId } = useToolbarContext();
return (
<button
type="button"
data-toolbar-item={id}
tabIndex={activeId === id ? 0 : -1}
onFocus={() => setActiveId(id)}
className={cn(
"flex size-8 items-center justify-center rounded-md text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
/>
);
}