component — actions

Toolbar

Horizontal or vertical container that groups actions, toggles and separators into a single keyboard-navigable surface, built on react-aria-components and styled with the quebi design system.

  • action
  • toolbar
  • group
  • toggle
  • button
  • interactive

07 examples

Default

A horizontal toolbar grouping text-formatting toggles with a separator.

Actions and toggles

ToolbarItem is a toggle, so it reports a pressed state; ToolbarButton is a plain action that does not. Both carry the toolbar's size and intent defaults, so a mixed row lines up without hand-sizing anything.

With labels

Items can carry text alongside (or instead of) icons.

Vertical

Set orientation to stack items into a vertical rail.

Circle

isCircle rounds the toolbar and its items into pill/circle shapes.

Controlled selection

ToolbarItem is a toggle — wire its selected state like any controlled toggle.

Disabled group

A ToolbarGroup can disable all of its items at once.

source

toolbar.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { createContext, use } from "react"
import {
  composeRenderProps,
  Group,
  type GroupProps,
  type SeparatorProps,
  Toolbar as ToolbarPrimitive,
  type ToolbarProps as ToolbarPrimitiveProps,
} from "react-aria-components"
import { Button, type ButtonProps } from "@/components/button"
import { Separator } from "@/components/separator"
import { Toggle, type ToggleProps } from "@/components/toggle"
import { cn } from "@/lib/utils"

/**
 * Toolbar — quebi design system
 *
 * A horizontal or vertical container that groups related controls — actions,
 * toggles and separators — into a single keyboard-navigable surface. Built on
 * react-aria-components for roving-focus and arrow-key navigation.
 *
 * Drawn as a ruled tray: a hairline box at the surface radius, no fill of its
 * own — the controls inside carry their own edges.
 *
 * Two kinds of item, and the choice between them is not cosmetic:
 * `ToolbarItem` is a Toggle, so it has an on state that fills with ink and
 * reports `aria-pressed`. `ToolbarButton` is a Button — Save, Export,
 * Delete — which has no such state, and saying it does would be a lie to a
 * screen reader. Both carry the same size and intent defaults, so a row of
 * either or both lines up.
 *
 * Anything else can go in directly: `children` is unrestricted and react-aria's
 * roving focus picks the control up. Ask it for the same 38px the two items
 * default to — `<SelectTrigger size="sm">`, `<Input size="sm">` — and the row
 * stays one height. A `ToggleGroup` is the one control that cannot be asked in
 * those terms, because its shell is 6px taller than the item inside it at every
 * size: `<ToggleGroup size="sm" height="control">` is how it joins the row at
 * 38px, and `height` is there for this (task #209).
 */

interface ToolbarProps extends ToolbarPrimitiveProps {
  isCircle?: boolean
}

const ToolbarContext = createContext<{
  orientation?: ToolbarProps["orientation"]
  isCircle?: boolean
}>({
  orientation: "horizontal",
  isCircle: false,
})

const Toolbar = ({ orientation = "horizontal", isCircle, className, ...props }: ToolbarProps) => {
  return (
    <ToolbarContext value={{ orientation, isCircle }}>
      <ToolbarPrimitive
        orientation={orientation}
        {...props}
        className={composeRenderProps(className, (className, { orientation }) =>
          cn(
            "group inline-flex gap-1.5 border border-quebi-hairline p-1.5",
            "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
            isCircle ? "rounded-full" : "rounded-(--q-radius-surface)",
            orientation === "horizontal"
              ? "flex-row items-center"
              : "flex-col items-start",
            className,
          ),
        )}
      />
    </ToolbarContext>
  )
}

const ToolbarGroupContext = createContext<{ isDisabled?: boolean; isCircle?: boolean }>({})

interface ToolbarGroupProps extends GroupProps {}

const ToolbarGroup = ({ isDisabled, className, ...props }: ToolbarGroupProps) => {
  return (
    <ToolbarGroupContext value={{ isDisabled }}>
      <Group
        className={composeRenderProps(className, (resolved) =>
          cn(
            "flex gap-1.5",
            "group-orientation-vertical:flex-col group-orientation-vertical:items-start",
            "group-orientation-horizontal:items-center",
            resolved,
          ),
        )}
        {...props}
      >
        {props.children}
      </Group>
    </ToolbarGroupContext>
  )
}

interface ToolbarItemProps extends ToggleProps {}

/**
 * A two-state control in the tray — bold, italic, "show archived". Reach for
 * `ToolbarButton` when the control fires and forgets.
 */
const ToolbarItem = ({
  isDisabled,
  isCircle,
  size = "sm",
  intent = "outline",
  ref,
  className,
  ...props
}: ToolbarItemProps) => {
  const context = use(ToolbarGroupContext)
  const { isCircle: contextCircle } = use(ToolbarContext)
  const effectiveIsDisabled = isDisabled || context.isDisabled
  const effectiveIsCircle = isCircle || contextCircle
  return (
    <Toggle
      intent={intent}
      size={size}
      ref={ref}
      data-slot="toolbar-item"
      isCircle={effectiveIsCircle}
      className={className}
      isDisabled={effectiveIsDisabled}
      {...props}
    />
  )
}

interface ToolbarButtonProps extends ButtonProps {}

/**
 * A plain action in the tray — the same defaults as `ToolbarItem`, minus the
 * pressed state.
 *
 * Without it, a Save or Export button in a toolbar is either a `ToolbarItem`
 * asserting an `aria-pressed` that means nothing, or a bare `Button` hand-sized
 * to match its neighbours — which is where the heights stop agreeing. The
 * defaults are deliberately `ToolbarItem`'s, character for character.
 */
const ToolbarButton = ({
  isDisabled,
  isCircle,
  size = "sm",
  intent = "outline",
  ref,
  className,
  ...props
}: ToolbarButtonProps) => {
  const context = use(ToolbarGroupContext)
  const { isCircle: contextCircle } = use(ToolbarContext)
  const effectiveIsDisabled = isDisabled || context.isDisabled
  const effectiveIsCircle = isCircle || contextCircle
  return (
    <Button
      intent={intent}
      size={size}
      ref={ref}
      data-slot="toolbar-button"
      isCircle={effectiveIsCircle}
      className={className}
      isDisabled={effectiveIsDisabled}
      {...props}
    />
  )
}

type ToolbarSeparatorProps = SeparatorProps

const ToolbarSeparator = ({ className, ...props }: ToolbarSeparatorProps) => {
  const { orientation } = use(ToolbarContext)
  const reverseOrientation = orientation === "vertical" ? "horizontal" : "vertical"
  return (
    <Separator
      orientation={reverseOrientation}
      className={cn(
        reverseOrientation === "vertical" ? "mx-0.5 h-6" : "my-0.5 w-8",
        className,
      )}
      {...props}
    />
  )
}

export type {
  ToolbarButtonProps,
  ToolbarItemProps,
  ToolbarGroupProps,
  ToolbarProps,
  ToolbarSeparatorProps,
}
export { Toolbar, ToolbarButton, ToolbarGroup, ToolbarItem, ToolbarSeparator }