Toggle group
A row of toggles where one or several can be pressed, for formatting and filters.
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
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
Anatomy#
- Group. A
role="group"row with an 8px gap (spacing={2}).spacing={0}joins the items. - Item. A 32px toggle button (28px at
sm, 36px atlg) with 10px corners and 14px medium text. Outline adds the Field Stroke. - Icon. 16px (14px at
sm). Mark itdata-icon="inline-start"beside a label so the padding tightens by 2px. - 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.
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.
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#
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> );}| State | Treatment |
|---|---|
| Rest | Transparent. Outline items show the Field Stroke. |
| Hover | Well Gray fill and ink text over 150ms. |
| Pressed | Well Gray fill, aria-pressed="true" and data-pressed. The same fill as hover. |
| Focus visible | A 3px Focus Indigo ring at 50%. Outline items also take an indigo border. |
| Disabled | 50% 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 withvalueandonValueChange, or start it withdefaultValue. - 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. variantandsizeset on the group apply to every item. Item-level values are used only when the group doesn't set one.spacingis the gap in spacing units (2 is 8px). At0, 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#
aria-label and a tooltip.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 witharia-label, or witharia-labelledbypointing at a visible heading or a<legend>. - Each item is a
<button>witharia-pressed, so screen readers announce it as a toggle button, pressed or not pressed. - Icon-only items need an
aria-labeland a tooltip; mark iconsaria-hidden="true". - The group is one tab stop; arrow keys move within it.
smitems are 28px, above the 24px minimum target. Use the default size on touch layouts.
| Keys | Action |
|---|---|
| Tab | Moves focus into the group, then out of it. |
| ←→ | Moves focus to the previous or next item, looping at the ends. |
| HomeEnd | Moves focus to the first or last item. |
| Space | Presses or releases the focused item. |
| Enter | Presses or releases the focused item. |
Design tokens#
| Token | Used for |
|---|---|
--muted | Hover and pressed fill |
--foreground | Hovered label |
--input | Outline stroke |
--ring | Focus border and 3px ring at 50% |
--destructive | Invalid border with aria-invalid |
--radius-lg | 10px 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>).
| Prop | Type | Default | Description |
|---|---|---|---|
value | readonly string[] | No default | Controlled pressed values. An array even without multiple. |
defaultValue | readonly string[] | No default | Initial pressed values when uncontrolled. |
onValueChange | (groupValue: string[], eventDetails) => void | No default | Called with the new array. It can be empty. |
multiple | boolean | false | Lets several items be pressed at once. |
variant | "default" | "outline" | No default | Applied to every item. Items default to default. |
size | "default" | "sm" | "lg" | No default | Applied to every item: 32, 28 or 36px. |
spacing | number | 2 | Gap in spacing units. 0 joins the items. |
orientation | "horizontal" | "vertical" | "horizontal" | Row or column layout. |
disabled | boolean | false | Disables every item. |
loopFocus | boolean | true | Arrow keys wrap from the last item to the first. |
ToggleGroupItem
One toggle button in the group.
Other props spread onto Base UI Toggle (<button>).
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | string | No default | The 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. |
disabled | boolean | false | Disables 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.