Spring Switch
A switch whose knob stretches on the way and lands on the spring.
Preview
"use client";
import { useState } from "react";
import { useAutoplay } from "../demo-context";
import { SpringSwitch } from "./SpringSwitch";
export default function Demo() {
const [on, setOn] = useState(true);
useAutoplay(() => setOn((v) => !v), 1100);
return <SpringSwitch checked={on} onChange={setOn} label="Notifications" />;
}Installation
npx @usefluid/cli add spring-switchpnpm dlx @usefluid/cli add spring-switchyarn dlx @usefluid/cli add spring-switchbunx @usefluid/cli add spring-switchInstall the engine.
Terminal npm i @usefluid/motionCopy these files into your project.
components/fluid/spring-switch/SpringSwitch.tsx "use client"; import { springs } from "@usefluid/motion"; import { useSpring } from "@usefluid/motion/react"; import { useRef } from "react"; import styles from "./SpringSwitch.module.css"; const TRAVEL = 20; type Props = { checked: boolean; onChange(checked: boolean): void; label: string; className?: string }; export function SpringSwitch({ checked, onChange, label, className }: Props) { const thumb = useRef<HTMLSpanElement>(null); useSpring( { x: checked ? TRAVEL : 0 }, ({ x }, velocity) => { if (!thumb.current) return; const stretch = Math.min(0.45, Math.abs(velocity.x) * 0.0016); thumb.current.style.transform = `translateX(${x}px) scale(${1 + stretch}, ${1 - stretch * 0.4})`; }, springs.snappy, ); return ( <button type="button" role="switch" aria-checked={checked} className={className ? `${styles.root} ${className}` : styles.root} onClick={() => onChange(!checked)} > <span className={styles.label}>{label}</span> <span className={styles.track} data-on={checked}> <span ref={thumb} className={styles.thumb} /> </span> </button> ); }components/fluid/spring-switch/SpringSwitch.module.css .root { display: inline-flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 4px; border: 0; border-radius: var(--fluid-radius); background: none; color: var(--fluid-text); font: 500 15px/1 var(--fluid-font); cursor: pointer; touch-action: manipulation; } .root:focus-visible { outline: 2px solid var(--fluid-focus); outline-offset: 2px; } .label { text-align: left; } .track { position: relative; flex: none; width: 52px; height: 32px; border-radius: 999px; background: var(--fluid-text-2); transition: background-color 180ms ease; } .track[data-on="true"] { background: var(--fluid-accent); } .thumb { position: absolute; top: 3px; left: 3px; width: 26px; height: 26px; border-radius: 999px; background: var(--fluid-on-accent); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); transform-origin: center; } @media (forced-colors: active) { .track { border: 1px solid ButtonText; } .thumb { background: ButtonText; } .track[data-on="true"] .thumb { background: Highlight; } }Import the theme once, then point its variables at your own tokens.
layout.tsx import "@usefluid/motion/theme.css";
Usage
import { SpringSwitch } from "./SpringSwitch";
<SpringSwitch label="Email notifications" checked={emails} onChange={setEmails} />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | — | |
onChange | (checked) => void | — | |
label | string | — | Visible and announced. |
className | string | — | For layout, like full width in a settings list. |
Keyboard and screen readers
role="switch" with aria-checked. Works in high contrast mode.
Notes
The thumb uses --fluid-on-accent, so it stays visible in light and dark themes.
Every colour comes from the --fluid-* variables, and the piece is yours to change.
How it is made
The knob's position is a spring. The faster it moves, the more it stretches sideways and thins vertically, and when it stops it is a circle again. It is a button with the switch role, so screen readers announce on and off.
For your agent
For your agent
Add Spring Switch to my project with the Fluid library. Use SpringSwitch for preferences that apply right away. Read my project first. Use my logo, my colours and my font through the --fluid-* variables. Keep 44px touch targets, visible focus and reduced motion. If I have no SVG logo, ask before making one up.