Toggle
A two-state button that can be either on or off — two variants, three sizes, and a loading state that keeps its width.
- Status
- Since
0.1.0- Accessibility pattern
- native button with aria-pressed
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Toggle before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify toggleInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/toggleRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/spinner.
Usage
import { Toggle } from "@/components/ui/toggle";
<Toggle>Toggle</Toggle>;Examples
Outline
Use variant="outline" for an outline style.
With Text
A toggle takes an icon, a label, or both. The icon reacts to the pressed state through the
group/toggle class the recipe puts on the root — group-aria-pressed/toggle:fill-foreground
fills a bookmark once it is on.
Size
Use the size prop to change the size of the toggle.
Disabled
A disabled toggle keeps its pointer events (FRM-4), so a Tooltip can explain why it is unavailable.
Base UI's Toggle has no focusableWhenDisabled, so the native disabled attribute stays and the
control leaves the tab order — this is the pointer half of FRM-4 only.
RTL
Every size is written in logical properties, so a right-to-left document mirrors padding and icon spacing without any extra work.
Loading
loading is ours (API-5). It shows a spinner over the label, sets aria-busy, blocks activation,
and keeps the label's box at opacity: 0 — so the toggle's width does not move and its accessible
name survives (A11Y-12).
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
loading | boolean | — | Shows a spinner over the label, blocks activation and sets aria-busy. The label keeps its
box at opacity: 0, so the toggle's width does not move and its accessible name survives
(API-5, A11Y-12). The wrapper is a real inline-flex box inheriting the root's gap, never
display: contents: a box-less element accepts no opacity, so the label would paint at
full strength under the spinner. |
size | "default" | "lg" | "sm" | null | — | |
variant | "default" | "outline" | null | — |
Accessibility
- Renders a native
<button>through Base UI's Toggle, carryingaria-pressedfor the on/off state; Enter and Space flip it. - On
:focus-visiblethe global 2px outline inbase.cssapplies (FOC-1). The recipe carries no focus ring, noring-3glow and nooutline-noneof its own (FOC-6). - An icon-only toggle has no visible text — give it an
aria-label, as every example here does. loadingsetsaria-busy="true"and keeps the label in the accessibility tree (A11Y-12).- The pressed state is never carried by colour alone: the fill changes and
aria-pressedchanges with it.
| Contract | States tested |
|---|---|
| Behaviour | default, disabled, error, invalid, pressed |
| Accessibility | invalid, labeled, pressed |
| Visual | default, hover, disabled, invalid, error |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/toggle.patch. Every hunk:
- FOC-1, FOC-6 — the
focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50glow and theoutline-nonethat suppressed the global outline are removed, along with thearia-invalid:ring-destructive/20anddark:aria-invalid:ring-destructive/40halos. - FOC-14 — the invalid tint stays upstream's
aria-invalid:border-destructive, held in every state, focused or not (FOC-14 retired FOC-5'snot-focus:guard). - FRM-4 —
disabled:pointer-events-noneis dropped, so a disabled toggle stays hoverable and a Tooltip can explain it. Base UI's Toggle has nofocusableWhenDisabled, so the nativedisabledattribute stays: this is the pointer half of FRM-4 only. - API-5, A11Y-12 — the
loadingprop, the out-of-flow spinner and theopacity-0label; the dim is scoped todisabled:not-data-loading:opacity-50so a loading toggle is not dimmed twice. The label wrapper is a realinline-flexbox inheriting the root'sgap, exactly as Button's is — adisplay: contentswrapper generates no box and so accepts noopacity, which would leave the label painted at full strength under the spinner. This is whytoggledepends on thespinnerregistry item. - API-39 — a pressed toggle is filled, so pressed never reads as hover:
aria-pressedanddata-[state=on]takebg-primary/10(upstream'sbg-mutedis also its hover fill), hovering a pressed toggle deepens it tobg-primary/15, and theoutlinevariant's pressed border isborder-primary/30. The ink staystext-foreground, icons included. An unpressed toggle keeps upstream's transparent fill andhover:bg-muted. ToggleGroup items inherit it. - DOC-2 —
cnis imported from@vegastack/design.