Skip to content

Toggle

A two-state button that stays pressed, for formatting and view options.

Level
Atom
Category
Actions
Adoption
Not used yet
import { Toggle } from "@oration/canon/components/toggle";
packages/canon/src/components/toggle.tsx

Note to Halcyon Logistics

  • Short-paid INV-20440 by $305.25 for the damaged pallet
  • Credit memo CM-1142 applied to INV-20398
  • Questions go to ap@cedarline.io
import { Kbd, KbdGroup } from "@oration/canon/components/kbd";import { Toggle } from "@oration/canon/components/toggle";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { cn } from "@oration/canon/lib/utils";import { BoldIcon, ItalicIcon, ListIcon } from "lucide-react";import * as React from "react";export function Hero() {    const [bold, setBold] = React.useState(false);    const [italic, setItalic] = React.useState(false);    const [list, setList] = React.useState(true);    const lines = [        "Short-paid INV-20440 by $305.25 for the damaged pallet",        "Credit memo CM-1142 applied to INV-20398",        "Questions go to ap@cedarline.io",    ];    return (        <div className="flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-card text-left shadow-border">            <div                role="toolbar"                aria-label="Note formatting"                className="flex items-center gap-1 border-b border-border px-2 py-1.5"            >                <Tooltip>                    <TooltipTrigger                        render={                            <Toggle                                size="sm"                                aria-label="Bold"                                aria-keyshortcuts="Meta+B"                                pressed={bold}                                onPressedChange={setBold}                            />                        }                    >                        <BoldIcon aria-hidden="true" />                    </TooltipTrigger>                    <TooltipContent>                        Bold                        <KbdGroup>                            <Kbd>⌘</Kbd>                            <Kbd>B</Kbd>                        </KbdGroup>                    </TooltipContent>                </Tooltip>                <Tooltip>                    <TooltipTrigger                        render={                            <Toggle                                size="sm"                                aria-label="Italic"                                aria-keyshortcuts="Meta+I"                                pressed={italic}                                onPressedChange={setItalic}                            />                        }                    >                        <ItalicIcon aria-hidden="true" />                    </TooltipTrigger>                    <TooltipContent>                        Italic                        <KbdGroup>                            <Kbd>⌘</Kbd>                            <Kbd>I</Kbd>                        </KbdGroup>                    </TooltipContent>                </Tooltip>                <Tooltip>                    <TooltipTrigger                        render={                            <Toggle                                size="sm"                                aria-label="Bulleted list"                                pressed={list}                                onPressedChange={setList}                            />                        }                    >                        <ListIcon aria-hidden="true" />                    </TooltipTrigger>                    <TooltipContent>Bulleted list</TooltipContent>                </Tooltip>            </div>            <div                className={cn(                    "p-4 text-sm",                    bold && "font-semibold",                    italic && "italic",                )}            >                <p className="mb-2 text-muted-foreground not-italic font-normal text-13">                    Note to Halcyon Logistics                </p>                {list ? (                    <ul className="list-disc space-y-1 pl-5">                        {lines.map((line) => (                            <li key={line}>{line}</li>                        ))}                    </ul>                ) : (                    <p>{lines.join(". ")}.</p>                )}            </div>        </div>    );}

Usage#

Toggle is a button that stays pressed: one click turns an option on, the next turns it off, and the state is exposed as aria-pressed. It is for formatting and view options in toolbars, such as bold in a note editor or wrapping long memos in a table. It is built on Base UI Toggle and shares its look with Toggle group, which is how the product uses it today. People get two things wrong: they reach for it where a Switch belongs, and they ship icon-only toggles without a name.

When to use

  • For formatting in an editor toolbar: Bold, Italic, Bulleted list.
  • For a view option that changes how the current screen draws, right away: Wrap memos, Show cents.
  • For a single on and off control that sits among buttons and should look like one.
  • As the building block of a Toggle group, when several toggles share a toolbar.

When not to use

  • For a setting in a settings page or a form row. Use Switch
  • For a set of toggles that belong together, or a choice of exactly one. Use Toggle group
  • For choosing one of two to five view modes, such as List and Board. Use Segmented control
  • For an icon action with a tooltip and an active state, such as pinning a record. Use Icon action
  • For an action that does something once, such as Export. Use Button
  • For an active filter on a list. Use Filter chip

The label never changes

A toggle's label and icon stay the same in both states; only the pressed fill changes. If the label would need to flip (Show and Hide), use a Switch or a Button whose action changes.

Toggles change the view, not the data

Use a toggle for how something is drawn or formatted. A change that is saved for everyone, or that people would expect to undo, belongs to a Switch or a Button.

Anatomy#

  1. Container. 32px tall and at least 32px wide, 10px corners, transparent, or with a 1px Field Stroke in the outline variant.
  2. Icon. 16px (14px at sm), with data-icon="inline-start" tightening the padding on that side by 2px.
  3. Label. Optional, 14px at weight 500, 4px from the icon.
  4. Pressed fill. Well Gray (--muted) while aria-pressed is true.

Examples#

Icon only

Square toggles for toolbars. Each names itself with aria-label and repeats the name in a tooltip, and what it controls changes in the same frame.

$18,240.00
import { toast } from "@oration/canon/components/toast";import { Toggle } from "@oration/canon/components/toggle";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { DollarSignIcon, PinIcon } from "lucide-react";import * as React from "react";export function IconOnly() {    const [pinned, setPinned] = React.useState(false);    const [cents, setCents] = React.useState(true);    return (        <div className="flex items-center gap-1">            <Tooltip>                <TooltipTrigger                    render={                        <Toggle                            aria-label="Pin to sidebar"                            pressed={pinned}                            onPressedChange={(next) => {                                setPinned(next);                                toast.add({                                    title: next                                        ? "Northwind Freight pinned to the sidebar"                                        : "Northwind Freight unpinned",                                });                            }}                        />                    }                >                    <PinIcon aria-hidden="true" />                </TooltipTrigger>                <TooltipContent>Pin to sidebar</TooltipContent>            </Tooltip>            <Tooltip>                <TooltipTrigger                    render={                        <Toggle                            aria-label="Show cents"                            pressed={cents}                            onPressedChange={setCents}                        />                    }                >                    <DollarSignIcon aria-hidden="true" />                </TooltipTrigger>                <TooltipContent>Show cents</TooltipContent>            </Tooltip>            <span className="ml-3 text-13 tabular-nums">                {cents ? "$18,240.00" : "$18,240"}            </span>        </div>    );}

With text

A labelled toggle for a view option above a table. The label stays the same in both states; the pressed fill and the content show which state it is in.

INV-20440Short-paid by $305.25 for the damaged pallet on BOL 88213, per Jordan Lee
INV-20398Credit memo CM-1142 applied, remaining balance paid by ACH on Sep 28
import { Toggle } from "@oration/canon/components/toggle";import { cn } from "@oration/canon/lib/utils";import { WrapTextIcon } from "lucide-react";import * as React from "react";export function WithText() {    const [wrap, setWrap] = React.useState(false);    const rows = [        {            id: "INV-20440",            memo: "Short-paid by $305.25 for the damaged pallet on BOL 88213, per Jordan Lee",        },        {            id: "INV-20398",            memo: "Credit memo CM-1142 applied, remaining balance paid by ACH on Sep 28",        },    ];    return (        <div className="flex w-full max-w-md flex-col gap-3">            <Toggle                variant="outline"                size="sm"                pressed={wrap}                onPressedChange={setWrap}                className="self-start"            >                <WrapTextIcon data-icon="inline-start" aria-hidden="true" />                Wrap memos            </Toggle>            <div className="overflow-hidden rounded-xl bg-card shadow-border">                {rows.map((row) => (                    <div                        key={row.id}                        className="flex items-start gap-3 border-b border-border px-3 py-2 text-13 last:border-b-0"                    >                        <span className="w-20 shrink-0 font-mono text-xs leading-5">                            {row.id}                        </span>                        <span                            className={cn(                                "min-w-0 flex-1",                                wrap ? "text-pretty" : "truncate",                            )}                        >                            {row.memo}                        </span>                    </div>                ))}            </div>        </div>    );}

Variants

Default is transparent until hovered or pressed, for toolbars that already have a frame. Outline adds a Field Stroke border, for toggles that stand alone on the plane.

import { Toggle } from "@oration/canon/components/toggle";import { DollarSignIcon, WrapTextIcon } from "lucide-react";export function Variants() {    return (        <div className="flex flex-wrap items-center gap-6">            <div className="flex items-center gap-1">                <Toggle defaultPressed>                    <WrapTextIcon data-icon="inline-start" aria-hidden="true" />                    Wrap memos                </Toggle>                <Toggle>                    <DollarSignIcon                        data-icon="inline-start"                        aria-hidden="true"                    />                    Show cents                </Toggle>            </div>            <div className="flex items-center gap-1">                <Toggle variant="outline" defaultPressed>                    <WrapTextIcon data-icon="inline-start" aria-hidden="true" />                    Wrap memos                </Toggle>                <Toggle variant="outline">                    <DollarSignIcon                        data-icon="inline-start"                        aria-hidden="true"                    />                    Show cents                </Toggle>            </div>        </div>    );}

Sizes

28, 32 and 36px tall. sm steps the icon to 14px, the text to 12.8px and the corners to 8px, to sit inside toolbars.

import { Toggle } from "@oration/canon/components/toggle";import { WrapTextIcon } from "lucide-react";export function Sizes() {    return (        <div className="flex items-center gap-3">            <Toggle size="sm" variant="outline" defaultPressed>                <WrapTextIcon data-icon="inline-start" aria-hidden="true" />                Small            </Toggle>            <Toggle variant="outline" defaultPressed>                <WrapTextIcon data-icon="inline-start" aria-hidden="true" />                Default            </Toggle>            <Toggle size="lg" variant="outline" defaultPressed>                <WrapTextIcon data-icon="inline-start" aria-hidden="true" />                Large            </Toggle>        </div>    );}

States#

OffHoverPressedFocusDisabled
default
outline
import { Toggle } from "@oration/canon/components/toggle";import { cn } from "@oration/canon/lib/utils";import { BoldIcon } from "lucide-react";export function StatesMatrix() {    const variants = ["default", "outline"] as const;    return (        <div className="grid w-full min-w-0 grid-cols-[5.5rem_repeat(5,minmax(0,1fr))] items-center gap-x-2 gap-y-3">            <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">                            <Toggle                                variant={variant}                                tabIndex={-1}                                aria-label={`Bold, ${state.toLowerCase()}`}                                pressed={state === "Pressed"}                                disabled={state === "Disabled"}                                className={cn(                                    "pointer-events-none",                                    state === "Hover" &&                                        "bg-muted text-foreground",                                    state === "Focus" &&                                        "border-ring ring-[3px] ring-ring/50",                                )}                            >                                <BoldIcon aria-hidden="true" />                            </Toggle>                        </div>                    ))}                </div>            ))}        </div>    );}
States
StateTreatment
OffTransparent, or a Field Stroke outline.
HoverWell Gray fill and ink text, over 150ms.
Pressed (on)Well Gray fill, the same as hover. See known gaps.
Focus visibleA 3px Focus Indigo ring at 50%, and an indigo border on the outline variant.
Disabled50% opacity and no pointer events, in either state.

Behavior#

  • Renders a native <button type="button"> through Base UI with aria-pressed and data-pressed when on.
  • Controlled with pressed and onPressedChange(pressed, eventDetails), or uncontrolled with defaultPressed.
  • Inside a Toggle group, the group owns the state and each toggle's value identifies it.
  • The change is immediate: whatever the toggle controls should update in the same frame, with no save.
  • toggleVariants is exported so a Toggle group item, or another element, can take the same classes.

Do and don't#

Do. Give an icon-only toggle an aria-label and a tooltip with the same words.
Don't. Ship a bare icon. Screen readers announce Toggle button, pressed, with no name.
$912.50
Do. Keep the label fixed and let the pressed fill show the state.
$912.50
Don't. Swap the label between Show cents and Hide cents. Combined with the pressed state, it reads backwards half the time.

Content#

  • Name the option, not the action: Wrap memos, Show cents, Bold.
  • Keep text toggles to one or two words; most toolbar toggles are icon-only with a tooltip.
  • Tooltips match the aria-label and may add the shortcut: Bold, then the ⌘B keys.

Accessibility#

  • aria-pressed tells screen readers the state; don't also put on or off in the label.
  • Icon-only toggles need an aria-label and a tooltip. Mark the icon aria-hidden="true".
  • The hover and pressed fills are the same Well Gray, so check the pressed state reads without a pointer over it. Pair a toggle with a visible effect on the content.
  • The sm size is 28px, the smallest a pointer target should be. Use the default 32px on touch layouts.
  • If a toggle has a keyboard shortcut, expose it with aria-keyshortcuts and show it in the tooltip.
Keyboard interactions
KeysAction
TabMoves focus to the toggle.
SpaceToggles it.
EnterToggles it.

Design tokens#

Design tokens
TokenUsed for
--mutedHover and pressed fill
--foregroundText on hover
--inputOutline variant border
--ringFocus border and 3px ring at 50%
--radius-lg10px corners; sm steps down to 8px (--radius-md)

API reference#

Toggle

A styled Base UI Toggle. Renders data-slot="toggle". Also exported: toggleVariants, the class recipe Toggle group reuses.

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

Props of Toggle
PropTypeDefaultDescription
variant"default" | "outline""default"Transparent, or with a 1px Field Stroke border.
size"sm" | "default" | "lg""default"28, 32 or 36px tall, and at least as wide.
pressedbooleanNo defaultControlled pressed state.
defaultPressedbooleanfalseInitial state when uncontrolled.
onPressedChange(pressed: boolean, eventDetails: Toggle.ChangeEventDetails) => voidNo defaultCalled with the next state.
valuestringNo defaultIdentifies the toggle inside a Toggle group.
disabledbooleanfalseDims to 50% and blocks interaction.
aria-labelstringNo defaultRequired for icon-only toggles.
classNamestringNo defaultMerged into the variant classes.

Known gaps#

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

Pressed and hover draw the same Well Gray fill, and nothing else changes when a toggle is on. A hovered off toggle looks on, and an on toggle under the pointer gives no feedback when clicked off.

data-[state=on]:bg-muted is a Radix attribute; Base UI sets data-pressed, so that class never applies (aria-pressed:bg-muted does the work).

aria-invalid sets a red ring color with no ring width, so an invalid toggle only shows a red border, and only in the outline variant.

The icon-to-label gap is 4px at every size; Button uses 6px at its default size and DESIGN.md asks for 6 to 8px inside controls.

No product screen uses Toggle directly yet; it ships through Toggle group.