Spring switch

Free · MIT

Switch with a springy thumb that squashes while pressed, icons that spin inside the thumb, three sizes, label and description.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/switch-spring.json

The file lands in components/goodui/switch-spring.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/switch-spring works too.

Usage

import * as React from "react"
import { SwitchSpring } from "@/components/goodui/switch-spring"

export function ReminderSettings() {
  const [sms, setSms] = React.useState(true)

  return (
    <div className="rounded-xl border bg-card p-4">
      <SwitchSpring
        checked={sms}
        onCheckedChange={setSms}
        label="SMS reminders"
        description={sms ? "Clients get a text 24 hours before each visit." : "Clients won't be reminded."}
        labelPosition="start"
      />
    </div>
  )
}

When to use

Use it for
  • Settings that take effect immediately: SMS reminders, auto-confirm bookings, email digests.
  • Settings rows that need a label and a helper line that can change with the state.
  • Mode toggles where an icon inside the thumb explains the state (sun/moon, check/x).
Reach for something else
  • A choice that is only applied when a form is submitted, or a list of items to tick: use checkbox-draw.
  • Switching the whole site theme with a single icon button: use theme-toggle.
  • Choosing between more than two options: use segmented-control.
If you needUse
Dense settings lists and tablessize="sm"
Standard settings rowssize="md" (default) with label and description
A prominent mode togglesize="lg" with icons={{ on: <Moon />, off: <Sun /> }}
Label on the left, switch on the right (settings pages)labelPosition="start"

Examples

Icons inside the thumb

import { Moon, Sun } from "lucide-react"

<SwitchSpring
  size="lg"
  defaultChecked={false}
  icons={{ on: <Moon />, off: <Sun /> }}
  label="Quiet hours"
  description="Pause notifications from 9 pm to 7 am."
/>

In a form, without a visible label

<form action={saveBillingSettings}>
  <SwitchSpring name="autoRenew" defaultChecked aria-label="Auto-renew subscription" />
  <button type="submit">Save</button>
</form>
// Submits autoRenew=on or autoRenew=off

API reference

PropTypeDefault
name
Form field name; a hidden input submits "on" when checked and "off" when not.
string—
checked
Controlled on/off state; pair with onCheckedChange.
boolean—
defaultChecked
Initial state when uncontrolled.
booleanfalse
onCheckedChange
Called with the new state on every toggle (click, Space or Enter).
(checked: boolean) => void—
size
"sm" 20×36px, "md" 24×44px, "lg" 32×56px track.
"sm" | "md" | "lg""md"
icons
Icons drawn inside the thumb, e.g. { on: <Moon />, off: <Sun /> }.
{ on: React.ReactNode; off: React.ReactNode }—
label
Clickable label next to the switch; also its accessible name.
React.ReactNode—
description
Muted helper text under the label, linked with aria-describedby.
React.ReactNode—
labelPosition
Put the label before the switch instead of after it.
"start" | "end""end"

Also accepts every <button> attribute.

Accessibility

Space / EnterToggles the switch (native <button>); the thumb squashes while the key is held.
TabMoves focus to the switch; a focus ring shows on keyboard focus only.
  • Renders a native <button role="switch"> with aria-checked, so screen readers announce it as on or off.
  • label renders a <label htmlFor> tied to the button, so clicking the text toggles it and it names the switch; description is linked with aria-describedby.
  • Without label, pass aria-label so the switch has a name.
  • The thumb and its icons are aria-hidden; the state comes from aria-checked only.

Motion

  • The thumb slides on a spring (stiffness 520, damping 24, mass 0.7).
  • Pressing with the pointer or holding Space/Enter stretches the thumb to scaleX 1.22 and scaleY 0.9 on a stiffer spring, anchored to the side it rests on.
  • Icons swap with a spin and scale: the old one turns away while the new one rotates in from ±120 degrees.
  • The track color fades over 200 ms (CSS).
  • Reduced motion: the thumb jumps without a spring, the squash is off, and icons cross-fade in 100 ms.

Notes for AI

Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.

  • Use checked + onCheckedChange (not value/onChange); onCheckedChange receives a boolean.
  • className goes on the <button> track, not the wrapper; recolor the on state with a class like data-[state=checked]:bg-emerald-500.
  • icons needs both on and off; pass lucide icons without size classes, the thumb sizes them.
  • With name, the hidden input submits "on" or "off" (unlike a native checkbox, which omits unchecked values). Parse both on the server.
  • Only sm, md and lg sizes exist; do not invent xs or xl.

FAQ

How do I change the on color?

Pass a class for the checked state on className, e.g. data-[state=checked]:bg-emerald-500. By default it uses your theme's --primary.

Does it submit with a form?

Yes, when you pass name. A hidden input sends "on" when checked and "off" when not.

Related