Actions

Toggle group

A toolbar row of on and off buttons, such as bold, italic and underline, with one or many pressed at once.

Built on
<button>aria-pressedrole=toolbar
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.1 kB
Dependencies
React
Try its options

Loading its options.

Code
import { ToggleGroup, ToggleGroupItem } from "@/components/equip/toggle-group";

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d={d} />
    </svg>
  );
}

export default function Demo() {
  return (
    <ToggleGroup aria-label="Text formatting" defaultValue={["bold"]}>
      <ToggleGroupItem value="bold" aria-label="Bold">
        <Icon d="M4.5 2.5h4.25a2.75 2.75 0 0 1 0 5.5H4.5zM4.5 8h5a2.75 2.75 0 0 1 0 5.5h-5z" />
      </ToggleGroupItem>
      <ToggleGroupItem value="italic" aria-label="Italic">
        <Icon d="M6.5 2.5h6M3.5 13.5h6M9.5 2.5l-3 11" />
      </ToggleGroupItem>
      <ToggleGroupItem value="underline" aria-label="Underline">
        <Icon d="M4.5 2.5V7a3.5 3.5 0 0 0 7 0V2.5M3.5 13.5h9" />
      </ToggleGroupItem>
      <ToggleGroupItem value="strike" aria-label="Strikethrough">
        <Icon d="M2.5 8h11M11 4.5c-.5-1.2-1.6-2-3-2-1.9 0-3 1-3 2.3 0 1 .6 1.7 1.6 2.2M5 11.5c.5 1.2 1.6 2 3 2 1.9 0 3.2-1 3.2-2.4 0-.5-.1-.8-.3-1.1" />
      </ToggleGroupItem>
    </ToggleGroup>
  );
}
Your versionchange its options in Look, This part

Examples

Single

With type single, pressing another item releases the first, and pressing the pressed item releases it.

Code
import { ToggleGroup, ToggleGroupItem } from "@/components/equip/toggle-group";

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
      <path d={d} />
    </svg>
  );
}

export default function Example() {
  return (
    <ToggleGroup aria-label="Paragraph alignment" type="single" defaultValue={["left"]}>
      <ToggleGroupItem value="left" aria-label="Align left">
        <Icon d="M2.5 3.5h11M2.5 6.5h7M2.5 9.5h11M2.5 12.5h7" />
      </ToggleGroupItem>
      <ToggleGroupItem value="center" aria-label="Align centre">
        <Icon d="M2.5 3.5h11M4.5 6.5h7M2.5 9.5h11M4.5 12.5h7" />
      </ToggleGroupItem>
      <ToggleGroupItem value="right" aria-label="Align right">
        <Icon d="M2.5 3.5h11M6.5 6.5h7M2.5 9.5h11M6.5 12.5h7" />
      </ToggleGroupItem>
    </ToggleGroup>
  );
}

Controlled, with text labels

Showing errors, warnings

Code
"use client";

import { useState } from "react";
import { ToggleGroup, ToggleGroupItem } from "@/components/equip/toggle-group";

export default function Example() {
  const [shown, setShown] = useState(["errors", "warnings"]);
  return (
    <div style={{ display: "grid", gap: 8, justifyItems: "start" }}>
      <ToggleGroup aria-label="Log levels" overflow="wrap" value={shown} onValueChange={setShown}>
        <ToggleGroupItem value="errors">Errors</ToggleGroupItem>
        <ToggleGroupItem value="warnings">Warnings</ToggleGroupItem>
        <ToggleGroupItem value="info">Info</ToggleGroupItem>
        <ToggleGroupItem value="debug">Debug</ToggleGroupItem>
      </ToggleGroup>
      <p style={{ margin: 0, fontSize: 13 }}>
        {shown.length === 0 ? "No log levels shown" : `Showing ${shown.join(", ")}`}
      </p>
    </div>
  );
}

Disabled item

Arrow keys skip a disabled item, so focus never lands where nothing can happen.

Code
import { ToggleGroup, ToggleGroupItem } from "@/components/equip/toggle-group";

export default function Example() {
  return (
    <ToggleGroup aria-label="Diff view options" overflow="wrap" defaultValue={["whitespace"]}>
      <ToggleGroupItem value="whitespace">Hide whitespace</ToggleGroupItem>
      <ToggleGroupItem value="wrap" disabled>
        Wrap lines
      </ToggleGroupItem>
      <ToggleGroupItem value="blame">Blame</ToggleGroupItem>
    </ToggleGroup>
  );
}

Long rows

overflow scroll keeps one line that scrolls sideways; overflow wrap lets the row break onto more lines.

Code
import { ToggleGroup, ToggleGroupItem } from "@/components/equip/toggle-group";

const regions = ["eu-west-1", "eu-central-1", "us-east-1", "us-west-2", "ap-south-1", "ap-northeast-1", "sa-east-1"];

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 12, width: "100%", maxWidth: 280 }}>
      <ToggleGroup aria-label="Regions, scrolling" type="single" overflow="scroll" defaultValue={["eu-west-1"]}>
        {regions.map((region) => (
          <ToggleGroupItem key={region} value={region}>
            {region}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
      <ToggleGroup aria-label="Regions, wrapping" overflow="wrap" defaultValue={["eu-west-1", "us-east-1"]}>
        {regions.map((region) => (
          <ToggleGroupItem key={region} value={region}>
            {region}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
    </div>
  );
}

Sizes

Each size is as tall as the Button of the same size, so a toggle group can share a row with buttons.

Code
import { Button } from "@/components/equip/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/equip/toggle-group";

const sizes = ["s", "m", "l"] as const;

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 12, justifyItems: "start" }}>
      {sizes.map((size) => (
        <div key={size} style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8, maxWidth: "100%" }}>
          <ToggleGroup aria-label={`Chart range, size ${size}`} type="single" size={size} overflow="wrap" defaultValue={["30d"]}>
            <ToggleGroupItem value="7d">7 days</ToggleGroupItem>
            <ToggleGroupItem value="30d">30 days</ToggleGroupItem>
            <ToggleGroupItem value="90d">90 days</ToggleGroupItem>
          </ToggleGroup>
          <Button variant="outline" size={size}>
            Export
          </Button>
        </div>
      ))}
    </div>
  );
}

Props

PropTypeDefaultWhat it does
type"single" | "multiple""multiple"single keeps at most one item pressed; multiple lets each item toggle on its own.
valuestring[]noneControlled list of pressed item values.
defaultValuestring[][]Uncontrolled starting list of pressed item values.
onValueChange(value: string[]) => voidnoneCalled with the new list after an item is toggled.
disabledbooleanfalseDisables every item.
overflow"scroll" | "wrap"noneWhat a row too long for its space does. scroll keeps one line that scrolls sideways, with the focus ring drawn inside each item so it is not clipped; wrap moves items onto more lines. Unset, the row keeps its width.
size"s" | "m" | "l""m"Height of the group: 28, 34 or 42px, matching a Button of the same size. Items keep at least a 24px target at s.
...restComponentProps<"div">noneEvery native div attribute on the toolbar. Give it an aria-label, such as Text formatting.
ToggleGroupItem valuestringnoneIdentifies the item in the group's value.
ToggleGroupItem ...restComponentProps<"button">noneEvery native button attribute. Icon-only items need an aria-label.

Anatomy

root.eq-toggle-groupThe div with role toolbar. Carries data-size.
item.eq-toggle-group-itemA native button with aria-pressed.

Keyboard

TabMoves focus into the toolbar, to the last focused item, else the first pressed item, else the first item.
ArrowRightMoves focus to the next item, wrapping to the first.
ArrowLeftMoves focus to the previous item, wrapping to the last.
HomeEndMoves focus to the first or the last item.
EnterSpaceToggles the focused item.

Notes

  • The toolbar is one tab stop; arrow keys move between items, which skips disabled ones.
  • Pressed state is aria-pressed on each button, so it reads as on or off rather than as a selection.
  • For a required one-of-many choice that submits with a form, use a segmented control instead.
  • Until the client script runs, every item is in the tab order; after hydration only one is.
  • With overflow scroll the scrollbar is hidden: swipe or use a trackpad to scroll, and arrow keys bring the focused item into view.

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/toggle-group.tsx166 lines
"use client";

import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import type { ComponentProps, FocusEvent, KeyboardEvent, MouseEvent, Ref } from "react";
import "./toggle-group.css";

type GroupContext = {
  isPressed: (value: string) => boolean;
  toggle: (value: string) => void;
  tabStop: string | null;
  setTabStop: (value: string) => void;
  disabled: boolean;
};

const ToggleGroupContext = createContext<GroupContext | null>(null);

export type ToggleGroupProps = Omit<ComponentProps<"div">, "defaultValue"> & {
  /** "single" keeps at most one item pressed; "multiple" lets each item toggle on its own. */
  type?: "single" | "multiple";
  /** Controlled list of pressed item values. */
  value?: string[];
  /** Uncontrolled starting list of pressed item values. */
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
  /** Disables every item. */
  disabled?: boolean;
  /** What a row too long for its space does: scroll sideways, or wrap onto more lines. Unset, it overflows. */
  overflow?: "scroll" | "wrap";
  /** Height of the group: 28, 34 or 42px, to sit beside a Button of the same size. */
  size?: "s" | "m" | "l";
};

const ITEM = ".eq-toggle-group-item";

function items(root: HTMLElement | null) {
  return root ? Array.from(root.querySelectorAll<HTMLButtonElement>(ITEM)).filter((b) => !b.disabled) : [];
}

function setRefs<T>(node: T, ...refs: (Ref<T> | undefined)[]) {
  for (const ref of refs) {
    if (typeof ref === "function") ref(node);
    else if (ref) ref.current = node;
  }
}

export function ToggleGroup({
  type = "multiple",
  value,
  defaultValue = [],
  onValueChange,
  disabled = false,
  overflow,
  size = "m",
  className,
  onKeyDown,
  ref,
  children,
  ...rest
}: ToggleGroupProps) {
  const rootRef = useRef<HTMLDivElement | null>(null);
  const [inner, setInner] = useState(defaultValue);
  const [tabStop, setTabStop] = useState<string | null>(null);
  const pressed = value ?? inner;
  const mergedRef = useCallback((node: HTMLDivElement | null) => setRefs(node, rootRef, ref), [ref]);

  // Keep exactly one item in the tab order: the last one focused if it is still there,
  // else the first pressed item, else the first enabled item.
  useEffect(() => {
    const enabled = items(rootRef.current);
    if (enabled.some((b) => b.dataset.value === tabStop)) return;
    const next = enabled.find((b) => b.getAttribute("aria-pressed") === "true") ?? enabled[0];
    setTabStop(next?.dataset.value ?? null);
  });

  function toggle(item: string) {
    const on = pressed.includes(item);
    const next =
      type === "single" ? (on ? [] : [item]) : on ? pressed.filter((v) => v !== item) : [...pressed, item];
    if (value === undefined) setInner(next);
    onValueChange?.(next);
  }

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    onKeyDown?.(event);
    if (event.defaultPrevented) return;
    const enabled = items(rootRef.current);
    const index = enabled.findIndex((b) => b === document.activeElement);
    if (index === -1) return;
    let target: HTMLButtonElement | undefined;
    if (event.key === "ArrowRight") target = enabled[(index + 1) % enabled.length];
    else if (event.key === "ArrowLeft") target = enabled[(index - 1 + enabled.length) % enabled.length];
    else if (event.key === "Home") target = enabled[0];
    else if (event.key === "End") target = enabled[enabled.length - 1];
    if (!target) return;
    event.preventDefault();
    target.focus();
  }

  return (
    <ToggleGroupContext.Provider
      value={{ isPressed: (v) => pressed.includes(v), toggle, tabStop, setTabStop, disabled }}
    >
      <div
        role="toolbar"
        aria-orientation="horizontal"
        {...rest}
        ref={mergedRef}
        className={["eq-toggle-group", className].filter(Boolean).join(" ")}
        aria-disabled={disabled || undefined}
        data-type={type}
        data-overflow={overflow}
        data-size={size}
        onKeyDown={handleKeyDown}
      >
        {children}
      </div>
    </ToggleGroupContext.Provider>
  );
}

export type ToggleGroupItemProps = Omit<ComponentProps<"button">, "value"> & {
  /** Identifies the item in the group's value. */
  value: string;
};

export function ToggleGroupItem({
  value,
  className,
  disabled,
  onClick,
  onFocus,
  children,
  ...rest
}: ToggleGroupItemProps) {
  const group = useContext(ToggleGroupContext);
  if (!group) throw new Error("ToggleGroupItem must be used inside a ToggleGroup.");
  const isDisabled = disabled || group.disabled;
  // Before the group has picked a tab stop (on the server), every item stays reachable.
  const tabIndex = group.tabStop === null || group.tabStop === value ? 0 : -1;

  function handleClick(event: MouseEvent<HTMLButtonElement>) {
    onClick?.(event);
    if (!event.defaultPrevented) group?.toggle(value);
  }

  function handleFocus(event: FocusEvent<HTMLButtonElement>) {
    onFocus?.(event);
    group?.setTabStop(value);
  }

  return (
    <button
      type="button"
      {...rest}
      className={["eq-toggle-group-item", className].filter(Boolean).join(" ")}
      data-value={value}
      aria-pressed={group.isPressed(value)}
      disabled={isDisabled}
      tabIndex={tabIndex}
      onClick={handleClick}
      onFocus={handleFocus}
    >
      {children}
    </button>
  );
}
components/equip/toggle-group.css146 lines
@layer equip {
  /* 1px border + 2px inset + a small-height item = the medium control height. */
  .eq-toggle-group {
    --_h: var(--eq-h-m);

    box-sizing: border-box;
    display: inline-flex;
    align-items: center;
    gap: 2px;
    height: var(--_h);
    padding: 2px;
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
  }

  .eq-toggle-group-item {
    display: inline-flex;
    flex: none;
    align-items: center;
    justify-content: center;
    gap: var(--eq-space-1);
    min-width: var(--eq-h-s);
    height: var(--eq-h-s);
    margin: 0;
    padding: 0 var(--eq-space-2);
    border: 0;
    border-radius: var(--eq-radius-s);
    background: transparent;
    color: var(--eq-ink-2);
    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);
  }

  /* An icon-only item is square. */
  .eq-toggle-group-item:has(> svg:only-child) {
    padding: 0;
  }

  .eq-toggle-group-item > svg {
    flex: none;
    width: 16px;
    height: 16px;
  }

  /* Small: the height of a small button. A 1px inset keeps each item at the 24px minimum target. */
  .eq-toggle-group[data-size="s"] {
    --_h: var(--eq-h-s);
    padding: 1px;
    border-radius: var(--eq-radius-s);
  }

  .eq-toggle-group[data-size="s"] .eq-toggle-group-item {
    min-width: calc(var(--eq-h-s) - 4px);
    height: calc(var(--eq-h-s) - 4px);
    padding-inline: var(--eq-space-1);
    border-radius: calc(var(--eq-radius-s) - 1px);
  }

  /* Large: the height of a large button, with medium-height items. */
  .eq-toggle-group[data-size="l"] {
    --_h: var(--eq-h-l);
    padding: 3px;
  }

  .eq-toggle-group[data-size="l"] .eq-toggle-group-item {
    min-width: calc(var(--eq-h-l) - 8px);
    height: calc(var(--eq-h-l) - 8px);
    padding-inline: var(--eq-space-3);
    font-size: var(--eq-text-m);
  }

  .eq-toggle-group[data-size="l"] .eq-toggle-group-item > svg {
    width: 18px;
    height: 18px;
  }

  /* Icon-only items stay square at every size. */
  .eq-toggle-group[data-size] .eq-toggle-group-item:has(> svg:only-child) {
    padding: 0;
  }

  @media (hover: hover) {
    .eq-toggle-group-item:hover:not(:disabled, [aria-pressed="true"]) {
      background: var(--eq-tint);
      color: var(--eq-ink);
    }
  }

  .eq-toggle-group-item:active:not(:disabled) {
    scale: var(--eq-press);
  }

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

  .eq-toggle-group-item:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  /* A long row scrolls sideways inside its own box. Arrow keys still reach every item, and the
     browser scrolls the focused one into view. */
  .eq-toggle-group[data-overflow="scroll"] {
    min-width: 0;
    max-width: 100%;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scrollbar-width: none;
  }

  .eq-toggle-group[data-overflow="scroll"]::-webkit-scrollbar {
    display: none;
  }

  /* A ring outside the item would be clipped by the scrolling row, so there it is drawn inside. */
  .eq-toggle-group[data-overflow="scroll"] .eq-toggle-group-item:focus-visible {
    outline-offset: -2px;
  }

  /* A long row wraps onto more lines; the group grows to hold them. */
  .eq-toggle-group[data-overflow="wrap"] {
    flex-wrap: wrap;
    height: auto;
    min-height: var(--_h);
    max-width: 100%;
  }

  .eq-toggle-group:not([aria-disabled="true"]) .eq-toggle-group-item:disabled,
  .eq-toggle-group[aria-disabled="true"] {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-toggle-group[aria-disabled="true"] .eq-toggle-group-item {
    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