Toolbar
Group a set of related controls into one framed row. The toolbar is a single tab stop; arrow keys rove between its items, so a dense bar of actions never floods the page's tab order.
Basic
A row of buttons, a toggle, and a link, divided into clusters by a hairline separator. The first item is the only tab stop — once focus is inside, the arrow keys move between items. A Toolbar.Toggle is just another flat item that shows an on state (the pressed fill is the neutral selected fill — accent stays reserved for the focus ring, never selection).
"use client";
import { Toolbar } from "@stridge/noctis";
import { Star } from "lucide-react";
export default function ToolbarBasic() {
return (
<Toolbar.Root aria-label="Document actions">
<Toolbar.Button>Share</Toolbar.Button>
<Toolbar.Button>Duplicate</Toolbar.Button>
{/* A toggle is just another flat item — it shows an on state, no nested container. */}
<Toolbar.Toggle startIcon={Star} defaultPressed aria-label="Favorite" />
<Toolbar.Separator />
<Toolbar.Button>Export</Toolbar.Button>
<Toolbar.Link href="#docs">Docs</Toolbar.Link>
</Toolbar.Root>
);
}
Groups
Weld a set of items into one segmented cluster with Toolbar.Group — the buttons abut and only the cluster's outer corners round, so it reads as a single control. Separators rule between clusters. Icon-only buttons take an aria-label to name them.
"use client";
import { Toolbar } from "@stridge/noctis";
import { AlignCenter, AlignLeft, AlignRight, Bold, Italic, Underline } from "lucide-react";
export default function ToolbarGroups() {
return (
<Toolbar.Root aria-label="Text formatting">
<Toolbar.Group>
<Toolbar.Button icon={Bold} aria-label="Bold" />
<Toolbar.Button icon={Italic} aria-label="Italic" />
<Toolbar.Button icon={Underline} aria-label="Underline" />
</Toolbar.Group>
<Toolbar.Separator />
<Toolbar.Group>
<Toolbar.Button icon={AlignLeft} aria-label="Align left" />
<Toolbar.Button icon={AlignCenter} aria-label="Align center" />
<Toolbar.Button icon={AlignRight} aria-label="Align right" />
</Toolbar.Group>
</Toolbar.Root>
);
}
Sizes
size sets the density. md (the default) is the comfortable bar; sm drops to control-xs heights and tighter padding for a dense editor header. The hosted toggles map their own size from the toolbar, so a whole bar resizes in one prop. (Icon-only items stay touch-safe on coarse pointers.)
"use client";
import { Toolbar } from "@stridge/noctis";
import { Bold, Italic, Underline } from "lucide-react";
export default function ToolbarSizes() {
return (
<div className="flex flex-col items-start gap-4">
{(["md", "sm"] as const).map((size) => (
<Toolbar.Root key={size} size={size} aria-label={`Formatting (${size})`}>
<Toolbar.Button icon={Bold} aria-label="Bold" />
<Toolbar.Button icon={Italic} aria-label="Italic" />
<Toolbar.Button icon={Underline} aria-label="Underline" />
</Toolbar.Root>
))}
</div>
);
}
Variants
variant chooses the frame. framed (default) is a bordered surface row; plain drops the border and background for a toolbar that already sits inside a Surface or Card; floating lifts a bubble / selection bar off the page with an elevated shadow. All stay sharp.
"use client";
import { Surface, Toolbar } from "@stridge/noctis";
import { Bold, Italic, Underline } from "lucide-react";
function Items() {
return (
<>
<Toolbar.Button icon={Bold} aria-label="Bold" />
<Toolbar.Button icon={Italic} aria-label="Italic" />
<Toolbar.Button icon={Underline} aria-label="Underline" />
</>
);
}
export default function ToolbarVariants() {
return (
<div className="flex flex-col items-start gap-5">
{/* framed (default): a bordered surface row. */}
<Toolbar.Root aria-label="Framed">
<Items />
</Toolbar.Root>
{/* plain: no frame — for a toolbar already sitting inside a Surface or Card. */}
<Surface elevation="elevated" bordered className="rounded-md p-2">
<Toolbar.Root variant="plain" aria-label="Plain">
<Items />
</Toolbar.Root>
</Surface>
{/* floating: lifted off the page with an elevated shadow — a bubble / selection bar. */}
<div className="py-2">
<Toolbar.Root variant="floating" aria-label="Floating">
<Items />
</Toolbar.Root>
</div>
</div>
);
}
Input & spacer
Toolbar.Input is a roving field item — a zoom %, a font size, an inline search — styled with the field roles and kept level with the buttons beside it. Toolbar.Spacer is a logical flex slot that pushes the items after it to the inline-end, so a left-tools / right-actions split leans correct in RTL with no hand-rolled flex-1 div. An overflow "More" menu composes through Toolbar.Button's render (render={<Menu.Trigger />}).
"use client";
import { Toolbar } from "@stridge/noctis";
import { Minus, Plus } from "lucide-react";
export default function ToolbarInput() {
return (
// A canvas toolbar: a zoom field flanked by step buttons, with a Spacer pushing Reset to the end.
// A bounded width lets the Spacer's flex actually distribute the gap.
<Toolbar.Root aria-label="Canvas zoom" className="w-full max-w-sm">
<Toolbar.Button icon={Minus} aria-label="Zoom out" />
<Toolbar.Input aria-label="Zoom level" defaultValue="100%" className="text-center" />
<Toolbar.Button icon={Plus} aria-label="Zoom in" />
<Toolbar.Spacer />
<Toolbar.Button>Reset</Toolbar.Button>
</Toolbar.Root>
);
}
Vertical
Set orientation="vertical" on Toolbar.Root to stack the items into a column — for a side rail of tools. The arrow keys follow the orientation (up/down), and the separator turns to a horizontal rule.
"use client";
import { Toolbar } from "@stridge/noctis";
export default function ToolbarVertical() {
return (
<Toolbar.Root aria-label="Canvas tools" orientation="vertical">
<Toolbar.Button>Select</Toolbar.Button>
<Toolbar.Button>Move</Toolbar.Button>
<Toolbar.Separator />
<Toolbar.Button>Zoom</Toolbar.Button>
<Toolbar.Button disabled>Crop</Toolbar.Button>
</Toolbar.Root>
);
}
Keyboard
The toolbar is one tab stop; focus roves within it with the arrow keys. Directional keys mirror under RTL — ArrowRight walks toward the visual end in LTR and toward the start in RTL.
| Key | Action |
|---|---|
Tab | Move focus into the toolbar (landing on the last-focused item) or out of it — the whole row is a single tab stop. |
ArrowRight / ArrowDown | Move focus to the next item. In a horizontal toolbar ArrowRight follows the writing direction (toward the start under RTL); a vertical toolbar uses ArrowDown. |
ArrowLeft / ArrowUp | Move focus to the previous item, mirrored under RTL. |
Home | Move focus to the first item. |
End | Move focus to the last item. |
Enter / Space | Activate the focused button, follow the focused link, or toggle the focused toggle. |
Focus loops at the ends by default; pass loopFocus={false} to Toolbar.Root to stop at the first and last item. A hosted menu or select item owns its own arrow keys, so the toolbar's roving doesn't fight it.
Accessibility
- Name the toolbar. Always pass
aria-label(oraria-labelledby) toToolbar.Root— the APG toolbar pattern requires the row be named, and the toolbar should never be the only way to reach an action. - Tooltips on icon-only items fire on focus and hover. A keyboard-roving user lands on an unlabeled glyph, so the name must reach them too: give every icon-only item an
aria-label(the visible tooltip supplements, never replaces it). - Don't nest a bare arrow-key widget. A radio group inside a horizontal toolbar fights the toolbar's own arrow navigation; host such controls behind a menu button instead (compose
MenuthroughToolbar.Button'srender).
Anatomy
Compose a toolbar from its parts. Toolbar.Root owns the orientation, density (size), frame (variant), and the roving-tabindex focus model.
Toolbar.Root— the row container. Props:size(sm|md, defaultmd),variant(framed|plain|floating, defaultframed),orientation(defaulthorizontal),loopFocus(defaulttrue),disabled, plus the Base UIToolbar.Rootprops. Give it anaria-label.Toolbar.Button— one button item. Optional leadingicon; can bedisabled(it stays in the roving order so it can still be reached and announced).Toolbar.Link— a roving item rendered as an anchor, sharing the button look. Passhref.Toolbar.Toggle— a pressable on/off item (a flat ghost toggle with a neutral pressed fill andaria-pressed); use it standalone for an independent toggle. OptionalstartIcon/endIcon;aria-labelwhen icon-only.Toolbar.ToggleGroup— coordinates a set of toggles with a shared value model — single-select (a view switcher / alignment) ormultiple(Bold/Italic/Underline). It renders as a plain, gap-separated row of toggles (no segmented track), spaced like the buttons. Itssizemaps from the toolbar density.Toolbar.Input— a roving field item (zoom %, font size), field-styled; mark itaria-invalidfor the error border.Toolbar.Group— welds several items into one segmented cluster; only the cluster's outer corners round.Toolbar.Spacer— a logicalflexslot pushing the following items to the inline-end (a left / right split); presentational, not a roving stop.Toolbar.Separator— a hairline divider between item clusters; it turns horizontal in a vertical toolbar.
The toolbar hosts the shipped controls rather than forking its own: Toolbar.Button renders a Button (ghost), Toolbar.Link is a real anchor styled with the same Button recipe, and the toggles render Toggle/ToggleGroup — so every item shares one visual language and the link never drifts in size from the buttons. The toolbar-owned slots are toolbar, toolbar-separator, toolbar-group, toolbar-input, and toolbar-spacer; the items carry the Button/Toggle slots and markers. Pair a slot with the state attributes (data-size, data-variant, data-orientation) for host-side styling. The row is fully keyboard-operable and RTL-aware.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every toolbar in that region retunes — e.g. .tight { --noctis-toolbar-item-radius: 0; } squares the hosted controls. The items' own sizing/padding comes from the Button/Toggle tokens. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI list just the props they pass through. Expand a row for the full type and description.