Button
Press shrinks it, the label swaps with a blur, and the width follows the label on a snap spring. One node runs idle → loading → done (spinner, then a check on a green fill), driven by the `state` prop.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"use client";
// Button: press shrinks it, the label swaps with a blur, and the width follows the label on a snap spring.
// One node runs idle → loading → done (spinner, then a check on a green fill), driven by the `state` prop.
import { forwardRef, useEffect, useRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Check } from "lucide-react";
export type ButtonCue = "push" | "done";
export type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
/** Look of the button. */
variant?: "solid" | "line" | "ghost" | "danger";
/** Height and text size. */
size?: "sm" | "md" | "lg";
/** Icon before the label, about 1.15em. */
icon?: ReactNode;
/** Icon after the label. */
iconRight?: ReactNode;
/** idle → loading → done, all inside one node. */
state?: "idle" | "loading" | "done";
/** Label shown in the done state. */
doneLabel?: ReactNode;
/** Label shown while loading. Falls back to the normal label. */
loadingLabel?: ReactNode;
/** Skip the click cue. */
silent?: boolean;
/** Sound hook: "push" on press, "done" when `state` goes from loading to done. */
onSound?: (cue: ButtonCue) => void;
children?: ReactNode;
};
const LEAVE = [0.4, 0, 1, 1] as const;
const SNAP = { type: "spring", stiffness: 380, damping: 34, mass: 0.8 } as const;
const BOUNCE = { type: "spring", stiffness: 500, damping: 18, mass: 0.7 } as const;
const join = (...parts: (string | false | null | undefined)[]) => parts.filter(Boolean).join(" ");
/** An icon bubble that scales in and out beside the label. */
function Slot({ children }: { id: string; children: ReactNode }) {
return (
<motion.span className="btn-ico" aria-hidden initial={{ opacity: 0, scale: 0.6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.6 }} transition={{ duration: 0.16 }}>
{children}
</motion.span>
);
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = "solid", size = "md", icon, iconRight, state = "idle", doneLabel = "Done", loadingLabel, silent, onSound, className, children, onClick, disabled, ...rest },
ref,
) {
const loading = state === "loading";
const idle = state === "idle";
const text = state === "done" ? doneLabel : loading && loadingLabel ? loadingLabel : children;
const textKey = loading && !loadingLabel ? "idle" : state;
const was = useRef(state);
useEffect(() => {
if (state === "done" && was.current === "loading") onSound?.("done");
was.current = state;
}, [state, onSound]);
const press: ButtonHTMLAttributes<HTMLButtonElement>["onClick"] = (e) => {
if (!silent) onSound?.("push");
onClick?.(e);
};
return (
<motion.button
ref={ref}
type="button"
layout
transition={SNAP}
className={join("btn-root", className)}
data-variant={variant}
data-size={size}
data-state={state}
disabled={disabled || loading}
aria-busy={loading || undefined}
onClick={press}
{...(rest as object)}
>
<AnimatePresence mode="popLayout" initial={false}>
{loading && <motion.span key="spin" className="btn-spin" aria-hidden initial={{ opacity: 0, scale: 0.5 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.5, transition: { duration: 0.14, ease: LEAVE } }} transition={{ ...BOUNCE, mass: 0.5 }} />}
{state === "done" && (
<motion.span key="done" className="btn-ico" aria-hidden initial={{ opacity: 0, scale: 0.4, rotate: -30 }} animate={{ opacity: 1, scale: 1, rotate: 0 }} exit={{ opacity: 0, scale: 0.4, transition: { duration: 0.14, ease: LEAVE } }} transition={BOUNCE}>
<Check strokeWidth={2.4} />
</motion.span>
)}
{idle && icon && <Slot key="icon" id="icon">{icon}</Slot>}
{text != null && text !== "" && (
<motion.span key={`l-${textKey}`} className="btn-label" initial={{ opacity: 0, y: 6, filter: "blur(3px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } }} exit={{ opacity: 0, y: -6, filter: "blur(3px)" }} transition={{ duration: 0.18 }}>
{text}
</motion.span>
)}
{idle && iconRight && <Slot key="iconR" id="iconR">{iconRight}</Slot>}
</AnimatePresence>
</motion.button>
);
});
/* Button. Prefix btn-. */
.btn-root, .btn-root * { box-sizing: border-box; }
.btn-root {
--btn-ease: cubic-bezier(0.22, 1, 0.36, 1);
--btn-h: 40px;
--btn-px: 16px;
--btn-fs: 14px;
position: relative;
height: var(--btn-h);
padding: 0 var(--btn-px);
border: 0;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font: inherit;
font-family: var(--ts-font, system-ui, sans-serif);
font-size: var(--btn-fs);
font-weight: 500;
letter-spacing: -0.01em;
white-space: nowrap;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
background: var(--ts-fg);
color: var(--ts-raised);
transition:
scale 0.22s var(--btn-ease),
background 0.16s var(--btn-ease),
color 0.16s var(--btn-ease),
box-shadow 0.16s var(--btn-ease),
opacity 0.16s var(--btn-ease);
}
.btn-root[data-size="sm"] {
--btn-h: 32px;
--btn-px: 12px;
--btn-fs: 13px;
gap: 6px;
}
.btn-root[data-size="lg"] {
--btn-h: 48px;
--btn-px: 20px;
--btn-fs: 15px;
}
.btn-root:active:not(:disabled) {
scale: 0.96;
}
.btn-root:disabled {
cursor: default;
}
.btn-root:disabled:not([aria-busy]) {
opacity: 0.45;
}
.btn-root[data-variant="line"] {
background: var(--ts-raised);
color: var(--ts-fg);
box-shadow: var(--ts-edge);
}
.btn-root[data-variant="line"]:hover:not(:disabled) {
background: color-mix(in srgb, var(--ts-fg) 5%, var(--ts-raised));
}
.btn-root[data-variant="ghost"] {
background: transparent;
color: color-mix(in srgb, var(--ts-fg) 75%, var(--ts-raised));
}
.btn-root[data-variant="ghost"]:hover:not(:disabled) {
background: rgba(var(--ts-fg-rgb), 0.06);
color: var(--ts-fg);
}
.btn-root[data-variant="danger"] {
background: var(--ts-danger);
color: var(--ts-fg-inverse);
}
.btn-root[data-variant="solid"]:hover:not(:disabled) {
opacity: 0.92;
}
.btn-root[data-state="done"] {
background: var(--ts-success);
color: var(--ts-fg-inverse);
}
.btn-root:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px var(--ts-page), 0 0 0 4px var(--ts-accent);
}
.btn-label {
display: inline-block;
}
.btn-ico {
display: grid;
place-items: center;
width: 1.15em;
height: 1.15em;
}
.btn-ico svg {
width: 100%;
height: 100%;
}
.btn-spin {
width: 1em;
height: 1em;
border-radius: 50%;
border: 2px solid currentColor;
border-right-color: transparent;
animation: btn-spin 0.7s linear infinite;
}
@keyframes btn-spin {
to {
rotate: 360deg;
}
}
@media (prefers-reduced-motion: reduce) {
.btn-spin {
animation-duration: 1.4s;
}
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { Button } from "./Button";
import "./Button.css";
export default function Demo() {
return <Button />;
}