FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Toggle
  • ToggleGroup
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Toolbar
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup
  • Select
  • Combobox
  • Toast
  • Slider
  • NumberField
  • Field & Form
  • Progress
  • Meter

Resources

  • Roadmap & changelog
  • GitHub ↗

Toolbar

A role="toolbar" container (WAI-ARIA APG Toolbar pattern) with one Tab stop and arrow keys between its controls. Its items are its focusable descendants, found in the DOM: a ToggleGroup, a Menu.Trigger, a Toggle, a plain <button>, a link or an input all join the sequence without a wrapper. Toolbar.Button is a command button with an action.



Styles: bold. Alignment: left. Last command: Nothing yet.

Show code
ToolbarEditor.tsx
"use client";
import { startTransition, useState } from "react";
import { Menu } from "@funstack/ui/menu";
import { ToggleGroup } from "@funstack/ui/toggle-group";
import { Toolbar } from "@funstack/ui/toolbar";
import { DemoErrorBoundary } from "./DemoErrorBoundary.js";
import { fakeSave } from "./fake-api.js";

// An editor toolbar: one Tab stop over two ToggleGroups, separators, a menu button, three
// Toolbar.Buttons (Copy is disabled but stays focusable) and a text input placed last. Left /
// Right walk every control; ArrowDown on Insert opens the menu.
export function ToolbarEditor() {
  const [styles, setStyles] = useState<string[]>(["bold"]);
  const [align, setAlign] = useState<string | null>("left");
  const [log, setLog] = useState("Nothing yet.");
  const note = (line: string) => startTransition(() => setLog(line));

  return (
    <DemoErrorBoundary>
      <div className="demo-stack">
        <Toolbar.Root aria-label="Formatting" className="demo-toolbar">
          <ToggleGroup.Root
            aria-label="Text style"
            className="demo-toggle-group"
            value={styles}
            valueChangeAction={async (_next, { added, removed }) => {
              await fakeSave("styles");
              // Functional update: overlapping saves that settle out of order lose nothing.
              startTransition(() =>
                setStyles((prev) => [
                  ...prev.filter((v) => !removed.includes(v)),
                  ...added.filter((v) => !prev.includes(v)),
                ]),
              );
            }}
          >
            <ToggleGroup.Item value="bold" className="demo-toggle-item" aria-label="Bold">
              <b aria-hidden>B</b>
            </ToggleGroup.Item>
            <ToggleGroup.Item value="italic" className="demo-toggle-item" aria-label="Italic">
              <i aria-hidden>I</i>
            </ToggleGroup.Item>
          </ToggleGroup.Root>
          <Toolbar.Separator className="demo-toolbar-separator" />
          <ToggleGroup.Root
            exclusive
            aria-label="Text alignment"
            className="demo-toggle-group"
            value={align}
            valueChangeAction={async (next) => {
              await fakeSave(`alignment=${next}`);
              startTransition(() => setAlign(next));
            }}
          >
            <ToggleGroup.Item value="left" className="demo-toggle-item">
              Left
            </ToggleGroup.Item>
            <ToggleGroup.Item value="center" className="demo-toggle-item">
              Center
            </ToggleGroup.Item>
          </ToggleGroup.Root>
          <Toolbar.Separator className="demo-toolbar-separator" />
          <Menu.Root>
            <Menu.Trigger className="demo-toolbar-button">Insert</Menu.Trigger>
            <Menu.Popup className="demo-menu">
              <Menu.Item action={() => note("Inserted an image.")}>Image</Menu.Item>
              <Menu.Item action={() => note("Inserted a table.")}>Table</Menu.Item>
            </Menu.Popup>
          </Menu.Root>
          <Toolbar.Button
            className="demo-toolbar-button"
            action={async () => {
              await fakeSave("cut");
              note("Cut.");
            }}
          >
            {({ pending }) => (
              <>
                {pending && <span className="demo-spinner" aria-hidden />}
                Cut
              </>
            )}
          </Toolbar.Button>
          <Toolbar.Button className="demo-toolbar-button" disabled>
            Copy
          </Toolbar.Button>
          <Toolbar.Button className="demo-toolbar-button" action={() => note("Pasted.")}>
            Paste
          </Toolbar.Button>
          {/* A text-entry control owns its arrow keys: at most one, and last (APG). */}
          <input
            type="text"
            aria-label="Font size"
            inputMode="numeric"
            defaultValue="16"
            size={3}
            className="demo-toolbar-input"
          />
        </Toolbar.Root>
        <p>
          Styles: <output>{styles.join(", ") || "none"}</output>. Alignment:{" "}
          <output>{align ?? "none"}</output>. Last command: <output>{log}</output>
        </p>
      </div>
    </DemoErrorBoundary>
  );
}
toolbar.css
/* Toolbar: the toolbar is a flex row (a column when vertical); the separator is an <hr>. */
.demo-toolbar {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.25rem;
  padding: 0.25rem;
  border: 1px solid var(--color-border);
  border-radius: 10px;
}

.demo-toolbar[data-orientation="vertical"] {
  flex-direction: column;
  align-items: stretch;
}

.demo-toolbar-button {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  padding: 0.3em 0.8em;
  border: 0;
  border-radius: 6px;
  background: transparent;
  color: var(--color-text);
  font: inherit;
  cursor: pointer;
}

.demo-toolbar-button:hover {
  background: color-mix(in srgb, var(--color-text) 8%, transparent);
}

.demo-toolbar-button[aria-disabled="true"] {
  opacity: 0.5;
  cursor: not-allowed;
}

.demo-toolbar-button[data-pending] {
  cursor: progress;
}

.demo-toolbar-button:focus-visible,
.demo-toolbar-input:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

.demo-toolbar-input {
  padding: 0.25em 0.4em;
  border: 1px solid var(--color-border);
  border-radius: 6px;
  background: var(--color-bg);
  color: var(--color-text);
  font: inherit;
}

/* <hr> is a block; in a horizontal toolbar it is a vertical rule. */
.demo-toolbar-separator {
  margin: 0;
  border: 0;
  background: var(--color-border);
}

.demo-toolbar-separator[data-orientation="vertical"] {
  inline-size: 1px;
  align-self: stretch;
}

.demo-toolbar-separator[data-orientation="horizontal"] {
  block-size: 1px;
}

Tab enters on the last focused control (Bold the first time). Left / Right walk every control in document order: both groups, the menu button, the three commands, the input. ArrowDown on Insert opens the menu. Copy is disabled but stays focusable, so it is announced. In the input, Left / Right move the caret and Tab leaves the toolbar (it is last).

Label the toolbar

A toolbar must have a name: pass aria-label or aria-labelledby. Name each ToggleGroup inside it too.

One text-entry control, last

An input, textarea, select, contenteditable or a control with role textbox, combobox, spinbutton or slider keeps its arrow keys, Home and End. From it, Tab and Shift+Tab move to the next and previous toolbar item while one exists (as Chromium's native focusgroup does). Following the APG, put at most one such control in a toolbar, and put it last.

Choices inside a toolbar: ToggleGroup, not radios

For a one-of-several choice inside a toolbar (text alignment), use an exclusive ToggleGroup. Native radios (and RadioGroup) currently behave differently on the JS path and the native focusgroup path inside a toolbar.

Vertical toolbar


Tool: select

Show code
ToolbarVertical.tsx
"use client";
import { useState } from "react";
import { Menu } from "@funstack/ui/menu";
import { Toolbar } from "@funstack/ui/toolbar";

// A vertical toolbar. Up / Down walk the tools; on Shapes, ArrowRight opens the menu with its
// first item focused (ArrowLeft under direction: rtl) and ArrowDown moves on to the next tool.
// The popup sits on the inline-end side; a right-to-left page would use side="left".
export function ToolbarVertical() {
  const [tool, setTool] = useState("select");
  return (
    <div className="demo-row">
      <Toolbar.Root orientation="vertical" aria-label="Tools" className="demo-toolbar">
        <Toolbar.Button className="demo-toolbar-button" action={() => setTool("select")}>
          Select
        </Toolbar.Button>
        <Toolbar.Button className="demo-toolbar-button" action={() => setTool("draw")}>
          Draw
        </Toolbar.Button>
        <Toolbar.Separator className="demo-toolbar-separator" />
        <Menu.Root>
          <Menu.Trigger className="demo-toolbar-button">Shapes</Menu.Trigger>
          <Menu.Popup className="demo-menu" side="right">
            <Menu.Item action={() => setTool("rectangle")}>Rectangle</Menu.Item>
            <Menu.Item action={() => setTool("ellipse")}>Ellipse</Menu.Item>
          </Menu.Popup>
        </Menu.Root>
      </Toolbar.Root>
      <p>
        Tool: <output>{tool}</output>
      </p>
    </div>
  );
}

Up / Down walk the tools. A Menu.Trigger in a vertical toolbar opens on the inline-end arrow: ArrowRight, or ArrowLeft under direction: rtl. Put its popup on that side: side="right", or side="left" in a right-to-left page. ArrowDown moves on to the next tool.

Anatomy

import { Toolbar } from "@funstack/ui/toolbar";

<Toolbar.Root aria-label="Formatting">
  <Toolbar.Button action={cut}>Cut</Toolbar.Button>
  <Toolbar.Separator />
  <ToggleGroup.Root aria-label="Text style">…</ToggleGroup.Root>
  <Menu.Root>…</Menu.Root>
</Toolbar.Root>
PartElementRole
Toolbar.Root<div role="toolbar" aria-orientation>Finds its items in the DOM and moves focus between them.
Toolbar.Button<button type="button">A command: action in its own transition, data-pending, focusable when disabled.
Toolbar.Separator<hr aria-orientation>A divider, perpendicular to the toolbar. Not focusable.

There are no Toolbar.Link, Toolbar.Group or Toolbar.ToggleGroup parts: put the element itself in the toolbar. Do not pass tabIndex to toolbar items; the toolbar writes tabindex itself. An element with its own tabIndex={-1} stays out of the sequence.

Props

Toolbar.Root

PropTypeDefaultDescription
orientation"horizontal" | "vertical""horizontal"Axis of the arrow keys; also aria-orientation.
loopbooleantrueArrows wrap from the last item to the first.
experimental_focusgroupbooleanfalseRenders focusgroup="toolbar inline|block wrap|nowrap"; where the browser implements it (Chromium 150+), the browser owns the Tab stop, the arrows, Home and End. See Experimental features.
classNamestring | ((state: ToolbarRootState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: ToolbarRootState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: ToolbarRootState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((props, state: ToolbarRootState) => ReactNode)–Replace the default <div> (element form) or take over rendering (function form).

ToolbarRootState is { orientation }. There is no disabled: disable items individually, or wrap the toolbar in a <fieldset disabled>. role, aria-orientation, onKeyDown, onFocus, onBlur and onPointerDown are the library's and are not props.

Toolbar.Button

PropTypeDefaultDescription
actionAction<[details: ToolbarButtonActionDetails]>–Runs inside the button's own transition on click, Enter or Space. Sync or async (a Server Function too); rejections reach the nearest Error Boundary above the button.
disabledbooleanfalseRenders aria-disabled="true": the button stays focusable and in the arrow-key sequence (APG), and never dispatches.
transitionViewTransitionClass–Forwarded to the button's <ViewTransition default>: animates the commit that ends the action.
classNamestring | ((state: ToolbarButtonState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: ToolbarButtonState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: ToolbarButtonState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((props, state: ToolbarButtonState) => ReactNode)–Replace the default <button> (element form) or take over rendering (function form).

ToolbarButtonState is { pending, disabled }. With render={<a href />} the link navigates before hydration and runs action after it; it gets no type.

Toolbar.Separator

PropTypeDefaultDescription
classNamestring | ((state: ToolbarSeparatorState) => string | undefined)–A class name, or a function of the separator's state.
styleCSSProperties | ((state: ToolbarSeparatorState) => CSSProperties | undefined)–Inline style, or a function of the separator's state.
renderReactElement | ((props, state: ToolbarSeparatorState) => ReactNode)–Replace the default <hr> (element form) or take over rendering (function form).

ToolbarSeparatorState is { orientation }: the separator's own orientation, "vertical" in a horizontal toolbar.

Data attributes

AttributeOnMeaning
data-orientationRootThe toolbar's orientation.
data-orientationSeparatorThe separator's own (perpendicular) orientation.
data-pendingButtonIts action is in flight.
data-disabledButtonThe disabled prop (with aria-disabled="true").

Keyboard

KeyHorizontalVerticalEffect
ArrowRight / ArrowLeftNext / previous item (swapped under direction: rtl)IgnoredMoves focus; wraps if loop.
ArrowDown / ArrowUpIgnoredNext / previous itemSame.
Home / EndFirst / last itemSameSame.
Any of the above on a text-entry itemNot handled: the item keeps its caret.
Tab / Shift+Tab on a text-entry itemNext / previous toolbar item while one exists; otherwise leaves the toolbar.
Tab / Shift+Tab elsewhereLeaves the toolbar; re-entry lands on the last focused item.
Enter / Space on Toolbar.ButtonRuns action, unless disabled.
A key the item handled itselfIgnored by the toolbar (a Menu.Trigger's ArrowDown opens its menu).

Natively disabled controls are skipped; aria-disabled ones are reached. A popup rendered inside the toolbar (a Menu.Popup) is not part of the sequence: its items are the menu's.

A ToggleGroup inside a Toolbar

The group joins the toolbar's single sequence: Left / Right (or Up / Down when vertical) flow through its Items like any other items, and the other axis does nothing on them. The group's own orientation, loop and experimental_focusgroup are ignored there (with a development warning); it still renders role="group", its label and its pending state.

A Menu.Trigger inside a Toolbar

ContextOpens with the first itemOpens with the last itemLeft to the Toolbar
Horizontal ToolbarArrowDownArrowUpArrowLeft / ArrowRight
Vertical ToolbarArrowRight (ArrowLeft under direction: rtl)–ArrowDown / ArrowUp

Before hydration

The toolbar writes tabindex from an effect, so the server HTML has none: before hydration every control is its own Tab stop, reachable and working with its native behaviour. After hydration there is one Tab stop.

Styling

.toolbar { display: flex; gap: .25rem; }
.toolbar[data-orientation="vertical"] { flex-direction: column; }
/* <hr> is a block; in a horizontal toolbar it is a vertical rule. */
.toolbar-separator { margin: 0; border: 0; background: var(--line); }
.toolbar-separator[data-orientation="vertical"] { inline-size: 1px; align-self: stretch; }
.toolbar-separator[data-orientation="horizontal"] { block-size: 1px; }
.toolbar-button[aria-disabled="true"] { opacity: .5; }
.toolbar-button[data-pending] { cursor: progress; }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

Toolbar.Button transition animates the commit that ends its action with a view transition; keep the reduced-motion block above in your stylesheet. The Toolbar adds no view transition types of its own.