Input

Switch

One boolean, and a thumb you can throw. It widens while it is held, travels on a spring, and relaxes once it lands.

Usage

import { Switch } from '@carabine/ui/switch';
<Switch label="Notifications" checked={on} onCheckedChange={setOn} />

A switch takes effect immediately. If it needs a Save button beside it, it is a checkbox wearing the wrong clothes.

How it moves

Three beats, and they do not overlap: the thumb widens over 120ms, then travels on a spring at 520 stiffness and 34 damping — deliberately under critical, which is about 46 — and then relaxes back to a circle over 200ms once the travel says it has finished. The stretch outlasts the press on purpose: a thumb that snaps back the instant you let go never looks like it was thrown.

The position is a ratio, not a number of pixels. The thumb changes width while it moves, so a travel measured in pixels lands short by exactly the stretch.

const x = useTransform([at, wide], ([ratio, size]: number[]) => ratio * (span - size));

And a flick counts. Distance alone refuses a fast, short drag — the gesture people actually make — so the release reads speed first and only falls back to where the thumb happens to be.

const speed = Math.abs(by) / Math.max(16, performance.now() - since.current);
const next = speed > FLICK ? by > 0 : at.get() > 0.5;

Examples

Sizes

width, height and inset are the whole geometry. The thumb is whatever is left after the inset, so a switch stays a switch at any size rather than becoming a pill with a dot in it.

<Switch width={36} height={18} inset={2} aria-label="Small" />
<Switch aria-label="Default" />
<Switch width={64} height={32} inset={4} aria-label="Large" />

A skin of your own

skin is merged over the theme's, so one field changes without restating the rest. Keep the values hex or rgb() — they are interpolated as the thumb travels, not swapped at the end.

<Switch label="Live" skin={{ on: '#22c55e', thumbOn: '#052e16' }} />
<Switch label="Alert" skin={{ on: '#f43f5e', thumbOn: '#4c0519' }} />

Customization

<Switch
  label="Notifications"
  labelSide="left"   // the label before the control
  gap={14}           // between the two
  stretch={8}        // how much wider the thumb goes while held
  theme="light"
/>

Styling reference

The track is the segmented control's recess, and it is opaque #0e0e11 dark, #f3f3f4 light. It was translucent white at first, which is the one thing this library does not allow without a blur behind it: a hole you read a thumb against is not a tint.

FieldWhat it paints
on / offThe track, either way
thumbOn / thumbOffThe thumb, either way
ringThe recess, as inset shadows. A hole does not cast one outward
thumbRingThe thumb’s hairline and its short drop shadow

API reference

PropType
classNamestringAdded to the component’s own classes, so yours wins
styleCSSPropertiesMerged after the component’s own inline styles, so yours wins

Colours are data and dimensions are props, and neither covers a margin, a font, or a class from your own system. That is what these are for. State is legible from CSS as well — data-state, data-disabled, data-side — so a rule can answer it without knowing a single class name of ours. See Theming.

PropTypeDefault
checkedbooleanControlled
defaultCheckedbooleanfalse
onCheckedChange(checked: boolean) => void
labelReactNodeBeside it, and the accessible name
labelSide'left' | 'right''right'
aria-labelstringThe name when there is no visible label
widthnumber48
heightnumber24
insetnumber3The thumb is the rest
stretchnumber5How much wider the thumb goes while held
gapnumber10Between the control and its label
skinPartial<SwitchSkin>the theme'sMerged over, so one field changes alone
disabledbooleanfalse
theme'dark' | 'light''dark'
name / valuestring

role="switch" with aria-checked on a real <button>, so Space and Enter come free and nothing in the pointer handling gets in their way. prefers-reduced-motion drops the travel and the stretch to nothing — it still lands on the other side, it just does not cross.