Spring Button
A button that sinks when touched and comes back with weight, squashing with its speed.
Preview
"use client";
import { useState } from "react";
import { useAutoplay } from "../demo-context";
import { SpringButton } from "./SpringButton";
export default function Demo() {
const [pressed, setPressed] = useState<number | null>(null);
const [turn, setTurn] = useState(0);
useAutoplay(() => {
setPressed(turn % 2);
setTurn((t) => t + 1);
setTimeout(() => setPressed(null), 180);
}, 900);
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, justifyContent: "center" }}>
<SpringButton pressed={pressed === 0 || undefined}>Save</SpringButton>
<SpringButton variant="quiet" pressed={pressed === 1 || undefined}>Cancel</SpringButton>
</div>
);
}Installation
npx @usefluid/cli add spring-buttonpnpm dlx @usefluid/cli add spring-buttonyarn dlx @usefluid/cli add spring-buttonbunx @usefluid/cli add spring-buttonInstall the engine.
Terminal npm i @usefluid/motionCopy these files into your project.
components/fluid/spring-button/SpringButton.tsx "use client"; import { springs } from "@usefluid/motion"; import { useSpring } from "@usefluid/motion/react"; import { type ButtonHTMLAttributes, useRef, useState } from "react"; import styles from "./SpringButton.module.css"; type Props = ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "solid" | "quiet"; pressed?: boolean; }; export function SpringButton({ variant = "solid", pressed, className, ...props }: Props) { const el = useRef<HTMLButtonElement>(null); const [hover, setHover] = useState(false); const [down, setDown] = useState(false); const isDown = pressed ?? down; useSpring( { s: isDown ? 0.93 : hover ? 1.025 : 1 }, ({ s }, velocity) => { if (!el.current) return; const stretch = Math.max(-0.05, Math.min(0.05, velocity.s * 0.03)); el.current.style.transform = `scale(${s - stretch}, ${s + stretch})`; }, springs.bouncy, ); return ( <button {...props} ref={el} className={[styles.button, className].filter(Boolean).join(" ")} data-variant={variant} onPointerEnter={(e) => { if (e.pointerType === "mouse") setHover(true); props.onPointerEnter?.(e); }} onPointerLeave={(e) => { setHover(false); setDown(false); props.onPointerLeave?.(e); }} onPointerDown={(e) => { setDown(true); props.onPointerDown?.(e); }} onPointerUp={(e) => { setDown(false); props.onPointerUp?.(e); }} onPointerCancel={(e) => { setDown(false); props.onPointerCancel?.(e); }} onKeyDown={(e) => { if (e.key === " " || e.key === "Enter") setDown(true); props.onKeyDown?.(e); }} onKeyUp={(e) => { setDown(false); props.onKeyUp?.(e); }} onBlur={(e) => { setDown(false); props.onBlur?.(e); }} /> ); }components/fluid/spring-button/SpringButton.module.css .button { min-height: 48px; padding: 0 22px; border: 0; border-radius: 999px; font: 500 15px/1 var(--fluid-font); letter-spacing: -0.01em; cursor: pointer; user-select: none; touch-action: manipulation; will-change: transform; transition: background-color 200ms ease; } .button[data-variant="solid"] { background: var(--fluid-accent); color: var(--fluid-on-accent); } .button[data-variant="quiet"] { background: color-mix(in srgb, var(--fluid-text) 10%, transparent); color: var(--fluid-text); } @media (hover: hover) { .button[data-variant="quiet"]:hover { background: color-mix(in srgb, var(--fluid-text) 16%, transparent); } } .button:focus-visible { outline: 2px solid var(--fluid-focus); outline-offset: 3px; } .button:disabled { opacity: 0.45; cursor: not-allowed; }Import the theme once, then point its variables at your own tokens.
layout.tsx import "@usefluid/motion/theme.css";
Usage
import { SpringButton } from "./SpringButton";
<SpringButton onClick={save}>Save</SpringButton>
<SpringButton variant="quiet" onClick={cancel}>Cancel</SpringButton>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "solid" | "quiet" | "solid" | Main or secondary action. |
pressed | boolean | — | Show it held down, for showcases. |
...button props | ButtonHTMLAttributes | — | Everything a native button takes: onClick, type, disabled, aria-*. |
Keyboard and screen readers
A plain button with a visible focus ring.
Every colour comes from the --fluid-* variables, and the piece is yours to change.
How it is made
A spring takes the scale from 1 to 0.93 on press and back on release. The speed of that spring squashes the button along the movement, which is why it feels like it has mass. It works with touch, mouse and keyboard.
For your agent
For your agent
Add Spring Button to my project with the Fluid library. Replace the main buttons with SpringButton, keeping the actions they already have. 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.