Teseria
All elements

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

Terminal
npm i framer-motion lucide-react

2. Files

Button.tsx95 lines
"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.css119 lines
/* 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;
  }
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { Button } from "./Button";
import "./Button.css";

export default function Demo() {
  return <Button />;
}