Toolbar
A strip of controls that behaves as one tab stop, with the arrow keys moving between them.
A client component — it manages a roving tabindex. One control is tabbable at a time, so a toolbar costs a reader one Tab rather than one per button; the arrow keys then move between controls, Home and End jump to the ends, and both wrap. Disabled controls are skipped, and clicking or tabbing to a control hands it the tab stop so leaving and coming back returns to where you left off. A text field inside the toolbar keeps its own arrow, Home, and End keys, since those move the caret there — Tab out of it rather than arrowing. Give it an aria-label. ToolbarGroup, ToolbarSeparator, and ToolbarSpacer are exported for the layout; the separator turns to match the toolbar's orientation on its own.
Install
npx shadcn@latest add @vibra/toolbarNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import {
Columns3Icon,
DownloadIcon,
ListFilterIcon,
RowsIcon,
SlidersHorizontalIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Toolbar,
ToolbarGroup,
ToolbarSeparator,
ToolbarSpacer,
} from "@/components/ui/toolbar"
export default function ToolbarDemo() {
const [density, setDensity] = React.useState<"comfortable" | "compact">("comfortable")
return (
<div className="flex w-full flex-col gap-3">
<Toolbar aria-label="Report actions" className="w-full">
<ToolbarGroup>
<Button variant="ghost" size="sm">
<ListFilterIcon data-icon="inline-start" />
Filter
</Button>
<Button variant="ghost" size="sm">
<SlidersHorizontalIcon data-icon="inline-start" />
Group
</Button>
</ToolbarGroup>
<ToolbarSeparator />
<Button
variant="ghost"
size="icon-sm"
aria-pressed={density === "compact"}
aria-label="Compact rows"
onClick={() => setDensity(density === "compact" ? "comfortable" : "compact")}
>
{density === "compact" ? <RowsIcon /> : <Columns3Icon />}
</Button>
<ToolbarSpacer />
<Button variant="ghost" size="sm">
<DownloadIcon data-icon="inline-start" />
Export
</Button>
</Toolbar>
<p className="text-sm text-muted-foreground">
Tab into the toolbar once, then use the arrow keys. Rows are {density}.
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| orientation | "horizontal" | "vertical" | "horizontal" | Which axis the toolbar runs on, and which arrow keys move along it. |
| size | "sm" | "default" | "default" | sm tightens the padding and the gap between controls. |
| ToolbarGroup | React.ComponentProps<"div"> | — | Controls that belong together, kept tight against each other. |
| ToolbarSeparator | React.ComponentProps<typeof Separator> | — | A hairline between two groups, drawn across the toolbar's own axis. |
| ToolbarSpacer | React.ComponentProps<"div"> | — | Pushes everything after it to the far end of the toolbar. |
Dependencies
Registry
Source
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
// Everything a toolbar hands a tab stop to. Disabled items are excluded by the
// selector rather than filtered afterwards, so arrow keys walk straight past
// them; a node matching two of these branches is still returned once.
const ITEM_SELECTOR = [
"button:not([disabled])",
"a[href]",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[role="button"]',
'[role="checkbox"]',
'[role="radio"]',
].join(",")
// A text field owns its own arrows, Home, and End: inside one those keys move
// the caret, and inside a select they change the option. Readers Tab out of
// such a control rather than arrowing out of it.
const NON_TEXT_INPUTS = ["button", "checkbox", "color", "file", "radio", "range", "reset", "submit"]
function ownsArrowKeys(element: Element | null): boolean {
if (!element) return false
if (element.tagName === "TEXTAREA" || element.tagName === "SELECT") return true
if (element.tagName !== "INPUT") return false
return !NON_TEXT_INPUTS.includes((element as HTMLInputElement).type)
}
function itemsOf(root: HTMLElement | null): HTMLElement[] {
if (!root) return []
return Array.from(root.querySelectorAll<HTMLElement>(ITEM_SELECTOR)).filter(
(element) => element.getAttribute("aria-disabled") !== "true" && !element.hidden
)
}
const toolbarVariants = cva(
"group/toolbar flex w-fit items-center panel p-1 text-foreground",
{
variants: {
orientation: { horizontal: "flex-row", vertical: "flex-col items-stretch" },
size: { default: "gap-1", sm: "gap-0.5 p-0.5" },
},
defaultVariants: { orientation: "horizontal", size: "default" },
}
)
const ToolbarContext = React.createContext<{
orientation: NonNullable<VariantProps<typeof toolbarVariants>["orientation"]>
}>({ orientation: "horizontal" })
export type ToolbarProps = React.ComponentProps<"div"> & {
orientation?: NonNullable<VariantProps<typeof toolbarVariants>["orientation"]>
size?: NonNullable<VariantProps<typeof toolbarVariants>["size"]>
}
/** A strip of controls that behaves as one tab stop, with the arrow keys moving between them. */
function Toolbar({
className,
orientation = "horizontal",
size = "default",
onKeyDown,
onFocus,
...props
}: ToolbarProps) {
const ref = React.useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = React.useState(0)
// Runs after every render, not just when activeIndex changes: children come
// and go (a filter chip appears, an action disables), and each new one needs
// its tabIndex set before the reader can tab into the toolbar.
React.useEffect(() => {
const items = itemsOf(ref.current)
if (items.length === 0) return
const active = Math.min(activeIndex, items.length - 1)
items.forEach((item, index) => {
item.tabIndex = index === active ? 0 : -1
})
})
function moveTo(index: number, items: HTMLElement[]) {
setActiveIndex(index)
items[index]?.focus()
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
onKeyDown?.(event)
if (event.defaultPrevented || ownsArrowKeys(document.activeElement)) return
const items = itemsOf(ref.current)
if (items.length === 0) return
const next = orientation === "vertical" ? "ArrowDown" : "ArrowRight"
const previous = orientation === "vertical" ? "ArrowUp" : "ArrowLeft"
const current = items.indexOf(document.activeElement as HTMLElement)
if (event.key === next) {
event.preventDefault()
moveTo((current + 1) % items.length, items)
} else if (event.key === previous) {
event.preventDefault()
moveTo((current <= 0 ? items.length : current) - 1, items)
} else if (event.key === "Home") {
event.preventDefault()
moveTo(0, items)
} else if (event.key === "End") {
event.preventDefault()
moveTo(items.length - 1, items)
}
}
// A click or a Tab into the middle of the toolbar hands that item the tab
// stop, so leaving and coming back returns to where the reader left off.
function handleFocus(event: React.FocusEvent<HTMLDivElement>) {
onFocus?.(event)
const index = itemsOf(ref.current).indexOf(event.target as HTMLElement)
if (index >= 0) setActiveIndex(index)
}
return (
<ToolbarContext.Provider value={{ orientation }}>
<div
ref={ref}
role="toolbar"
aria-orientation={orientation}
data-slot="toolbar"
data-orientation={orientation}
data-size={size}
className={cn(toolbarVariants({ orientation, size }), className)}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
{...props}
/>
</ToolbarContext.Provider>
)
}
/** Buttons that belong together, kept tight against each other inside a Toolbar. */
function ToolbarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="toolbar-group"
className={cn(
"flex items-center gap-0.5 group-data-[orientation=vertical]/toolbar:flex-col group-data-[orientation=vertical]/toolbar:items-stretch",
className
)}
{...props}
/>
)
}
/** A hairline between two groups, drawn across the toolbar's own axis. */
function ToolbarSeparator({
className,
...props
}: Omit<React.ComponentProps<typeof Separator>, "orientation">) {
const { orientation } = React.useContext(ToolbarContext)
return (
<Separator
data-slot="toolbar-separator"
orientation={orientation === "vertical" ? "horizontal" : "vertical"}
className={cn("data-vertical:my-0.5 data-vertical:h-5 data-horizontal:mx-0.5", className)}
{...props}
/>
)
}
/** Pushes everything after it to the far end of the toolbar. */
function ToolbarSpacer({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="toolbar-spacer" aria-hidden="true" className={cn("flex-1", className)} {...props} />
)
}
export { Toolbar, ToolbarGroup, ToolbarSeparator, ToolbarSpacer, toolbarVariants }