Skip to contentVibraUI
Navigation & layout

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/toolbar

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
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.
ToolbarGroupReact.ComponentProps<"div">—Controls that belong together, kept tight against each other.
ToolbarSeparatorReact.ComponentProps<typeof Separator>—A hairline between two groups, drawn across the toolbar's own axis.
ToolbarSpacerReact.ComponentProps<"div">—Pushes everything after it to the far end of the toolbar.

Dependencies

Source

components/ui/toolbar.tsx
"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 }