Toolbar
A row of 48px icon actions, 8px apart, with arrow-key focus.
Installation
$ pnpm dlx shadcn@latest add @ssntl/toolbarUsage
import { Toolbar, ToolbarButton, ToolbarSeparator } from "@/components/ui/toolbar"
<Toolbar aria-label="Text formatting">
<ToolbarButton aria-label="Bold"><Bold /></ToolbarButton>
<ToolbarButton aria-label="Italic"><Italic /></ToolbarButton>
<ToolbarSeparator />
<ToolbarButton aria-label="Add link"><Link /></ToolbarButton>
</Toolbar>- Target spacing: 48px targets with 8px between them, so a tap never lands on the neighbour.
- The bar and every button need an aria-label (the types require it); the icons are decorative.
- Arrow keys, Home and End move focus inside the bar.
- Separators group related actions; they are not targets.
Code
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/ssntl/ui/button"
// A row of icon actions. Every target is 48px and they sit 8px apart, so a
// thumb never lands on the neighbour. Concentric: 4px of padding around 48px
// pills makes the bar a pill too. Arrow keys, Home and End move focus.
function Toolbar({
className,
onKeyDown,
...props
}: React.ComponentProps<"div"> & { "aria-label": string }) {
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
const keys = ["ArrowLeft", "ArrowRight", "Home", "End"]
if (event.defaultPrevented || !keys.includes(event.key)) return
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>(
"[data-slot=toolbar-button]:not(:disabled)"
)
)
const current = items.indexOf(document.activeElement as HTMLElement)
if (current === -1) return
event.preventDefault()
const last = items.length - 1
const next = {
ArrowLeft: current === 0 ? last : current - 1,
ArrowRight: current === last ? 0 : current + 1,
Home: 0,
End: last,
}[event.key]
items[next ?? current]?.focus()
}
return (
<div
role="toolbar"
data-slot="toolbar"
className={cn(
"inline-flex items-center gap-2 rounded-full bg-card p-1 shadow-float",
className
)}
onKeyDown={handleKeyDown}
{...props}
/>
)
}
// Icon only, so the label is required: it is the button's name.
function ToolbarButton({
className,
variant = "ghost",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> & {
"aria-label": string
}) {
return (
<Button
data-slot="toolbar-button"
variant={variant}
size="icon"
className={cn("px-0", className)}
{...props}
/>
)
}
// Splits the bar into groups; it adds no target of its own.
function ToolbarSeparator({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
role="separator"
aria-orientation="vertical"
data-slot="toolbar-separator"
className={cn("h-6 w-px bg-border", className)}
{...props}
/>
)
}
export { Toolbar, ToolbarButton, ToolbarSeparator }