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';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.
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.
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.
| Field | What it paints |
|---|---|
| on / off | The track, either way |
| thumbOn / thumbOff | The thumb, either way |
| ring | The recess, as inset shadows. A hole does not cast one outward |
| thumbRing | The thumb’s hairline and its short drop shadow |
API reference
| Prop | Type | |
|---|---|---|
| className | string | Added to the component’s own classes, so yours wins |
| style | CSSProperties | Merged 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.
| Prop | Type | Default | |
|---|---|---|---|
| checked | boolean | — | Controlled |
| defaultChecked | boolean | false | |
| onCheckedChange | (checked: boolean) => void | — | |
| label | ReactNode | — | Beside it, and the accessible name |
| labelSide | 'left' | 'right' | 'right' | |
| aria-label | string | — | The name when there is no visible label |
| width | number | 48 | |
| height | number | 24 | |
| inset | number | 3 | The thumb is the rest |
| stretch | number | 5 | How much wider the thumb goes while held |
| gap | number | 10 | Between the control and its label |
| skin | Partial<SwitchSkin> | the theme's | Merged over, so one field changes alone |
| disabled | boolean | false | |
| theme | 'dark' | 'light' | 'dark' | |
| name / value | string | — |
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.