Skip to content
Component installs need the registry setup
VegaStack Design

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
stable
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 toggle

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/toggle

Run 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

PropTypeDefaultDescription
loadingboolean—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, carrying aria-pressed for the on/off state; Enter and Space flip it.
  • On :focus-visible the global 2px outline in base.css applies (FOC-1). The recipe carries no focus ring, no ring-3 glow and no outline-none of its own (FOC-6).
  • An icon-only toggle has no visible text — give it an aria-label, as every example here does.
  • loading sets aria-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-pressed changes with it.
ContractStates tested
Behaviourdefault, disabled, error, invalid, pressed
Accessibilityinvalid, labeled, pressed
Visualdefault, hover, disabled, invalid, error

Do / Don't

Do
Give every toggle an aria-label that names the thing being toggled, not the current state.
Don't
Use a Toggle for a form value that must submit — that is a Checkbox or a Switch.

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/50 glow and the outline-none that suppressed the global outline are removed, along with the aria-invalid:ring-destructive/20 and dark:aria-invalid:ring-destructive/40 halos.
  • FOC-14 — the invalid tint stays upstream's aria-invalid:border-destructive, held in every state, focused or not (FOC-14 retired FOC-5's not-focus: guard).
  • FRM-4 — disabled:pointer-events-none is dropped, so a disabled toggle stays hoverable and a Tooltip can explain it. Base UI's Toggle has no focusableWhenDisabled, so the native disabled attribute stays: this is the pointer half of FRM-4 only.
  • API-5, A11Y-12 — the loading prop, the out-of-flow spinner and the opacity-0 label; the dim is scoped to disabled:not-data-loading:opacity-50 so a loading toggle is not dimmed twice. The label wrapper is a real inline-flex box inheriting the root's gap, exactly as Button's is — a display: contents wrapper generates no box and so accepts no opacity, which would leave the label painted at full strength under the spinner. This is why toggle depends on the spinner registry item.
  • API-39 — a pressed toggle is filled, so pressed never reads as hover: aria-pressed and data-[state=on] take bg-primary/10 (upstream's bg-muted is also its hover fill), hovering a pressed toggle deepens it to bg-primary/15, and the outline variant's pressed border is border-primary/30. The ink stays text-foreground, icons included. An unpressed toggle keeps upstream's transparent fill and hover:bg-muted. ToggleGroup items inherit it.
  • DOC-2 — cn is imported from @vegastack/design.

On this page