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.tsxCopy 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 }