DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

Toolbar

A bar of mixed controls — buttons, toggles, links, an input, separators — with one tab stop and arrow-key traversal. Five variants, three sizes, five radii, horizontal + vertical, LTR/RTL. Never wraps.

Playground

Installation

pnpm add @tessinaui/ui

Usage

import {
  Toolbar,
  ToolbarButton,
  ToolbarGroup,
  ToolbarLink,
  ToolbarInput,
  ToolbarSeparator,
  ToolbarSpacer,
} from "@tessinaui/ui";
{/* Formatting bar — `pressed` derives `aria-pressed`, so state is announced. */}
function FormatToolbar() {
  const [bold, setBold]     = useState(true);
  const [italic, setItalic] = useState(false);
  const [align, setAlign]   = useState("left");

  return (
    <Toolbar aria-label="Format text">
      <ToolbarButton iconOnly leadingIcon={<Undo2 />} aria-label="Undo" />
      <ToolbarSeparator />
      <ToolbarButton iconOnly leadingIcon={<Bold />} aria-label="Bold"
        pressed={bold} onClick={() => setBold((v) => !v)} />
      <ToolbarButton iconOnly leadingIcon={<Italic />} aria-label="Italic"
        pressed={italic} onClick={() => setItalic((v) => !v)} />
      <ToolbarSeparator />
      <ToolbarButton iconOnly leadingIcon={<AlignLeft />} aria-label="Align left"
        pressed={align === "left"} onClick={() => setAlign("left")} />
      <ToolbarButton iconOnly leadingIcon={<AlignCenter />} aria-label="Align center"
        pressed={align === "center"} onClick={() => setAlign("center")} />
      <ToolbarSpacer />
      <ToolbarLink href="/help">Help</ToolbarLink>
    </Toolbar>
  );
}

{/* Table action bar — the input goes LAST, and the bar scrolls when narrow. */}
<Toolbar variant="outline" overflow="scroll" aria-label="Table actions">
  <ToolbarButton leadingIcon={<Filter />} trailingIcon={<ChevronDown />}>Filter</ToolbarButton>
  <ToolbarButton leadingIcon={<Plus />}>Add row</ToolbarButton>
  <ToolbarSpacer />
  <ToolbarInput placeholder="Search…" aria-label="Search rows" />
</Toolbar>

{/* Floating canvas bar */}
<Toolbar variant="floating" rounded="full" aria-label="Selection tools">
  <ToolbarButton iconOnly leadingIcon={<Bold />} aria-label="Bold" pressed />
  <ToolbarButton iconOnly leadingIcon={<Italic />} aria-label="Italic" />
</Toolbar>

Examples

Default

Icon buttons in labelled groups, divided by separators.

Variants

Five surfaces — soft (default, the iOS well), solid, outline, ghost, and floating for a bar detached from what it sits over.

Sizes

sm, md and lg drive the item heights — 32 / 36 / 40px, putting the bar at 40 / 44 / 48. At md that is exactly the 44px iOS toolbar height.

Rounded

Every corner derives from one rounded value: the bar, each item and a contained group step together, so the items always tuck inside the bar's silhouette.

Groups

ToolbarGroup clusters related items and names them for assistive technology. variant="contained" gives the cluster its own tinted well.

Pressed

pressed paints the raised plate and derives aria-pressed. The bar does not own the state — these are independent toggles held by the consumer.

Real toggle state

When a cluster needs controlled state, single- or multi-selection and a change event, compose a ToggleGroup inside the bar rather than wiring pressed by hand.

Overflow — scroll

A toolbar never wraps to a second line, so a bar with more tools than width becomes a rail along its own axis. This is the default, because it costs nothing while the items fit and it is what every phone editor ships.

Overflow — paging chevron

A rail only announces itself once you touch it. overflowAffordance pins a chevron at the end that pages the bar — the control Apple's edit menu puts after "Select All".

It renders only while the content actually overflows (measured with a ResizeObserver and a MutationObserver, so an item added later still counts), and it turns round at the end rather than going dead. Switching it on moves the items into an inner toolbar-rail element so the button can sit beside the rail instead of scrolling away with it — which is why it is opt-in: a bar that fits should not pay for the extra element.

Needs overflow="scroll" (the default). Under overflow="none" the bar clips, so there is nothing to page to, and the prop warns in development instead of rendering a button that cannot work.

Overflow — menu

The alternative: keep the frequent tools visible and put the rest behind a trailing menu. Unlike a rail, nothing ends up off-screen.

Menus

A menu opened from the bar. The trigger is a ToolbarButton passed through the menu's render, so it keeps its place in the roving focus instead of becoming a second tab stop.

Floating

A bar with a surface of its own, lifted off the content. Pinned to the bottom of a phone screen with the safe-area inset, this is the keyboard accessory bar.

Search and actions

Leading tools, trailing actions, and the real Search component filling the rest of the bar, so the field never ends up squeezed into the corner. Reach for ToolbarSpacer instead when the trailing item should keep its natural size.

Search reaches the bar through ToolbarInput's render: its input joins the bar's roving focus — arrows still traverse, Tab still leaves — while Search keeps the magnifier, Escape-to-clear, the clear button, the iOS search keyboard and its polite result-count region. Pass noForm (Search is a <form> by default, and nested forms are invalid HTML), size it from inputFieldSizeFor[size] so it matches item height at every bar density, and give it a layout box through wrapperClassName — Search's root is w-full and would otherwise claim the whole row.

Selection bar

A count, the actions that apply to it, and a way out — the batch-action bar over a list or a grid.

Labeled items

The stacked icon-over-label item media editors ship. A className recipe rather than a prop; a stacked item clears the 44px touch floor on its own.

In flight

loading announces aria-busy, swallows activation and shows the spinner — while the item stays focusable, so a request in progress never throws the user's focus back to the start of the bar.

Vertical

orientation="vertical" stacks the items into a rail. At rounded="full" the bar derives a stadium radius from its own item height rather than clipping into an oval.

RTL

dir="rtl" mirrors the layout and flips the arrow keys, by wrapping the subtree in Base UI's DirectionProvider.

Disabled

A disabled item stays reachable by arrow key, so a user can discover that a tool exists and is currently unavailable. disabled on the bar cascades to every item.

Skeleton

The placeholder paints from the same cvas the real bar renders with, so its geometry cannot drift from the component.

Composition

The toolbar is a focus-traversal container: arrow keys move between items, Tab leaves for the next region. Any focusable primitive composes inside it.

Give the bar a name. A screen reader announces the role and then has nothing to say about which bar this is. In development, an unnamed Toolbar — or an unnamed iconOnly item — logs a warning.

Toggle state. pressed on a ToolbarButton is a per-item visual flag that derives aria-pressed for you:

const [bold, setBold] = useState(false);

<ToolbarButton
  iconOnly
  leadingIcon={<Bold />}
  aria-label="Bold"
  pressed={bold}
  onClick={() => setBold((v) => !v)}
/>

For a cluster that needs real state — controlled or uncontrolled, single- or multi-select, with a change event — compose ToggleGroup inside the bar instead. Pair the sizes deliberately: the toolbar's sm / md / lg items are 32 / 36 / 40px, which line up with ToggleGroup's xs / xs / sm.

Menus, selects and tooltips compose through Base UI's render, which keeps the trigger inside the roving focus:

{/* A menu opened from the bar */}
<DropdownMenuTrigger
  render={<ToolbarButton iconOnly leadingIcon={<MoreHorizontal />} aria-label="More tools" />}
/>

{/* A tooltip — note the direction reverses: the item goes INTO the trigger */}
<Tooltip>
  <TooltipTrigger render={<ToolbarButton iconOnly leadingIcon={<Bold />} aria-label="Bold" />} />
  <TooltipContent>Bold ⌘B</TooltipContent>
</Tooltip>

An icon-only item needs both a tooltip (for sighted users) and an aria-label (for everyone else). The tooltip is not an accessible name.

API Reference

Toolbar (root)

PropTypeDefaultDescription
variant"solid" | "soft" | "outline" | "ghost" | "floating""soft"Bar chrome. floating is a detached, shadowed surface.
size"sm" | "md" | "lg""md"Bar inset, gap and item height (32 / 36 / 40px).
rounded"none" | "sm" | "md" | "lg" | "full""md"Corner radius; items and contained groups derive from it.
orientation"horizontal" | "vertical""horizontal"Layout axis.
overflowAffordancebooleanfalsePin a paging chevron at the end, shown only while the content overflows. Requires overflow="scroll".
overflowAffordanceLabels{ forward?: string; back?: string }{ forward: "Scroll forward", back: "Scroll back" }Accessible names for the affordance — override for i18n.
overflow"none" | "scroll""scroll"What happens when the items exceed the bar. scroll is a rail along the bar's own axis and costs nothing while the items fit; none clips instead. The bar never wraps either way.
tone"default" | "on-color"inheritedInk and chrome for the ground under the bar. Inherited from an enclosing Surface.
loopFocusbooleantrueWrap arrow-key focus at the ends.
disabledbooleanfalseDisable every item in the bar.
dir"ltr" | "rtl"inheritedReading direction. Set explicitly, it also flips the arrow keys.
aria-labelstring—Required in practice; warns in development when absent.

ToolbarButton

PropTypeDefaultDescription
iconOnlybooleanfalseSquare item, no horizontal padding. Needs an aria-label.
leadingIconReactNode—Icon before the content.
trailingIconReactNode—Icon after the content.
pressedboolean—Toggle state. Renders the raised plate and derives aria-pressed. Leave unset for a plain action button.
loadingbooleanfalseWork in flight: aria-busy, activation swallowed, item stays focusable.
disabledbooleanfalseDisable this item.
focusableWhenDisabledbooleantrueKeep a disabled item in the arrow-key sequence (Base UI default).

ToolbarLink

The same shape as ToolbarButton, rendering an <a>. Pass href and the usual anchor attributes.

ToolbarInput

PropTypeDefaultDescription
invalidbooleanfalseTints the boundary and sets aria-invalid. Ignored under render — a composed field owns its own validity chrome.
renderReactElement—Put a composite field in the bar's roving order instead of the bare input. Under render this part contributes no chrome: the composed component brings its own border, fill, height and radius.

Place at most one, last. Left/right arrows move the caret as well as traversing the bar, so items after an input are reachable only once the caret has run to the end of the value. A second input in one bar warns in development.

A composite may mount controls of its own inside the field — Search's clear button appears once the value is non-empty — and those are ordinary tab stops. An untouched bar is still one tab stop; a dirty one is two.

ToolbarGroup

PropTypeDefaultDescription
variant"none" | "contained""none"none groups for ARIA and spacing; contained adds a tinted well.
disabledbooleanfalseDisable every item in the group.
aria-labelstring—Recommended on any group that is not self-evident.

ToolbarSeparator

Orientation-aware rule: a vertical line in a horizontal bar, a horizontal one in a vertical bar. Inset off both ends and deliberately fainter than --border, which reads as a wall between buttons.

ToolbarSpacer

A flex-1 filler that pushes everything after it to the trailing edge. Under overflow="scroll" it collapses to zero before the rail starts scrolling — fill the bar, then scroll.

ToolbarSkeleton

PropTypeDefaultDescription
itemsnumber4Placeholder items.
groupsnumber[]—Items per cluster, separated as the real bar is. Overrides items.
spacerbooleanfalseA flex spacer before the trailing items.
labelledItemsnumber0Trailing items that carry a text label.

Plus size, variant, rounded, orientation and tone, which mirror the real bar.

Notes

  • A toolbar never wraps. Fluent, Primer, Material 3 and Carbon all specify this, and the alternative broke ToolbarSpacer: once a row wrapped, the spacer took a line of its own and stopped pushing. What it does instead is overflow's business — scroll (the default) rails, none clips. Neither lets content escape, because an unclipped bar pushes the whole page sideways, which is WCAG 1.4.10: measured at up to 98px on six of the examples below before the clip existed. To keep every action visible at any width, move the surplus into an overflow menu.
  • Pressed items render as a raised plate lifted off the well — the same treatment SegmentedControl gives its thumb, so the two read as one family. On a page-coloured bar (outline, floating, ghost) nothing can lift above the background, so the pressed state recesses into the well instead. It stays visible in Windows high-contrast.
  • Disabled items stay focusable by default, matching Base UI and WAI-ARIA: a user arrowing along the bar should be able to discover that a tool exists and is unavailable, rather than have it silently vanish.
  • Home / End are not bound. Base UI's toolbar composite does not bind them and does not expose the option; APG makes them optional wherever arrow wrapping exists, which loopFocus provides.
  • RTL — dir="rtl" mirrors the layout and flips the arrow keys, because the bar wraps its subtree in DirectionProvider. Setting the dir attribute by hand moves only the paint.
  • Touch — every item clears 44×44px on phones (the input included, which its own flex-1 would otherwise squeeze to 26px) while keeping the compact desktop density above md, and ToolbarInput renders at 16px on phones so iOS does not zoom the page on focus.
  • Accessibility — renders role="toolbar" with aria-orientation, one tab stop, arrow-key traversal. Always pass aria-label. Icon-only items need both a tooltip and an aria-label.
TokenizerTooltip

On this page

PlaygroundInstallationUsageExamplesDefaultVariantsSizesRoundedGroupsPressedReal toggle stateOverflow — scrollOverflow — paging chevronOverflow — menuMenusFloatingSearch and actionsSelection barLabeled itemsIn flightVerticalRTLDisabledSkeletonCompositionAPI ReferenceToolbar (root)ToolbarButtonToolbarLinkToolbarInputToolbarGroupToolbarSeparatorToolbarSpacerToolbarSkeletonNotes