CraftSpringToggleDemo.tsx usage.tsx
Craft · 試作
Spring-loaded toggle
A switch whose thumb overshoots the far edge and settles back — squashing a little as it travels — so flipping it feels physical, not linear.
- GSAP
- back.out
- A11y
Live demo
Off
Live · try it with mouse, touch, or keyboard
How it’s built
- The thumb travels on a GSAP back.out(2.4) spring, so it overshoots the far edge and rebounds — that little settle is what sells the 'click'.
- As it moves the thumb squashes ~14% along its direction of travel, then rounds back out at rest, like a physical switch.
- The track colour crossfades neutral ↔ accent in lockstep with the throw.
- It's a real role=switch — aria-checked, Tab to focus, Space/Enter to flip, with a visible focus ring; reduced motion snaps it with no overshoot.
Accessibility
- Real role="switch" with aria-checked — Tab to focus, Space/Enter to flip, visible focus ring.
- Reduced motion snaps it with no overshoot.
Source
The exact component, read straight from the repo at build. Requires gsap and @gsap/react.
"use client";
import { useRef, useState } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { prefersReducedMotion } from "@/lib/motion";
gsap.registerPlugin(useGSAP);
// How far the thumb travels: track inner width (60px) − thumb (28px) = 32px.
const TRAVEL = 32;
/**
* A spring-loaded toggle. The thumb travels on a `back.out` spring so it
* overshoots the far edge and settles back — and squashes a little along its
* direction of travel — so flipping it feels physical rather than linear. The
* track colour crossfades in lockstep (CSS). A real `role="switch"`: keyboard-
* operable with a focus ring; reduced motion snaps it with no overshoot.
*/
export function SpringToggleDemo() {
const rootRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLSpanElement>(null);
const [on, setOn] = useState(false);
useGSAP(
() => {
const thumb = thumbRef.current;
if (!thumb) return;
if (prefersReducedMotion()) {
gsap.set(thumb, { x: on ? TRAVEL : 0 });
return;
}
const tl = gsap.timeline();
tl.to(thumb, { x: on ? TRAVEL : 0, duration: 0.5, ease: "back.out(2.4)" });
// Squash along the throw, then round back out at rest.
tl.fromTo(
thumb,
{ scaleX: 1 },
{
scaleX: 0.86,
duration: 0.12,
yoyo: true,
repeat: 1,
ease: "power1.inOut",
transformOrigin: on ? "right center" : "left center",
},
0,
);
},
{ dependencies: [on], scope: rootRef },
);
return (
<div
ref={rootRef}
className="flex h-full w-full flex-col items-center justify-center gap-5 p-6"
>
<button
type="button"
role="switch"
aria-checked={on}
aria-label="Demo setting"
onClick={() => setOn((v) => !v)}
className={`relative inline-flex h-9 w-[4.25rem] items-center rounded-full p-1 outline-none transition-colors duration-300 focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-card ${
on ? "bg-accent" : "bg-foreground/15"
}`}
>
<span
ref={thumbRef}
aria-hidden
className="h-7 w-7 rounded-full bg-white shadow-[0_2px_8px_rgb(0_0_0_/_0.4)] will-change-transform"
/>
</button>
<p className="font-mono text-xs uppercase tracking-[0.22em] text-foreground/55">
{on ? "On" : "Off"}
</p>
</div>
);
}Usage
import { SpringToggleDemo } from "@/components/craft/SpringToggleDemo";
<SpringToggleDemo />Why these choices? The timing, easing, and reduced-motion rules behind every demo live in the motion skill file.
Read it