Navigation

Toolbar

A row of controls that takes one tab stop, with arrow keys between them. For editors and dense views.

Built on
role="toolbar"aria-pressedaria-disabledMutationObserver
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
2.8 kB
Dependencies
React
Try its options

Loading its options.

Code
"use client";

import { useState } from "react";
import { Toolbar, ToolbarButton, ToolbarGroup, ToolbarSeparator } from "@/components/equip/toolbar";

const icon = {
  width: 16,
  height: 16,
  viewBox: "0 0 16 16",
  fill: "none",
  stroke: "currentColor",
  strokeWidth: 1.5,
  strokeLinecap: "round",
  strokeLinejoin: "round",
  "aria-hidden": true,
} as const;

const alignments = [
  { value: "left", label: "Align left", lines: "M3 4h10M3 8h6M3 12h8" },
  { value: "center", label: "Align centre", lines: "M3 4h10M5 8h6M4 12h8" },
  { value: "right", label: "Align right", lines: "M3 4h10M7 8h6M5 12h8" },
];

export default function Demo() {
  const [align, setAlign] = useState("left");
  return (
    <Toolbar label="Text formatting">
      <ToolbarButton label="Bold" defaultPressed>
        <svg {...icon}>
          <path d="M5 3h4a2.5 2.5 0 0 1 0 5H5zM5 8h4.5a2.5 2.5 0 0 1 0 5H5z" />
        </svg>
      </ToolbarButton>
      <ToolbarButton label="Italic" defaultPressed={false}>
        <svg {...icon}>
          <path d="M7 3h5M4 13h5M10 3 6 13" />
        </svg>
      </ToolbarButton>
      <ToolbarButton label="Link">
        <svg {...icon}>
          <path d="M7 9a2.5 2.5 0 0 0 3.5 0l2-2a2.5 2.5 0 0 0-3.5-3.5l-.5.5M9 7a2.5 2.5 0 0 0-3.5 0l-2 2A2.5 2.5 0 0 0 7 12.5l.5-.5" />
        </svg>
      </ToolbarButton>
      <ToolbarSeparator />
      <ToolbarGroup label="Alignment">
        {alignments.map((item) => (
          <ToolbarButton
            key={item.value}
            label={item.label}
            pressed={align === item.value}
            onPressedChange={() => setAlign(item.value)}
          >
            <svg {...icon}>
              <path d={item.lines} />
            </svg>
          </ToolbarButton>
        ))}
      </ToolbarGroup>
    </Toolbar>
  );
}
Your versionchange its options in Look, This part

Examples

Text labels and a disabled item

Redo is disabled but still reachable with the arrow keys, so people can find it and learn it exists.

Code
import { Toolbar, ToolbarButton, ToolbarSeparator } from "@/components/equip/toolbar";

export default function Example() {
  return (
    <Toolbar label="Pull request">
      <ToolbarButton>Comment</ToolbarButton>
      <ToolbarButton>Suggest change</ToolbarButton>
      <ToolbarSeparator />
      <ToolbarButton>Undo</ToolbarButton>
      <ToolbarButton disabled>Redo</ToolbarButton>
    </Toolbar>
  );
}

Links and a select

Any focusable child joins the roving tab stop. The select keeps Up and Down for itself.

Code
import { Toolbar, ToolbarButton, ToolbarSeparator } from "@/components/equip/toolbar";

export default function Example() {
  return (
    <Toolbar label="Table view">
      <select
        aria-label="Group rows by"
        defaultValue="status"
        style={{
          height: "var(--eq-h-s)",
          padding: "0 var(--eq-space-2)",
          border: "1px solid var(--eq-line-strong)",
          borderRadius: "var(--eq-radius-s)",
          background: "var(--eq-raised)",
          color: "var(--eq-ink)",
          font: "inherit",
        }}
      >
        <option value="status">Group by status</option>
        <option value="owner">Group by owner</option>
        <option value="none">No grouping</option>
      </select>
      <ToolbarButton defaultPressed>Show archived</ToolbarButton>
      <ToolbarSeparator />
      <a
        href="#export"
        style={{ padding: "0 var(--eq-space-2)", color: "var(--eq-ink)", textUnderlineOffset: 3 }}
      >
        Export CSV
      </a>
    </Toolbar>
  );
}

Vertical

Up and Down move between tools, and the separator turns to run across.

Code
import { Toolbar, ToolbarButton, ToolbarSeparator } from "@/components/equip/toolbar";

const icon = {
  width: 16,
  height: 16,
  viewBox: "0 0 16 16",
  fill: "none",
  stroke: "currentColor",
  strokeWidth: 1.5,
  strokeLinecap: "round",
  strokeLinejoin: "round",
  "aria-hidden": true,
} as const;

export default function Example() {
  return (
    <Toolbar label="Canvas tools" orientation="vertical">
      <ToolbarButton label="Show grid" defaultPressed>
        <svg {...icon}>
          <path d="M3 6h10M3 10h10M6 3v10M10 3v10" />
        </svg>
      </ToolbarButton>
      <ToolbarButton label="Snap to objects" defaultPressed={false}>
        <svg {...icon}>
          <rect x="3" y="3" width="5" height="5" rx="1" />
          <rect x="8" y="8" width="5" height="5" rx="1" />
        </svg>
      </ToolbarButton>
      <ToolbarButton label="Lock layer" defaultPressed={false}>
        <svg {...icon}>
          <rect x="4" y="7" width="8" height="6" rx="1" />
          <path d="M6 7V5.5a2 2 0 0 1 4 0V7" />
        </svg>
      </ToolbarButton>
      <ToolbarSeparator />
      <ToolbarButton label="Zoom to fit">
        <svg {...icon}>
          <path d="M3 6V3h3M10 3h3v3M13 10v3h-3M6 13H3v-3" />
        </svg>
      </ToolbarButton>
    </Toolbar>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneToolbar: names it, such as "Text formatting".
orientation"horizontal" | "vertical""horizontal"Toolbar: the direction of the row and of the arrow keys.
pressedbooleannoneToolbarButton: makes it a toggle and sets aria-pressed, when you control it. Leave out for a plain action.
defaultPressedbooleannoneToolbarButton: the starting state of an uncontrolled toggle.
onPressedChange(pressed: boolean) => voidnoneToolbarButton: called with the new state.
labelstringnoneToolbarButton: the accessible name. Required when the children are only an icon. ToolbarGroup: names the group.
disabledbooleannoneToolbarButton: sets aria-disabled. The button stays focusable and does nothing when pressed.
...restComponentProps<"div"> | ComponentProps<"button">noneEvery native attribute: div for Toolbar, ToolbarGroup and ToolbarSeparator, button for ToolbarButton.

Anatomy

root.eq-toolbarA div with role="toolbar".
button.eq-toolbar-buttonGhost style; solid when aria-pressed is true.
group.eq-toolbar-groupA div with role="group" and its own name.
separator.eq-toolbar-separatorrole="separator", oriented across the row.

Keyboard

TabEnters the toolbar on the item that last had focus, or the first; Tab again leaves it.
ArrowLeftArrowRightMoves to the previous or next item, wrapping at the ends.
ArrowUpArrowDownThe same when orientation is vertical.
HomeEndMoves to the first or last item.
EnterSpaceActivates the button, or flips a toggle.

Notes

  • The whole toolbar is one tab stop, with a roving tabindex across every focusable child, including links and selects.
  • Disabled buttons use aria-disabled rather than disabled, so they stay in the arrow key order and can be discovered.
  • A text field inside keeps its arrow keys, Home and End; a select keeps Up and Down.
  • Children added or removed later join the arrow key order without a remount.
  • Use a toolbar for three or more controls; with fewer, plain buttons are easier to find.
  • For a set where only one can be on, such as alignment, control pressed so that turning one on turns the others off.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/toolbar.tsx201 lines
"use client";

import {
  createContext,
  useContext,
  useEffect,
  useRef,
  useState,
  type ComponentProps,
  type FocusEvent,
  type KeyboardEvent,
  type MouseEvent,
} from "react";
import "./toolbar.css";

type Orientation = "horizontal" | "vertical";

const OrientationContext = createContext<Orientation>("horizontal");

const ITEMS = 'button, a[href], select, input, textarea, [tabindex]:not([tabindex="-1"]), [data-eq-toolbar-item]';

/** Controls that need some of these keys for themselves: text fields all of them, a select Up and Down. */
function ownsKey(element: Element, key: string) {
  if (element instanceof HTMLSelectElement) return key === "ArrowUp" || key === "ArrowDown";
  if (element instanceof HTMLTextAreaElement) return true;
  if (!(element instanceof HTMLInputElement)) return false;
  return !["button", "checkbox", "radio", "reset", "submit", "image", "color", "file"].includes(element.type);
}

export type ToolbarProps = ComponentProps<"div"> & {
  /** Names the toolbar, e.g. "Text formatting". */
  label: string;
  orientation?: Orientation;
};

/**
 * One tab stop for the whole row. Arrow keys move between the items with a roving
 * tabindex, and the toolbar remembers the last item that had focus.
 */
export function Toolbar({
  label,
  orientation = "horizontal",
  className,
  onFocus,
  onKeyDown,
  children,
  ...rest
}: ToolbarProps) {
  const root = useRef<HTMLDivElement>(null);
  const active = useRef<Element | null>(null);

  function items() {
    const list = root.current ? [...root.current.querySelectorAll<HTMLElement>(ITEMS)] : [];
    return list.filter((item) => !item.hidden && !(item instanceof HTMLButtonElement && item.disabled));
  }

  function rove(current: Element | null) {
    const list = items();
    const target = current && list.includes(current as HTMLElement) ? current : list[0];
    active.current = target ?? null;
    for (const item of list) {
      item.setAttribute("data-eq-toolbar-item", "");
      item.tabIndex = item === target ? 0 : -1;
    }
  }

  // Re-sync after every render and whenever the children change underneath.
  useEffect(() => {
    rove(active.current);
    const node = root.current;
    if (!node) return;
    const observer = new MutationObserver(() => rove(active.current));
    observer.observe(node, { childList: true, subtree: true });
    return () => observer.disconnect();
  });

  function handleFocus(event: FocusEvent<HTMLDivElement>) {
    onFocus?.(event);
    const item = items().find((each) => each === event.target);
    if (item && item !== active.current) rove(item);
  }

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    onKeyDown?.(event);
    if (event.defaultPrevented) return;
    const list = items();
    const index = list.findIndex((item) => item === event.target);
    if (index === -1) return;
    const prev = orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
    const next = orientation === "vertical" ? "ArrowDown" : "ArrowRight";
    let to = -1;
    if (event.key === prev) to = (index - 1 + list.length) % list.length;
    else if (event.key === next) to = (index + 1) % list.length;
    else if (event.key === "Home") to = 0;
    else if (event.key === "End") to = list.length - 1;
    else return;
    if (ownsKey(list[index] as Element, event.key)) return;
    event.preventDefault();
    const target = list[to];
    if (!target) return;
    rove(target);
    target.focus();
  }

  return (
    <OrientationContext.Provider value={orientation}>
      <div
        ref={root}
        role="toolbar"
        aria-label={label}
        aria-orientation={orientation}
        {...rest}
        className={["eq-toolbar", className].filter(Boolean).join(" ")}
        data-orientation={orientation}
        onFocus={handleFocus}
        onKeyDown={handleKeyDown}
      >
        {children}
      </div>
    </OrientationContext.Provider>
  );
}

export type ToolbarButtonProps = ComponentProps<"button"> & {
  /** Makes it a toggle: true and false set aria-pressed. Leave out for a plain action. */
  pressed?: boolean;
  /** Uncontrolled starting state for a toggle. */
  defaultPressed?: boolean;
  onPressedChange?: (pressed: boolean) => void;
  /** Accessible name. Required when the children are only an icon. */
  label?: string;
};

export function ToolbarButton({
  pressed,
  defaultPressed,
  onPressedChange,
  label,
  disabled,
  className,
  onClick,
  children,
  ...rest
}: ToolbarButtonProps) {
  const [inner, setInner] = useState(defaultPressed);
  const value = pressed ?? inner;

  function handleClick(event: MouseEvent<HTMLButtonElement>) {
    // Disabled items stay focusable so they can be found, but they do nothing.
    if (disabled) {
      event.preventDefault();
      return;
    }
    onClick?.(event);
    if (event.defaultPrevented || value === undefined) return;
    if (pressed === undefined) setInner(!value);
    onPressedChange?.(!value);
  }

  return (
    <button
      type="button"
      aria-label={label}
      {...rest}
      className={["eq-toolbar-button", className].filter(Boolean).join(" ")}
      aria-pressed={value}
      aria-disabled={disabled || undefined}
      onClick={handleClick}
    >
      {children}
    </button>
  );
}

export function ToolbarSeparator({ className, ...rest }: ComponentProps<"div">) {
  const orientation = useContext(OrientationContext);
  return (
    <div
      role="separator"
      aria-orientation={orientation === "horizontal" ? "vertical" : "horizontal"}
      {...rest}
      className={["eq-toolbar-separator", className].filter(Boolean).join(" ")}
    />
  );
}

export type ToolbarGroupProps = ComponentProps<"div"> & {
  /** Names the group, e.g. "Alignment". */
  label: string;
};

export function ToolbarGroup({ label, className, ...rest }: ToolbarGroupProps) {
  return (
    <div
      role="group"
      aria-label={label}
      {...rest}
      className={["eq-toolbar-group", className].filter(Boolean).join(" ")}
    />
  );
}
components/equip/toolbar.css105 lines
@layer equip {
  .eq-toolbar {
    box-sizing: border-box;
    display: inline-flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 2px;
    max-width: 100%;
    padding: 3px;
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1 var(--eq-font);
  }

  /* A toolbar wider than its column wraps onto more rows rather than spilling. */
  .eq-toolbar[data-orientation="vertical"] {
    flex-direction: column;
    flex-wrap: nowrap;
  }

  .eq-toolbar-group {
    display: flex;
    flex-direction: inherit;
    flex-wrap: inherit;
    align-items: center;
    gap: 2px;
  }

  .eq-toolbar-separator {
    flex: none;
    align-self: stretch;
    width: 1px;
    margin: 3px var(--eq-space-1);
    background: var(--eq-line);
  }

  .eq-toolbar[data-orientation="vertical"] .eq-toolbar-separator {
    width: auto;
    height: 1px;
    margin: var(--eq-space-1) 3px;
  }

  .eq-toolbar-button {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--eq-space-1);
    min-width: var(--eq-h-s);
    height: var(--eq-h-s);
    padding: 0 var(--eq-space-2);
    border: 0;
    border-radius: var(--eq-radius-s);
    background: transparent;
    color: var(--eq-ink);
    font: var(--eq-weight-strong) var(--eq-text-s) / 1 var(--eq-font);
    white-space: nowrap;
    cursor: pointer;
    user-select: none;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-toolbar-button:has(> svg:only-child) {
    padding: 0;
  }

  .eq-toolbar-button > svg {
    flex: none;
    width: 16px;
    height: 16px;
  }

  @media (hover: hover) {
    .eq-toolbar-button:hover:not([aria-disabled="true"]) {
      background: var(--eq-tint);
    }

    .eq-toolbar-button[aria-pressed="true"]:hover:not([aria-disabled="true"]) {
      background: color-mix(in srgb, var(--eq-solid) 88%, var(--eq-solid-ink));
    }
  }

  .eq-toolbar-button[aria-pressed="true"] {
    background: var(--eq-solid);
    color: var(--eq-solid-ink);
  }

  .eq-toolbar-button:active:not([aria-disabled="true"]) {
    scale: var(--eq-press);
  }

  .eq-toolbar-button:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

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

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index