Toggle
A two-state button that stays pressed, for formatting and view options.
- Status
- Experimental
- Level
- Atom
- Category
- Actions
- Adoption
- Not used yet
import { Toggle } from "@oration/canon/components/toggle";packages/canon/src/components/toggle.tsxNote 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
Toggles change the view, not the data
Anatomy#
- Container. 32px tall and at least 32px wide, 10px corners, transparent, or with a 1px Field Stroke in the outline variant.
- Icon. 16px (14px at
sm), withdata-icon="inline-start"tightening the padding on that side by 2px. - Label. Optional, 14px at weight 500, 4px from the icon.
- Pressed fill. Well Gray (
--muted) whilearia-pressedis 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.
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.
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#
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> );}| State | Treatment |
|---|---|
| Off | Transparent, or a Field Stroke outline. |
| Hover | Well Gray fill and ink text, over 150ms. |
| Pressed (on) | Well Gray fill, the same as hover. See known gaps. |
| Focus visible | A 3px Focus Indigo ring at 50%, and an indigo border on the outline variant. |
| Disabled | 50% opacity and no pointer events, in either state. |
Behavior#
- Renders a native
<button type="button">through Base UI witharia-pressedanddata-pressedwhen on. - Controlled with
pressedandonPressedChange(pressed, eventDetails), or uncontrolled withdefaultPressed. - Inside a Toggle group, the group owns the state and each toggle's
valueidentifies it. - The change is immediate: whatever the toggle controls should update in the same frame, with no save.
toggleVariantsis exported so a Toggle group item, or another element, can take the same classes.
Do and don't#
aria-label and a tooltip with the same words.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-labeland may add the shortcut: Bold, then the ⌘B keys.
Accessibility#
aria-pressedtells screen readers the state; don't also put on or off in the label.- Icon-only toggles need an
aria-labeland a tooltip. Mark the iconaria-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-keyshortcutsand show it in the tooltip.
| Keys | Action |
|---|---|
| Tab | Moves focus to the toggle. |
| Space | Toggles it. |
| Enter | Toggles it. |
Design tokens#
| Token | Used for |
|---|---|
--muted | Hover and pressed fill |
--foreground | Text on hover |
--input | Outline variant border |
--ring | Focus border and 3px ring at 50% |
--radius-lg | 10px 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>).
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
pressed | boolean | No default | Controlled pressed state. |
defaultPressed | boolean | false | Initial state when uncontrolled. |
onPressedChange | (pressed: boolean, eventDetails: Toggle.ChangeEventDetails) => void | No default | Called with the next state. |
value | string | No default | Identifies the toggle inside a Toggle group. |
disabled | boolean | false | Dims to 50% and blocks interaction. |
aria-label | string | No default | Required for icon-only toggles. |
className | string | No default | Merged 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.