Switch React component for Tailwind CSS v4
v1.0.1Updated in 1.9.0
A React toggle switch for settings that apply at once: a spring thumb that stretches and drags, thumb icons, and async saves that roll back.
Click the track, its label or the empty space of the row and the thumb springs across while the track fills in the tone. Hold it down and the thumb stretches toward where it is going, then settles as it lands; drag the thumb sideways like on an iPhone and it follows your finger, committing past half way and snapping back otherwise. A vertical swipe on a phone still scrolls the page. Tab reaches the switch with a ring around the track; Space and Enter toggle it, and holding Space stretches the thumb until you let go. When saving is async the switch flips at once and a spinner turns in the thumb; clicks and keys are ignored until it settles, and a failed save flips it back, shakes the row and shows the error under it (press Simulate an error in the playground). Read-only switches keep their value and ignore input, disabled ones dim. Cards take a ring while on and dip slightly when pressed. On a phone every row is at least 44px tall and the whole row is the target; a switch without a label gets a 44px hit area. With reduced motion the thumb jumps, nothing stretches or shakes, and the spinner turns slower.
Switch is a free React component in the React input components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add switch, together with UIProvider, Field, Spinner, useControllableState, Motion presets and 1 more and the npm packages @radix-ui/react-switch and motion.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add switchUsage
Import Switch from @/components/ui/switch.
import { useState } from "react"
import { Switch } from "@/components/ui/switch"
async function saveSetting(key: string, on: boolean) {
const res = await fetch("/api/settings", { method: "PATCH", body: JSON.stringify({ [key]: on }) })
if (!res.ok) throw new Error("save failed")
}
export function NotificationSettings() {
const [messages, setMessages] = useState(true)
return (
<div className="flex flex-col divide-y divide-foreground/10 rounded-2xl bg-surface shadow-raised">
<Switch
label="New messages"
description="When someone writes to you about a listing"
labelPosition="start"
checked={messages}
onCheckedChange={setMessages}
classNames={{ field: "min-h-14 px-4 py-3" }}
/>
<Switch
label="Price offers"
labelPosition="start"
defaultChecked
errorText="Could not save. Try again."
onCheckedChange={(on) => saveSetting("offers", on)}
classNames={{ field: "min-h-14 px-4 py-3", error: "px-4 pb-3" }}
/>
</div>
)
}Props
labeldefault""ReactNodeText beside the track. The whole row is one <label>, so clicking the text, the description or the empty space toggles it. It names the switch through aria-labelledby. Leave it empty for a bare switch (then pass aria-label).
descriptiondefault""ReactNodeA second line under the label, in the muted caption style. It stays out of the switch's name and is linked with aria-describedby, so screen readers read it after the label and the state.
errordefault""ReactNodeA message under the row, lined up with the label, that slides in and marks the switch invalid (red ring, aria-invalid). "", false and null are no error; true only marks it invalid.
requireddefaultfalsebooleanA red mark after the label (hidden from screen readers) and required on the switch, so a native form will not submit while it is off.
invaliddefaultfalsebooleanThe red ring and aria-invalid without a message, for forms that show errors elsewhere.
disableddefaultfalsebooleanDims the row to 50% and blocks every input. A disabled Field or FieldGroup around it disables it too.
readOnlydefaultfalsebooleanShows the value but ignores clicks, keys and drags, with no hover tint and aria-readonly. It stays focusable, so it is still read out.
defaultCheckeddefaultfalsebooleanThe starting value while uncontrolled. Pass checked and onCheckedChange to control it.
loadingdefaultfalsebooleanA spinner turns inside the thumb and clicks, keys and drags are ignored, with aria-busy. For a save you track yourself; an async onCheckedChange does the same on its own.
errorTextdefault"Could not save. Try again."ReactNodeShown under the row when an async onCheckedChange rejects, and announced. It clears on the next change. Press Simulate an error in the playground to see it.
sizedefault"md""sm" | "md" | "lg"Track 32x18 / 40x24 / 48x28px with a 14 / 20 / 24px thumb, label 13 / 14 / 15px. Rows grow to 44px on touch screens; the track keeps its size.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The track color while on. neutral is black in light mode and white in dark mode, where the thumb turns dark so it still stands out.
colordefault""stringAny CSS color for the track while on. Replaces the tone; the thumb turns black or white by the color's lightness. Leave empty to use the tone.
variantdefault"default""default" | "card"card turns the row into a soft-filled tile (the checkbox card look) with the switch at the top end; it takes a ring in the tone while on and dips slightly when pressed. Pair it with icon.
iconsdefaultfalsebooleanA check inside the thumb while on and an x while off, swapping with a crossfade and a small turn. They stay undistorted while the thumb stretches.
labelPositiondefault"end""end" | "start"end: the switch first, the text after it. start: the text first and the switch at the end of a full-width row, like an iOS settings list. Cards always put the switch at the end.
checkedbooleanThe controlled value. With an async onCheckedChange the switch shows the value being saved until the promise settles, then follows checked again, so set checked after the save succeeds (or revert it yourself when it fails) for the rollback to show.
onCheckedChange(checked: boolean) => (void | Promise<unknown>)Called with the new value on every click, key or drag. Return a promise to save first: the switch flips at once, spins, ignores input until it settles, and a rejection flips it back, shakes the row and shows errorText. A late result after unmount is ignored.
checkedIconReactNodeA custom icon inside the thumb while on, like a Moon. Unsized svgs follow the size (10 / 12 / 14px) and take the tone color.
uncheckedIconReactNodeA custom icon inside the thumb while off, like a Sun.
iconReactNodeCards: the leading icon tile, which takes the tone's soft color while on. Rows: an icon, thumbnail or avatar before the text, centered on the row.
namedefault""stringForm field name. Inside a <form> Radix renders a hidden checkbox that submits value while the switch is on (nothing while off), and a form reset returns it to defaultChecked. The demo prints what the form would submit.
valuedefault"on"stringWhat the form submits while the switch is on.
idstringThe switch's id. Inside a Field it reuses the Field's, so the Field's label points at it.
classNamesPartial<Record<"field" | "track" | "thumb" | "icon" | "content" | "label" | "description" | "error", string>>Extra classes for the parts: the row or card, the track, the thumb, the icon layer, the text column, the label, the description and the error.
classNamestringClasses for the outermost element: the wrapper around the row and its message, or the track itself when the switch has no text. Override the tone vars here, e.g. [--tone:var(--success)].
refRefObject<HTMLButtonElement | null> or (node) => voidThe track: the Radix switch <button role="switch">, which also receives every other prop.
Examples
Async save with rollback
Return a promise: the switch flips at once and spins. Turn on the error and the save fails, it flips back and shakes.
Sizes and tones
32, 40 and 48px tracks; any tone or CSS color for the on state.
Thumb icons
Your own icons (a moon and a sun) or the built-in check and x. They never distort while the thumb stretches.
Cards
A bordered tile with an icon; the ring follows the value and the tile dips when pressed.
Label position, disabled and read-only
end puts the switch first, start at the end of a full-width row. Read-only keeps the value and ignores input.
In a native form
name and value submit through FormData while on, required blocks the submit, and reset puts every switch back.
Without a label, in a list
A bare switch named with aria-label; on touch it still gets a 44px hit area.
Files
- components/ui/switch.tsx
FAQ
Is the React Switch component free?
Yes. The source is free and needs no account: copy it from the Code tab or install it with the CLI.
How do I install Switch?
Run npx wingo-ui@latest add switch. It also adds the Wingo UI items it builds on (UIProvider, Field, Spinner, useControllableState, Motion presets and 1 more) and the npm packages @radix-ui/react-switch and motion. With shadcn, run npx shadcn@latest add https://wingo-ui.com/r/switch.json.
How does Switch behave on phones?
A vertical swipe on a phone still scrolls the page. On a phone every row is at least 44px tall and the whole row is the target; a switch without a label gets a 44px hit area.
See also
Used in
More React input components
Articles using this component
- React Controllable State Hook: Code and 3 Pitfalls
Next.js and Tailwind CSS10 min read
- React Settings Page: Section Nav, Save Bar, Danger Zone
Build it: screens and apps11 min read
- Framer Motion Spring Presets: Stiffness and Damping
Component guides9 min read
- Web Vibration API: Haptic Feedback on Android and iPhone
Mobile UI in React11 min read