Toggle

A single-button two-state press control. Renders a real <button> with aria-pressed and data-state="on" | "off" — use it for "this one action is on or off" affordances like bold, italic, mute-icon, favourite.

Customize
Style
default
md

Installation

npx shadcn@latest add https://craftbits.dev/r/toggle.json

Usage

import { Toggle } from "@craft-bits/core";
 
<Toggle aria-label="Toggle bold">Bold</Toggle>

Controlled — pair pressed with onPressedChange:

const [bold, setBold] = useState(false);
 
<Toggle pressed={bold} onPressedChange={setBold} aria-label="Toggle bold">
  <BoldIcon />
</Toggle>

Uncontrolled — let the component own its state via defaultPressed:

<Toggle defaultPressed aria-label="Toggle italic">
  <ItalicIcon />
</Toggle>

Understanding the component

  1. Single press button — not a group. Toggle is the "one action, on/off" primitive. For a segmented multi-option control, reach for TogglePill (compound) or ModeToggle (props-driven). The three siblings cover the full toggle space.
  2. Controlled + uncontrolled. pressed + onPressedChange makes it a controlled mirror of your state; defaultPressed lets the component manage its own. Same shape as Radix's value / defaultValue pattern.
  3. State hooks. The button exposes aria-pressed (announced by screen readers) and data-state="on" | "off" (used by Tailwind variants). The data-[state=on]:bg-cb-bg-muted data-[state=on]:text-cb-fg rule paints the pressed surface; override it via className if you need a different active treatment.
  4. Tap feedback. Press scale uses the canonical TAP_SCALE from @craft-bits/core/motion; the transition runs on SPRINGS.snap so it composes with the rest of the transport family. Disabled buttons skip both — there's nothing to react to.
  5. Variant + size matrix. default is a borderless surface (toolbar use); outline adds a neutral border for flat backgrounds. The sm / md / lg sizes follow the same 32 / 36 / 40 px ramp as the other transport controls so the button drops into a row of PlayPause, SoundToggle, etc. without vertical drift.

Props

PropTypeDefaultDescription
pressedbooleanControlled pressed state. Pair with onPressedChange.
defaultPressedbooleanfalseUncontrolled initial pressed state.
onPressedChange(pressed: boolean) => voidFired with the next pressed state when the user clicks.
variant"default" | "outline""default"Visual treatment. outline adds a neutral border.
size"sm" | "md" | "lg""md"Height + min-width: 32 / 36 / 40 px.
disabledbooleanfalseDisables the button — no click, no tap feedback.
aria-labelstringAccessible name when the button only renders an icon.
classNamestringMerged onto the rendered <button> via cn().
...restButtonHTMLAttributes<HTMLButtonElement>Any other <button> prop is spread onto the root.

Accessibility

  • Renders a real <button type="button"> so it picks up native focus, keyboard activation (Space / Enter), and disabled semantics for free.
  • aria-pressed reflects the current state on every render so assistive tech announces transitions correctly.
  • data-state="on" | "off" mirrors aria-pressed for CSS styling hooks (data-[state=on]:… in Tailwind).
  • Provide aria-label when the button only contains an icon — the icon SVG should be aria-hidden. With text children, the visible label is the accessible name.
  • Focus is visible via a focus-visible: ring keyed to --cb-accent against a --cb-bg offset, so keyboard users always see the current target.
  • Tap scale respects prefers-reduced-motion: Motion's reduced-motion handling collapses the transition to an instant snap.

Credits

  • Extracted from: AlgoFlashcards (src/platform/ui/toggle.tsx). The source wrapped radix-ui's Toggle.Root; craft-bits drops the extra dependency, hand-rolls the controlled-state plumbing the same way SoundToggle does, and adds the TAP_SCALE + SPRINGS.snap feel so the button composes with the rest of the transport family.