Give buttons, toggles, menus and fields one tab stop with arrow-key navigation.

Use a Toolbar to group the controls of an editor, a canvas or a table. Tab enters it once, and the arrow keys move between its buttons, toggles, menus and fields.

Console
import { Toggle, ToggleIcon } from "@ui-sdk/solid/toggle"
import { Toolbar, ToolbarButton, ToolbarSeparator } from "@ui-sdk/solid/toolbar"
import { For } from "solid-js"
import { useLog } from "./log"

const icon = (markup: string) => () => (
  <svg
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    stroke-width="2"
    stroke-linecap="round"
    stroke-linejoin="round"
    aria-hidden="true"
    innerHTML={markup}
  />
)
const Bold = icon(
  '<path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8" />'
)
const Italic = icon(
  '<line x1="19" x2="10" y1="4" y2="4" /><line x1="14" x2="5" y1="20" y2="20" /><line x1="15" x2="9" y1="4" y2="20" />'
)
const Underline = icon(
  '<path d="M6 4v6a6 6 0 0 0 12 0V4" /><line x1="4" x2="20" y1="20" y2="20" />'
)

const styles = [
  { label: "Bold", icon: Bold },
  { label: "Italic", icon: Italic },
  { label: "Underline", icon: Underline },
]

function Example() {
  const log = useLog()
  return (
    <Toolbar aria-label="Formatting">
      <For each={styles}>
        {(style) => (
          <Toggle
            aria-label={style.label}
            onPressedChange={(pressed) =>
              log(`${style.label} ${pressed ? "on" : "off"}`)
            }
          >
            <ToggleIcon>
              <style.icon />
            </ToggleIcon>
          </Toggle>
        )}
      </For>
      <ToolbarSeparator />
      <ToolbarButton
        aria-label="Insert link"
        onClick={() => log("Insert link")}
      >
        <svg
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
          aria-hidden="true"
        >
          <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
          <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
        </svg>
      </ToolbarButton>
    </Toolbar>
  )
}

export { Example }
Tab into the toolbar, then use Left and Right to move between Bold, Italic, Underline and Insert link. Press Space to toggle the focused style. The console logs each change.

Installation

npm install @ui-sdk/solid @ui-sdk/core

Composition

Use the following composition to build a Toolbar:

Toolbar
├── ToolbarButton
├── ToolbarSeparator
├── ToolbarGroup
│   ├── ToolbarButton
│   └── ToolbarButton
├── ToolbarLink
└── ToolbarInput

Put buttons, links and fields straight inside the toolbar. ToolbarGroup names a set of related items, and ToolbarSeparator draws a line between sets. Toggles and toggle groups join the toolbar on their own, and menu and select triggers join through ToolbarButton.

Usage

Import the parts and put them together:

import { Toolbar, ToolbarButton, ToolbarSeparator } from "@ui-sdk/solid/toolbar"

function Example() {
  return (
    <Toolbar aria-label="Formatting">
      <ToolbarButton>Undo</ToolbarButton>
      <ToolbarButton>Redo</ToolbarButton>
      <ToolbarSeparator />
      <ToolbarButton>Insert Link</ToolbarButton>
    </Toolbar>
  )
}

export { Example }

Name the toolbar with aria-label, and give every icon-only control its own.

Toggle Groups

A Toggle group inside a toolbar joins its navigation instead of adding another tab stop. Arrow keys move focus without pressing anything; Enter or Space presses the focused toggle.

Console
import { Toggle, ToggleGroup, ToggleIcon } from "@ui-sdk/solid/toggle"
import { Toolbar, ToolbarSeparator } from "@ui-sdk/solid/toolbar"
import { For } from "solid-js"
import { useLog } from "./log"

const icon = (markup: string) => () => (
  <svg
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    stroke-width="2"
    stroke-linecap="round"
    stroke-linejoin="round"
    aria-hidden="true"
    innerHTML={markup}
  />
)
const AlignLeft = icon(
  '<path d="M21 5H3" /><path d="M15 12H3" /><path d="M17 19H3" />'
)
const AlignCenter = icon(
  '<path d="M21 5H3" /><path d="M17 12H7" /><path d="M19 19H5" />'
)
const AlignRight = icon(
  '<path d="M21 5H3" /><path d="M21 12H9" /><path d="M21 19H7" />'
)
const List = icon(
  '<path d="M3 5h.01" /><path d="M3 12h.01" /><path d="M3 19h.01" /><path d="M8 5h13" /><path d="M8 12h13" /><path d="M8 19h13" />'
)
const ListOrdered = icon(
  '<path d="M11 5h10" /><path d="M11 12h10" /><path d="M11 19h10" /><path d="M4 4h1v5" /><path d="M4 9h2" /><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 0 0-2.6-1.02" />'
)

const alignments = [
  { value: "left", label: "Align left", icon: AlignLeft },
  { value: "center", label: "Align center", icon: AlignCenter },
  { value: "right", label: "Align right", icon: AlignRight },
]

const lists = [
  { value: "bulleted", label: "Bulleted list", icon: List },
  { value: "numbered", label: "Numbered list", icon: ListOrdered },
]

function Example() {
  const log = useLog()
  return (
    <Toolbar aria-label="Paragraph">
      <ToggleGroup
        defaultValue={["left"]}
        deselectable={false}
        aria-label="Alignment"
        onValueChange={([value]) => log(`Align ${value}`)}
      >
        <For each={alignments}>
          {(alignment) => (
            <Toggle value={alignment.value} aria-label={alignment.label}>
              <ToggleIcon>
                <alignment.icon />
              </ToggleIcon>
            </Toggle>
          )}
        </For>
      </ToggleGroup>
      <ToolbarSeparator />
      <ToggleGroup
        aria-label="List"
        onValueChange={([value]) =>
          log(lists.find((list) => list.value === value)?.label ?? "No list")
        }
      >
        <For each={lists}>
          {(list) => (
            <Toggle value={list.value} aria-label={list.label}>
              <ToggleIcon>
                <list.icon />
              </ToggleIcon>
            </Toggle>
          )}
        </For>
      </ToggleGroup>
    </Toolbar>
  )
}

export { Example }
Arrow from the alignment group to the list group, then press Space on Align center or Numbered list. The console logs each choice.

Groups

Use ToolbarGroup to name related controls. Set disabled on the group to disable them together; they stay focusable with aria-disabled.

Console
import {
  Toolbar,
  ToolbarButton,
  ToolbarGroup,
  ToolbarSeparator,
} from "@ui-sdk/solid/toolbar"
import { For } from "solid-js"
import { useLog } from "./log"

const icon = (markup: string) => () => (
  <svg
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    stroke-width="2"
    stroke-linecap="round"
    stroke-linejoin="round"
    aria-hidden="true"
    innerHTML={markup}
  />
)
const Scissors = icon(
  '<circle cx="6" cy="6" r="3" /><path d="M8.12 8.12 12 12" /><path d="M20 4 8.12 15.88" /><circle cx="6" cy="18" r="3" /><path d="M14.8 14.8 20 20" />'
)
const Copy = icon(
  '<rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />'
)
const Clipboard = icon(
  '<rect width="8" height="4" x="8" y="2" rx="1" ry="1" /><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />'
)

const clipboard = [
  { label: "Cut", icon: Scissors },
  { label: "Copy", icon: Copy },
  { label: "Paste", icon: Clipboard },
]

function Example() {
  const log = useLog()
  return (
    <Toolbar aria-label="Edit">
      <ToolbarGroup aria-label="Clipboard">
        <For each={clipboard}>
          {(item) => (
            <ToolbarButton
              aria-label={item.label}
              onClick={() => log(item.label)}
            >
              <item.icon />
            </ToolbarButton>
          )}
        </For>
      </ToolbarGroup>
      <ToolbarSeparator />
      <ToolbarGroup disabled aria-label="History">
        <ToolbarButton aria-label="Undo">
          <svg
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
            aria-hidden="true"
          >
            <path d="M3 7v6h6" />
            <path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13" />
          </svg>
        </ToolbarButton>
        <ToolbarButton aria-label="Redo">
          <svg
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
            aria-hidden="true"
          >
            <path d="M21 7v6h-6" />
            <path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7" />
          </svg>
        </ToolbarButton>
      </ToolbarGroup>
    </Toolbar>
  )
}

export { Example }
Move through Cut, Copy and Paste into History with the arrow keys. Undo and Redo stay focusable but do nothing.

Render a Menu trigger through ToolbarButton. In a horizontal toolbar, Down opens the menu while Left and Right keep moving between toolbar items.

<Menu>
  <ToolbarButton render={(props) => <MenuTrigger {...props} />}>
    Share
  </ToolbarButton>
  <MenuContent>…</MenuContent>
</Menu>
Console
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@ui-sdk/solid/menu"
import { Toolbar, ToolbarButton } from "@ui-sdk/solid/toolbar"
import { useLog } from "./log"

function Example() {
  const log = useLog()
  return (
    <Toolbar aria-label="Document">
      <ToolbarButton onClick={() => log("Saved")}>Save</ToolbarButton>
      <Menu onAction={(value) => log(`Shared by ${value}`)}>
        <ToolbarButton render={(props) => <MenuTrigger {...props} />}>
          Share
          <svg
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
            aria-hidden="true"
          >
            <path d="m6 9 6 6 6-6" />
          </svg>
        </ToolbarButton>
        <MenuContent>
          <MenuItem value="email">Email</MenuItem>
          <MenuItem value="link">Copy Link</MenuItem>
        </MenuContent>
      </Menu>
      <ToolbarButton onClick={() => log("Printed")}>Print</ToolbarButton>
    </Toolbar>
  )
}

export { Example }
Focus Share and press Down, then choose Email or Copy Link. Escape closes the menu and returns focus to Share.

Selects

A Select trigger joins the same way. It keeps its select behavior while it takes part in the toolbar's focus order.

Console
import {
  Select,
  SelectContent,
  SelectItem,
  SelectItemIndicator,
  SelectTrigger,
  SelectValue,
} from "@ui-sdk/solid/select"
import { Toolbar, ToolbarButton } from "@ui-sdk/solid/toolbar"
import { createSignal, For } from "solid-js"
import { useLog } from "./log"

const zooms = ["50%", "75%", "100%", "150%", "200%"]

function Example() {
  const log = useLog()
  const [zoom, setZoom] = createSignal("100%")
  const index = () => zooms.indexOf(zoom())
  const change = (value: string) => {
    setZoom(value)
    log(`Zoom ${value}`)
  }
  return (
    <Toolbar aria-label="View">
      <ToolbarButton
        aria-label="Zoom out"
        disabled={index() === 0}
        onClick={() => change(zooms[index() - 1])}
      >
        <svg
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
          aria-hidden="true"
        >
          <circle cx="11" cy="11" r="8" />
          <line x1="21" x2="16.65" y1="21" y2="16.65" />
          <line x1="8" x2="14" y1="11" y2="11" />
        </svg>
      </ToolbarButton>
      <Select value={zoom()} onValueChange={(value) => value && change(value)}>
        <ToolbarButton
          render={(props) => <SelectTrigger {...props} />}
          aria-label="Zoom"
        >
          <SelectValue />
          <svg
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
            aria-hidden="true"
          >
            <path d="m7 15 5 5 5-5" />
            <path d="m7 9 5-5 5 5" />
          </svg>
        </ToolbarButton>
        <SelectContent>
          <For each={zooms}>
            {(zoom) => (
              <SelectItem value={zoom}>
                {zoom}
                <SelectItemIndicator>
                  <svg
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    stroke-width="2"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    aria-hidden="true"
                  >
                    <path d="M20 6 9 17l-5-5" />
                  </svg>
                </SelectItemIndicator>
              </SelectItem>
            )}
          </For>
        </SelectContent>
      </Select>
      <ToolbarButton
        aria-label="Zoom in"
        disabled={index() === zooms.length - 1}
        onClick={() => change(zooms[index() + 1])}
      >
        <svg
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
          aria-hidden="true"
        >
          <circle cx="11" cy="11" r="8" />
          <line x1="21" x2="16.65" y1="21" y2="16.65" />
          <line x1="11" x2="11" y1="8" y2="14" />
          <line x1="8" x2="14" y1="11" y2="11" />
        </svg>
      </ToolbarButton>
    </Toolbar>
  )
}

export { Example }
Open Zoom and choose a value, or step it with Zoom out and Zoom in. Each button turns off at the end of the range.

Fields

Use ToolbarInput for a text field, or render another field through it, like a Number Input control. A field keeps Home, End and the arrow keys while they move its caret. At the matching end, with no text selected, an arrow moves to the next item.

Entering a field with an arrow puts its caret at the far end in that direction: press the same arrow again to leave, or the other arrow to edit. A number input keeps Up and Down for stepping its value.

import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@ui-sdk/solid/number-input"
import { Toolbar, ToolbarInput, ToolbarSeparator } from "@ui-sdk/solid/toolbar"

function Example() {
  return (
    <Toolbar aria-label="Text">
      <NumberInput defaultValue={14} min={8} max={72}>
        <NumberInputGroup>
          <NumberInputDecrement aria-label="Decrease font size">
            <svg
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              stroke-width="2"
              stroke-linecap="round"
              stroke-linejoin="round"
              aria-hidden="true"
            >
              <path d="M5 12h14" />
            </svg>
          </NumberInputDecrement>
          <ToolbarInput
            render={(props) => (
              <NumberInputControl {...props} aria-label="Font size" />
            )}
          />
          <NumberInputIncrement aria-label="Increase font size">
            <svg
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              stroke-width="2"
              stroke-linecap="round"
              stroke-linejoin="round"
              aria-hidden="true"
            >
              <path d="M5 12h14" />
              <path d="M12 5v14" />
            </svg>
          </NumberInputIncrement>
        </NumberInputGroup>
      </NumberInput>
      <ToolbarSeparator />
      <ToolbarInput aria-label="Find" placeholder="Find…" />
    </Toolbar>
  )
}

export { Example }
Move into Font size and press Up or Down to change it. In Find, type a word, then move the caret to an edge before that arrow leaves the field.

ToolbarLink joins the same focus order and keeps its link behavior. The demo handles a plain click itself and leaves modified clicks, like opening in a new tab, to the browser.

import {
  Toolbar,
  ToolbarButton,
  ToolbarLink,
  ToolbarSeparator,
} from "@ui-sdk/solid/toolbar"
import { useLog } from "./log"

function Example() {
  const log = useLog()
  const follow = (event: MouseEvent & { currentTarget: HTMLAnchorElement }) => {
    if (
      event.button !== 0 ||
      event.metaKey ||
      event.ctrlKey ||
      event.shiftKey ||
      event.altKey
    ) {
      return
    }
    event.preventDefault()
    log(`Opened ${event.currentTarget.textContent}`)
  }
  return (
    <Toolbar aria-label="Pull request">
      <ToolbarButton aria-label="Approve" onClick={() => log("Approved")}>
        <svg
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
          aria-hidden="true"
        >
          <path d="M20 6 9 17l-5-5" />
        </svg>
      </ToolbarButton>
      <ToolbarButton aria-label="Comment" onClick={() => log("Comment")}>
        <svg
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
          aria-hidden="true"
        >
          <path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z" />
        </svg>
      </ToolbarButton>
      <ToolbarSeparator />
      <ToolbarLink href="#files" onClick={follow}>
        Files Changed
      </ToolbarLink>
      <ToolbarLink href="#checks" onClick={follow}>
        Checks
      </ToolbarLink>
    </Toolbar>
  )
}

export { Example }
Arrow to Files Changed and press Enter. The console logs the page it opened.

Vertical

Set orientation="vertical" to move with Up and Down, like a canvas tool palette. Separators turn to run across the toolbar.

Console
import { Toggle, ToggleGroup, ToggleIcon } from "@ui-sdk/solid/toggle"
import { Toolbar, ToolbarButton, ToolbarSeparator } from "@ui-sdk/solid/toolbar"
import { For } from "solid-js"
import { useLog } from "./log"

const icon = (markup: string) => () => (
  <svg
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    stroke-width="2"
    stroke-linecap="round"
    stroke-linejoin="round"
    aria-hidden="true"
    innerHTML={markup}
  />
)
const MousePointer2 = icon(
  '<path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z" />'
)
const Pencil = icon(
  '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /><path d="m15 5 4 4" />'
)
const Square = icon('<rect width="18" height="18" x="3" y="3" rx="2" />')
const Type = icon(
  '<path d="M12 4v16" /><path d="M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2" /><path d="M9 20h6" />'
)

const tools = [
  { value: "select", label: "Select", icon: MousePointer2 },
  { value: "draw", label: "Draw", icon: Pencil },
  { value: "shape", label: "Shape", icon: Square },
  { value: "text", label: "Text", icon: Type },
]

function Example() {
  const log = useLog()
  return (
    <Toolbar orientation="vertical" aria-label="Canvas">
      <ToggleGroup
        defaultValue={["select"]}
        deselectable={false}
        orientation="vertical"
        aria-label="Tool"
        onValueChange={([value]) =>
          log(`${tools.find((tool) => tool.value === value)?.label} tool`)
        }
      >
        <For each={tools}>
          {(tool) => (
            <Toggle value={tool.value} aria-label={tool.label}>
              <ToggleIcon>
                <tool.icon />
              </ToggleIcon>
            </Toggle>
          )}
        </For>
      </ToggleGroup>
      <ToolbarSeparator />
      <ToolbarButton
        aria-label="Delete"
        onClick={() => log("Deleted selection")}
      >
        <svg
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
          aria-hidden="true"
        >
          <path d="M10 11v6" />
          <path d="M14 11v6" />
          <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
          <path d="M3 6h18" />
          <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
        </svg>
      </ToolbarButton>
    </Toolbar>
  )
}

export { Example }
Use Up and Down to move between the tools and Delete. Space picks the focused tool.

Disabled

A disabled item stays reachable with aria-disabled, so people can find it, but it ignores presses, including keys meant for a component rendered through it. Set focusableWhenDisabled to false to skip it instead.

Console
import { Toolbar, ToolbarButton } from "@ui-sdk/solid/toolbar"
import { useLog } from "./log"

function Example() {
  const log = useLog()
  return (
    <Toolbar aria-label="Message">
      <ToolbarButton onClick={() => log("Reply")}>Reply</ToolbarButton>
      <ToolbarButton disabled onClick={() => log("Archive")}>
        Archive
      </ToolbarButton>
      <ToolbarButton
        disabled
        focusableWhenDisabled={false}
        onClick={() => log("Delete")}
      >
        Delete
      </ToolbarButton>
    </Toolbar>
  )
}

export { Example }
Arrow from Reply to Archive, which is disabled but focusable. Delete is skipped. Only Reply logs a press.

Set disabled on the toolbar to disable every item. The toolbar keeps its tab stop, and its fields can't be edited.

<Toolbar disabled aria-label="Formatting">
  <ToolbarButton>Bold</ToolbarButton>
  <ToolbarInput aria-label="Find" />
</Toolbar>

Loop

Arrow keys wrap from the last item to the first by default. Set loop to false to stop at the ends.

Console
import { Toolbar, ToolbarButton } from "@ui-sdk/solid/toolbar"
import { For } from "solid-js"
import { useLog } from "./log"

function Example() {
  const log = useLog()
  return (
    <Toolbar loop={false} aria-label="Pages">
      <For each={["First", "Previous", "Next", "Last"]}>
        {(label) => (
          <ToolbarButton onClick={() => log(`${label} page`)}>
            {label}
          </ToolbarButton>
        )}
      </For>
    </Toolbar>
  )
}

export { Example }
Focus Last and press Right. Focus stays there; Home still moves to First.

Controller

Create a controller with createToolbarController and pass it as controller. Its focus() moves focus to the item focused last, like the Alt+F10 shortcut editors give their toolbars. Create one controller per toolbar.

Console
import { useShortcut } from "@ui-sdk/solid/shortcuts"
import {
  createToolbarController,
  Toolbar,
  ToolbarButton,
} from "@ui-sdk/solid/toolbar"
import { For } from "solid-js"
import { useLog } from "./log"

const icon = (markup: string) => () => (
  <svg
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    stroke-width="2"
    stroke-linecap="round"
    stroke-linejoin="round"
    aria-hidden="true"
    innerHTML={markup}
  />
)
const Bold = icon(
  '<path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8" />'
)
const Italic = icon(
  '<line x1="19" x2="10" y1="4" y2="4" /><line x1="14" x2="5" y1="20" y2="20" /><line x1="15" x2="9" y1="4" y2="20" />'
)
const Link = icon(
  '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />'
)

const actions = [
  { label: "Bold", icon: Bold },
  { label: "Italic", icon: Italic },
  { label: "Insert link", icon: Link },
]

function Example() {
  const log = useLog()
  const toolbar = createToolbarController()
  useShortcut("alt+f10", () => toolbar.focus(), { allowInInput: true })
  return (
    <div data-slot="stack">
      <div data-slot="controls">
        <button type="button" onClick={() => toolbar.focus()}>
          Focus Toolbar
        </button>
      </div>
      <Toolbar controller={toolbar} aria-label="Notes formatting">
        <For each={actions}>
          {(action) => (
            <ToolbarButton
              aria-label={action.label}
              onClick={() => log(action.label)}
            >
              <action.icon />
            </ToolbarButton>
          )}
        </For>
      </Toolbar>
      <textarea aria-label="Notes" placeholder="Type, then press Alt+F10" />
    </div>
  )
}

export { Example }
Type in Notes, then press Alt+F10 or choose Focus Toolbar. Press a toolbar button and the console logs it.

Custom Rendering

Render a component through ToolbarButton or ToolbarInput to combine their behavior, as the menu, select and number input examples do. A render function must spread the toolbar props onto the inner component. Parts and Composition explains the rendering contract.

Styling

Use data-orientation on the root for a row or a column, and on a separator for the way its line runs, across the toolbar. Use data-disabled to style unavailable items and groups. Toggles inside keep their own data-pressed.

Each part has a data-slot, such as toolbar-button, and its state attributes are listed under Data Attributes.

Data Attributes

Toolbar

[data-slot="toolbar"]

AttributeWhat it means
data-orientationhorizontalverticalThe toolbar's orientation. On a separator, which way the line runs: across the toolbar.
data-disabledPresent when disabled, by the item itself, its group or the toolbar.

ToolbarButton

[data-slot="toolbar-button"]

AttributeWhat it means
data-disabledPresent when disabled, by the item itself, its group or the toolbar.
data-orientationhorizontalverticalThe toolbar's orientation. On a separator, which way the line runs: across the toolbar.

[data-slot="toolbar-link"]

AttributeWhat it means
data-disabledPresent when disabled, by the item itself, its group or the toolbar.
data-orientationhorizontalverticalThe toolbar's orientation. On a separator, which way the line runs: across the toolbar.

ToolbarInput

[data-slot="toolbar-input"]

AttributeWhat it means
data-disabledPresent when disabled, by the item itself, its group or the toolbar.
data-orientationhorizontalverticalThe toolbar's orientation. On a separator, which way the line runs: across the toolbar.

ToolbarGroup

[data-slot="toolbar-group"]

AttributeWhat it means
data-disabledPresent when disabled, by the item itself, its group or the toolbar.
data-orientationhorizontalverticalThe toolbar's orientation. On a separator, which way the line runs: across the toolbar.

ToolbarSeparator

[data-slot="toolbar-separator"]

AttributeWhat it means
data-orientationhorizontalverticalThe toolbar's orientation. On a separator, which way the line runs: across the toolbar.

API Reference

Props are the same as below, as Solid props.

Toolbar

orientation?ToolbarOrientation
Which arrow keys move between items: left and right, or up and down.
loop?boolean
Whether arrow keys wrap from the last item to the first.
disabled?boolean
Stops every item from being used. Items stay focusable with aria-disabled, so the toolbar keeps its tab stop and stays discoverable.
controller?{ focus: () => void; }
Focuses the toolbar from anywhere. Create one with createToolbarController().
debug?boolean
Logs every state change with its reason to the console, in development builds.

ToolbarButton

disabled?boolean
Stops the item from being used. It stays focusable with aria-disabled, so arrow keys still reach it and a tooltip can say why.
focusableWhenDisabled?boolean
Whether a disabled item stays reachable with arrow keys. Turn it off to skip it, natively disabled.

ToolbarInput

disabled?boolean
Stops the item from being used. It stays focusable with aria-disabled, so arrow keys still reach it and a tooltip can say why.
focusableWhenDisabled?boolean
Whether a disabled item stays reachable with arrow keys. Turn it off to skip it, natively disabled.

ToolbarGroup

disabled?boolean
Disables every item in the group. They stay focusable with aria-disabled.

ToolbarLink and ToolbarSeparator take no props of their own.

State

What render functions and state-based class and style receive on ToolbarButton and ToolbarInput, and children on ToolbarButton:

disabledboolean
Whether the item is disabled, by itself, its group or the toolbar.
orientationToolbarOrientation
The toolbar's orientation option: "vertical", or "horizontal" for anything else.

On ToolbarLink:

disabledboolean
Whether the link is disabled, by its group or the toolbar.
orientationToolbarOrientation
The toolbar's orientation option: "vertical", or "horizontal" for anything else.

On ToolbarSeparator:

orientationToolbarOrientation
Which way the line runs: across the toolbar, so vertical in a horizontal toolbar.

Controller

focus()() => void
Moves focus to the item that holds the toolbar's tab stop: the one focused last, or the first.

Accessibility

The toolbar follows the WAI-ARIA toolbar pattern. The root has the toolbar role and aria-orientation, and separators have the separator role.

Keyboard

The toolbar has one tab stop, a roving focus. Tab enters on the first enabled item, then returns to the item focused last. The next Tab leaves the toolbar.

KeyAction
ArrowRight / ArrowLeftMove focus in a horizontal toolbar
ArrowDown / ArrowUpMove focus in a vertical toolbar
Home / EndFocus the first or last item, except in a field
Enter / SpaceActivate a button or toggle, or open its popup

Controls keep the keys they use: Down opens a menu, and Up and Down step a number input. Moving focus never presses a toggle. In right-to-left text, Left and Right swap.

Tested Behavior

Every line below is an automated test.

  • The toolbar is named and has a horizontal orientation.
  • Tab lands on the first item, and Tab again leaves the toolbar.
  • Shift+Tab from after the toolbar lands on its tab stop.
  • ArrowRight moves through every item, into toggle groups and out of fields.
  • ArrowRight on the last item wraps to the first, and ArrowLeft back.
  • Arrow keys never press a toggle, and Space and Enter do.
  • Tab comes back to the item focused last.
  • A toggle group in the toolbar is no tab stop of its own.
  • A disabled button stays reachable, says it's disabled and does nothing.
  • A button runs its action when pressed.
  • A menu trigger rendered through a button opens with ArrowDown, and arrows still move past it.
  • Choosing from a menu in the toolbar returns focus to its trigger.
  • A text field keeps Home and End for its caret.
  • Up and Down step a number field in the toolbar.
  • Separators run across the toolbar.
  • A group of buttons is named.
  • A link in the toolbar follows its href with Enter.
  • With loop off, arrows stop at the ends.
  • A disabled toolbar keeps one tab stop, and every item says it's disabled.
Vertical
  • ArrowDown and ArrowUp move between items, and Left and Right don't.
  • Separators run across a vertical toolbar.
Disabled groups
  • Toggles and toggle groups in a disabled group are disabled.
  • A link in a disabled group is disabled, and follows its href once enabled.
  • Enabling a group enables the toggles and buttons in it.

Runs in React, Vue and web components.