Skip to content

Toggle group

A row of toggles where one or several can be pressed, for formatting and filters.

Status
Beta
Category
Selection
Adoption
Not used yet
import { ToggleGroup } from "@oration/canon/components/toggle-group";
packages/canon/src/components/toggle-group.tsx

Your payment of $18,240.00 for INV-20418 was sent on Friday, October 2.

import { ToggleGroup, ToggleGroupItem } from "@oration/canon/components/toggle-group";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { cn } from "@oration/canon/lib/utils";import {  AlignCenterIcon,  AlignLeftIcon,  AlignRightIcon,  BoldIcon,  ItalicIcon,  UnderlineIcon,} from "lucide-react";import * as React from "react";export function Hero() {    const [marks, setMarks] = React.useState<string[]>(["bold"]);    const [align, setAlign] = React.useState("left");    const formatting = [        { value: "bold", label: "Bold", icon: BoldIcon },        { value: "italic", label: "Italic", icon: ItalicIcon },        { value: "underline", label: "Underline", icon: UnderlineIcon },    ];    const alignments = [        { value: "left", label: "Align left", icon: AlignLeftIcon },        { value: "center", label: "Align center", icon: AlignCenterIcon },        { value: "right", label: "Align right", icon: AlignRightIcon },    ];    return (        <div className="w-full max-w-lg overflow-hidden rounded-xl bg-card text-left shadow-border">            <div className="flex items-center gap-2 border-b border-border px-2 py-1.5">                <ToggleGroup                    multiple                    size="sm"                    spacing={1}                    aria-label="Text formatting"                    value={marks}                    onValueChange={setMarks}                >                    {formatting.map((item) => (                        <Tooltip key={item.value}>                            <TooltipTrigger                                render={                                    <ToggleGroupItem                                        value={item.value}                                        aria-label={item.label}                                    />                                }                            >                                <item.icon aria-hidden="true" />                            </TooltipTrigger>                            <TooltipContent>{item.label}</TooltipContent>                        </Tooltip>                    ))}                </ToggleGroup>                <span aria-hidden="true" className="h-4 w-px bg-border" />                <ToggleGroup                    size="sm"                    spacing={1}                    aria-label="Alignment"                    value={[align]}                    onValueChange={(next) => {                        if (next[0]) setAlign(next[0]);                    }}                >                    {alignments.map((item) => (                        <Tooltip key={item.value}>                            <TooltipTrigger                                render={                                    <ToggleGroupItem                                        value={item.value}                                        aria-label={item.label}                                    />                                }                            >                                <item.icon aria-hidden="true" />                            </TooltipTrigger>                            <TooltipContent>{item.label}</TooltipContent>                        </Tooltip>                    ))}                </ToggleGroup>            </div>            <p                className={cn(                    "p-4 text-sm text-pretty",                    marks.includes("bold") && "font-semibold",                    marks.includes("italic") && "italic",                    marks.includes("underline") &&                        "underline underline-offset-4",                    align === "center" && "text-center",                    align === "right" && "text-right",                )}            >                Your payment of $18,240.00 for INV-20418 was sent on Friday,                October 2.            </p>        </div>    );}

Usage#

Toggle group is a row of toggle buttons built on Base UI Toggle group, where one or several can be pressed at once. It suits formatting and on-and-off filters that apply as you press: bold and italic in a remittance template, the channels shown in a report. With single selection it acts like a radio group, except that pressing the pressed item clears the value, so guard against the empty array when a value is required. For view modes that should read as one control, use a segmented control instead.

When to use

  • For formatting controls in an editor toolbar: bold, italic, underline, alignment.
  • For filters that combine, such as which channels a report shows.
  • For a single choice with icons and labels that may wrap, such as a chart's visualization in a settings sheet.
  • For a joined set of icon toggles, such as column alignment, with spacing={0}.

When not to use

  • For two to five view modes or a date range beside a list or chart. Use Segmented control
  • For one on and off button on its own, such as Mute. Use Toggle
  • For a setting that is on or off in a settings row. Use Switch
  • For actions such as Approve or Export. Toggles hold state; actions don't. Use Button group
  • For a form choice that needs a description per option. Use Choice card

Name the group and every icon

Give the group an aria-label or point aria-labelledby at its heading. Icon-only items carry an aria-label and a tooltip with the same words.

Toggles apply at once

Pressing an item changes what is on screen right away. If the change needs a Save, it belongs in a form control, not a toggle.

Anatomy#

  1. Group. A role="group" row with an 8px gap (spacing={2}). spacing={0} joins the items.
  2. Item. A 32px toggle button (28px at sm, 36px at lg) with 10px corners and 14px medium text. Outline adds the Field Stroke.
  3. Icon. 16px (14px at sm). Mark it data-icon="inline-start" beside a label so the padding tightens by 2px.
  4. Pressed fill. Well Gray behind a pressed item, set from aria-pressed.

Examples#

One or several

Without multiple, one item is pressed at a time; guard against the empty array when a value is required. With multiple, each item toggles on its own.

First reminder by
Show in the report
2 of 3 channels shown.
import { toast } from "@oration/canon/components/toast";import { ToggleGroup, ToggleGroupItem } from "@oration/canon/components/toggle-group";import * as React from "react";export function SingleAndMultiple() {    const reminderId = React.useId();    const reportId = React.useId();    const [channel, setChannel] = React.useState("email");    const [channels, setChannels] = React.useState<string[]>([        "email",        "voice",    ]);    const options = [        { value: "email", label: "Email" },        { value: "sms", label: "SMS" },        { value: "voice", label: "Voice" },    ];    return (        <div className="flex flex-col gap-5 text-left">            <div className="flex flex-col gap-1.5">                <span id={reminderId} className="text-13 font-medium">                    First reminder by                </span>                <ToggleGroup                    variant="outline"                    aria-labelledby={reminderId}                    value={[channel]}                    onValueChange={(next) => {                        if (!next[0]) return;                        setChannel(next[0]);                        toast.add({                            title: "First reminder channel set",                            description: next[0],                        });                    }}                >                    {options.map((option) => (                        <ToggleGroupItem                            key={option.value}                            value={option.value}                        >                            {option.label}                        </ToggleGroupItem>                    ))}                </ToggleGroup>            </div>            <div className="flex flex-col gap-1.5">                <span id={reportId} className="text-13 font-medium">                    Show in the report                </span>                <ToggleGroup                    multiple                    variant="outline"                    aria-labelledby={reportId}                    value={channels}                    onValueChange={setChannels}                >                    {options.map((option) => (                        <ToggleGroupItem                            key={option.value}                            value={option.value}                        >                            {option.label}                        </ToggleGroupItem>                    ))}                </ToggleGroup>                <span className="text-xs text-muted-foreground">                    {channels.length === 0                        ? "Nothing selected. The report is empty."                        : `${channels.length} of 3 channels shown.`}                </span>            </div>        </div>    );}

Variants

Default is transparent until hovered or pressed, for toolbars. Outline adds the Field Stroke, for groups that sit in a form.

import { ToggleGroup, ToggleGroupItem } from "@oration/canon/components/toggle-group";import { BoldIcon, ItalicIcon, UnderlineIcon } from "lucide-react";export function Variants() {    return (        <div className="flex flex-wrap items-center gap-6">            <ToggleGroup                multiple                defaultValue={["bold"]}                aria-label="Formatting, default"            >                <ToggleGroupItem value="bold" aria-label="Bold">                    <BoldIcon aria-hidden="true" />                </ToggleGroupItem>                <ToggleGroupItem value="italic" aria-label="Italic">                    <ItalicIcon aria-hidden="true" />                </ToggleGroupItem>                <ToggleGroupItem value="underline" aria-label="Underline">                    <UnderlineIcon aria-hidden="true" />                </ToggleGroupItem>            </ToggleGroup>            <ToggleGroup                multiple                variant="outline"                defaultValue={["bold"]}                aria-label="Formatting, outline"            >                <ToggleGroupItem value="bold" aria-label="Bold">                    <BoldIcon aria-hidden="true" />                </ToggleGroupItem>                <ToggleGroupItem value="italic" aria-label="Italic">                    <ItalicIcon aria-hidden="true" />                </ToggleGroupItem>                <ToggleGroupItem value="underline" aria-label="Underline">                    <UnderlineIcon aria-hidden="true" />                </ToggleGroupItem>            </ToggleGroup>        </div>    );}

Sizes

28, 32 and 36px, set once on the group. sm is for toolbars and sheets.

import { ToggleGroup, ToggleGroupItem } from "@oration/canon/components/toggle-group";export function Sizes() {    const sizes = ["sm", "default", "lg"] as const;    return (        <div className="flex flex-wrap items-center gap-6">            {sizes.map((size) => (                <ToggleGroup                    key={size}                    variant="outline"                    size={size}                    defaultValue={["week"]}                    aria-label={`Range, ${size}`}                >                    <ToggleGroupItem value="week">Week</ToggleGroupItem>                    <ToggleGroupItem value="month">Month</ToggleGroupItem>                </ToggleGroup>            ))}        </div>    );}

Joined

spacing={0} squares the inner corners and collapses the outline borders into one hairline between items.

import { ToggleGroup, ToggleGroupItem } from "@oration/canon/components/toggle-group";import { AlignCenterIcon, AlignLeftIcon, AlignRightIcon } from "lucide-react";import * as React from "react";export function Joined() {    const [align, setAlign] = React.useState("left");    return (        <ToggleGroup            variant="outline"            spacing={0}            aria-label="Column alignment"            value={[align]}            onValueChange={(next) => {                if (next[0]) setAlign(next[0]);            }}        >            <ToggleGroupItem value="left" aria-label="Align left">                <AlignLeftIcon aria-hidden="true" />            </ToggleGroupItem>            <ToggleGroupItem value="center" aria-label="Align center">                <AlignCenterIcon aria-hidden="true" />            </ToggleGroupItem>            <ToggleGroupItem value="right" aria-label="Align right">                <AlignRightIcon aria-hidden="true" />            </ToggleGroupItem>        </ToggleGroup>    );}

In a settings sheet

The insight sheet's visualization picker: small outline items with icons and labels that wrap, labelled by the heading above.

Insight settings

Days payable outstanding

Visualization
import { toast } from "@oration/canon/components/toast";import { ToggleGroup, ToggleGroupItem } from "@oration/canon/components/toggle-group";import {  BarChart3Icon,  HashIcon,  LineChartIcon,  PieChartIcon,  TableIcon,} from "lucide-react";import * as React from "react";export function Visualization() {    const labelId = React.useId();    const options = [        { value: "number", label: "Number", icon: HashIcon },        { value: "line", label: "Line", icon: LineChartIcon },        { value: "bar", label: "Bar", icon: BarChart3Icon },        { value: "pie", label: "Pie", icon: PieChartIcon },        { value: "table", label: "Table", icon: TableIcon },    ];    const [viz, setViz] = React.useState("bar");    return (        <fieldset className="flex w-full max-w-sm flex-col gap-1.5 rounded-xl bg-popover p-4 text-left shadow-lg">            <legend className="sr-only">Insight settings</legend>            <p className="mb-2 text-base leading-none font-medium">                Days payable outstanding            </p>            <span id={labelId} className="text-sm font-medium">                Visualization            </span>            <ToggleGroup                variant="outline"                size="sm"                aria-labelledby={labelId}                className="flex-wrap"                value={[viz]}                onValueChange={(next) => {                    if (!next[0]) return;                    setViz(next[0]);                    toast.add({                        title: "Insight updated",                        description: `Shown as ${next[0]}.`,                    });                }}            >                {options.map((option) => (                    <ToggleGroupItem key={option.value} value={option.value}>                        <option.icon className="size-3.5" aria-hidden="true" />                        {option.label}                    </ToggleGroupItem>                ))}            </ToggleGroup>        </fieldset>    );}

States#

RestHoverPressedFocusDisabled
default
outline
import { ToggleGroup, ToggleGroupItem } from "@oration/canon/components/toggle-group";import { BoldIcon } from "lucide-react";export function StatesMatrix() {    const states = ["Rest", "Hover", "Pressed", "Focus", "Disabled"] as const;    const forced: Record<(typeof states)[number], string> = {        Rest: "",        Hover: "bg-muted text-foreground",        Pressed: "",        Focus: "border-ring ring-[3px] ring-ring/50",        Disabled: "",    };    const variants = ["default", "outline"] as const;    return (        <div className="grid w-full min-w-0 grid-cols-[4.5rem_repeat(5,minmax(0,1fr))] items-center gap-x-2 gap-y-3 overflow-x-auto">            <span />            {states.map((state) => (                <span                    key={state}                    className="text-center text-xs text-muted-foreground"                >                    {state}                </span>            ))}            {variants.map((variant) => (                <div key={variant} className="contents">                    <span className="text-13 text-muted-foreground capitalize">                        {variant}                    </span>                    {states.map((state) => (                        <div key={state} className="flex justify-center">                            <ToggleGroup                                variant={variant}                                aria-label={`${variant} ${state}`}                                defaultValue={                                    state === "Pressed" ? ["bold"] : []                                }                                disabled={state === "Disabled"}                                className="pointer-events-none"                            >                                <ToggleGroupItem                                    value="bold"                                    aria-label="Bold"                                    tabIndex={-1}                                    className={forced[state]}                                >                                    <BoldIcon aria-hidden="true" />                                </ToggleGroupItem>                            </ToggleGroup>                        </div>                    ))}                </div>            ))}        </div>    );}
States
StateTreatment
RestTransparent. Outline items show the Field Stroke.
HoverWell Gray fill and ink text over 150ms.
PressedWell Gray fill, aria-pressed="true" and data-pressed. The same fill as hover.
Focus visibleA 3px Focus Indigo ring at 50%. Outline items also take an indigo border.
Disabled50% opacity and no pointer events. disabled on the group disables every item.

Behavior#

  • The value is always an array of item values, even with one choice: value={[align]}. Control it with value and onValueChange, or start it with defaultValue.
  • Without multiple, pressing an item releases the others, and pressing the pressed item empties the array. Ignore the empty array when the setting needs a value: if (next[0]) setAlign(next[0]).
  • With multiple, each item toggles on its own.
  • Arrow keys move focus between items and loop at the ends (loopFocus); Home and End jump to the first and last. Moving focus doesn't press anything.
  • variant and size set on the group apply to every item. Item-level values are used only when the group doesn't set one.
  • spacing is the gap in spacing units (2 is 8px). At 0, inner corners go square and outline borders collapse to one hairline.
  • orientation="vertical" stacks the items for layout only; see Known gaps.

Do and don't#

Do. Name the group and give every icon-only item an aria-label and a tooltip.
Don't. Ship bare icons. Screen readers announce toggle button, pressed, and nothing else.
Do. Use toggles for filters and formatting that combine and apply at once.
Don't. Use a single-choice toggle group for view modes. The segmented control reads as one choice and has the sliding thumb.

Content#

  • Labels are one word where possible: Email, Voice, Bar.
  • Tooltips and aria-labels name the effect, not the icon: Align left, not Left lines icon.
  • Don't change the label when pressed. The pressed fill carries the state.

Accessibility#

  • The group is role="group". Name it with aria-label, or with aria-labelledby pointing at a visible heading or a <legend>.
  • Each item is a <button> with aria-pressed, so screen readers announce it as a toggle button, pressed or not pressed.
  • Icon-only items need an aria-label and a tooltip; mark icons aria-hidden="true".
  • The group is one tab stop; arrow keys move within it.
  • sm items are 28px, above the 24px minimum target. Use the default size on touch layouts.
Keyboard interactions
KeysAction
TabMoves focus into the group, then out of it.
←→Moves focus to the previous or next item, looping at the ends.
HomeEndMoves focus to the first or last item.
SpacePresses or releases the focused item.
EnterPresses or releases the focused item.

Design tokens#

Design tokens
TokenUsed for
--mutedHover and pressed fill
--foregroundHovered label
--inputOutline stroke
--ringFocus border and 3px ring at 50%
--destructiveInvalid border with aria-invalid
--radius-lg10px item and group corners

API reference#

ToggleGroup

The group. Passes variant, size and spacing to its items through context.

Other props spread onto Base UI ToggleGroup (<div>).

Props of ToggleGroup
PropTypeDefaultDescription
valuereadonly string[]No defaultControlled pressed values. An array even without multiple.
defaultValuereadonly string[]No defaultInitial pressed values when uncontrolled.
onValueChange(groupValue: string[], eventDetails) => voidNo defaultCalled with the new array. It can be empty.
multiplebooleanfalseLets several items be pressed at once.
variant"default" | "outline"No defaultApplied to every item. Items default to default.
size"default" | "sm" | "lg"No defaultApplied to every item: 32, 28 or 36px.
spacingnumber2Gap in spacing units. 0 joins the items.
orientation"horizontal" | "vertical""horizontal"Row or column layout.
disabledbooleanfalseDisables every item.
loopFocusbooleantrueArrow keys wrap from the last item to the first.

ToggleGroupItem

One toggle button in the group.

Other props spread onto Base UI Toggle (<button>).

Props of ToggleGroupItem
PropTypeDefaultDescription
valueRequiredstringNo defaultThe value added to the group's array when pressed.
variant"default" | "outline""default"Used when the group sets none.
size"default" | "sm" | "lg""default"Used when the group sets none.
disabledbooleanfalseDisables this item.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

sm sets labels at 0.8rem (12.8px), off the type ramp. The Thirteen-Fourteen Rule calls for 13px in dense UI.

Pressed and hover share the same Well Gray fill, so a hovered item and a pressed one look identical.

orientation is used for layout and not passed to Base UI, so a vertical group still moves focus with ← and →, not ↑ and ↓.

One product use, the visualization picker in the insight sheet, which uses it for a single required choice.