← All components
Week 3 · 2026-10-07

Morphing Submit Button

A submit button that shrinks into a spinner, then bursts into a self-drawing checkmark with confetti, or shakes red on error.

Get the weekly digest

Hand-picked UI ideas every Friday. No spam, unsubscribe anytime.

Demo outcome

Source

Demo.module.css
.demo {
  --surface: #ffffff;
  --ink: #0b0b0b;
  --ink-2: #52514e;
  --muted: #898781;
  --border: rgba(11, 11, 11, 0.08);
  --field: #f6f5f2;
  --success: #079455;
  --error: #d92d20;

  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  width: 100%;
  max-width: 360px;
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}

@media (prefers-color-scheme: dark) {
  .demo {
    --surface: #18181c;
    --ink: #ffffff;
    --ink-2: #c3c2b7;
    --border: rgba(255, 255, 255, 0.09);
    --field: #222228;
    --success: #32d583;
    --error: #f97066;
  }
}

.card {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
  padding: 30px 24px 20px;
  border: 1px solid var(--border);
  border-radius: 24px;
  background: var(--surface);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 24px 48px -20px rgba(40, 30, 90, 0.35);
  color: var(--ink);
  text-align: center;
}
/* Soft colour wash at the top of the card (no overflow clipping, so confetti can spill out). */
.card::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background:
    radial-gradient(60% 35% at 35% 0%, rgba(139, 125, 255, 0.2), transparent),
    radial-gradient(50% 30% at 75% 0%, rgba(76, 201, 240, 0.16), transparent);
  pointer-events: none;
}
.card > * { position: relative; }

.badge {
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  margin-bottom: 14px;
  border-radius: 14px;
  background: linear-gradient(135deg, #8b7dff, #4cc9f0);
  box-shadow: 0 10px 24px -8px rgba(139, 125, 255, 0.7);
}
.badge svg { width: 24px; height: 24px; fill: none; stroke: #fff; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }

.title { margin: 0; font-size: 19px; font-weight: 700; letter-spacing: -0.01em; }
.subtitle { max-width: 34ch; margin: 6px 0 20px; color: var(--ink-2); font-size: 13.5px; line-height: 1.5; }

.field {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  height: 50px;
  margin-bottom: 14px;
  padding: 0 14px;
  border: 1px solid var(--border);
  border-radius: 14px;
  background: var(--field);
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.field:focus-within { border-color: #8b7dff; box-shadow: 0 0 0 4px rgba(139, 125, 255, 0.18); }
.field svg { flex: none; width: 18px; height: 18px; fill: none; stroke: var(--muted); stroke-width: 1.8; stroke-linecap: round; }
.field input {
  flex: 1;
  min-width: 0;
  height: 100%;
  border: 0;
  outline: none;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 15px;
}
.field input::placeholder { color: var(--muted); }

.message { min-height: 20px; margin: 12px 0 0; font-size: 13px; font-weight: 500; }
.message[data-tone="success"] { color: var(--success); }
.message[data-tone="error"] { color: var(--error); }

/* Demo-only control, kept outside the product card. */
.simulate {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 4px 4px 4px 12px;
  border: 1px dashed color-mix(in srgb, var(--ink) 20%, transparent);
  border-radius: 99px;
  color: var(--muted);
  font-size: 11.5px;
}
.simulate span { margin-right: 6px; }
.simulate button {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 10px;
  border: 0;
  border-radius: 99px;
  background: none;
  color: var(--ink-2);
  font: inherit;
  cursor: pointer;
}
.simulate button::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--success); }
.simulate button[data-tone="error"]::before { background: var(--error); }
.simulate button[aria-checked="true"] { background: var(--surface); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); color: var(--ink); font-weight: 600; }
.simulate button:focus-visible { outline: 2px solid #8b7dff; outline-offset: 1px; }
Demo.tsx
"use client";

import { useEffect, useRef, useState, type FormEvent } from "react";
import MorphButton, { type Status } from "./MorphButton";
import styles from "./Demo.module.css";

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

type Outcome = "success" | "error";

// A newsletter form wired to the button. The switch below decides whether the fake request succeeds.
export default function Demo() {
  const [status, setStatus] = useState<Status>("idle");
  const [outcome, setOutcome] = useState<Outcome>("success");
  const [message, setMessage] = useState<{ tone: Outcome; text: string } | null>(null);
  const mounted = useRef(true);
  useEffect(() => {
    mounted.current = true;
    return () => {
      mounted.current = false;
    };
  }, []);

  async function onSubmit(e: FormEvent) {
    e.preventDefault();
    if (status !== "idle") return;
    setMessage(null);
    setStatus("loading");
    await wait(1500);
    if (!mounted.current) return;
    setStatus(outcome);
    setMessage(
      outcome === "success"
        ? { tone: "success", text: "You're in! Check your inbox to confirm." }
        : { tone: "error", text: "Couldn't subscribe. Please try again." },
    );
    await wait(outcome === "success" ? 2000 : 1500);
    if (!mounted.current) return;
    setStatus("idle");
  }

  return (
    <div className={styles.demo}>
      <form className={styles.card} onSubmit={onSubmit}>
        <div className={styles.badge} aria-hidden="true">
          <svg viewBox="0 0 24 24">
            <rect x="3" y="5" width="18" height="14" rx="2.5" />
            <path d="m3.5 6.5 8.5 6 8.5-6" />
          </svg>
        </div>
        <h3 className={styles.title}>Get the weekly digest</h3>
        <p className={styles.subtitle}>Hand-picked UI ideas every Friday. No spam, unsubscribe anytime.</p>

        <label className={styles.field}>
          <svg viewBox="0 0 24 24" aria-hidden="true">
            <circle cx="12" cy="12" r="4" />
            <path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" />
          </svg>
          <input type="email" placeholder="you@example.com" aria-label="Email address" autoComplete="email" />
        </label>

        <MorphButton
          type="submit"
          block
          status={status}
          label={message?.tone === "error" && status === "idle" ? "Try again" : "Subscribe"}
          messages={{ loading: "Subscribing…", success: "Subscribed!", error: "Something went wrong." }}
        />

        <p className={styles.message} data-tone={message?.tone} aria-live="polite">
          {message?.text ?? ""}
        </p>
      </form>

      <div className={styles.simulate} role="radiogroup" aria-label="Demo outcome">
        <span>Demo outcome</span>
        {(["success", "error"] as const).map((o) => (
          <button key={o} type="button" role="radio" aria-checked={outcome === o} data-tone={o} onClick={() => setOutcome(o)}>
            {o === "success" ? "Success" : "Error"}
          </button>
        ))}
      </div>
    </div>
  );
}
MorphButton.module.css
/* Animatable angle for the rotating gradient border. */
@property --morph-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

.wrap {
  --c1: #8b7dff;
  --c2: #4cc9f0;
  --c3: #f72585;
  --success: #12b76a;
  --error: #f04438;
  --bg: #111116;
  --ease: cubic-bezier(0.65, 0, 0.35, 1);
  --glow: conic-gradient(from var(--morph-angle), var(--c1), var(--c2), var(--c3), var(--c1));

  position: relative;
  display: inline-grid;
  place-items: center;
}
.wrap[data-block="true"] { display: grid; width: 100%; }

.confetti {
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: 2;
  width: 420px;
  height: 340px;
  pointer-events: none;
  transform: translate(-50%, -50%);
}

.button {
  position: relative;
  isolation: isolate;
  display: grid;
  place-items: center;
  width: 210px;
  height: 54px;
  padding: 0;
  border: 0;
  border-radius: 27px;
  background: none;
  color: #fff;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.01em;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: width 0.55s var(--ease), transform 0.2s ease, box-shadow 0.4s ease;
}
.button > * { grid-area: 1 / 1; }
.wrap[data-block="true"] .button { width: 100%; }
.wrap .button:not([data-status="idle"]) { width: 54px; cursor: default; }

.button[data-status="idle"]:hover { transform: translateY(-2px); }
.button[data-status="idle"]:active { transform: translateY(0) scale(0.97); }
.button:focus-visible { outline: 2px solid var(--c2); outline-offset: 4px; }

/* Glow behind, gradient ring, dark surface */
.halo,
.ring,
.surface,
.fill,
.pulse { position: absolute; border-radius: inherit; pointer-events: none; }

.halo {
  inset: 2px;
  z-index: -1;
  background: var(--glow);
  filter: blur(16px);
  opacity: 0.5;
  animation: rotate 4s linear infinite;
  transition: opacity 0.35s ease;
}
.button[data-status="idle"]:hover .halo { opacity: 0.85; }

.ring {
  inset: 0;
  padding: 2px;
  background: var(--glow);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  animation: rotate 4s linear infinite;
  transition: opacity 0.3s ease;
}

.surface {
  inset: 2px;
  background: radial-gradient(120% 140% at 50% 0%, #2c2b3a, var(--bg) 62%);
}

/* While loading, the ring turns into the spinner. */
.button[data-status="loading"] .ring {
  padding: 3px;
  background: conic-gradient(from var(--morph-angle), transparent 0 62%, var(--c1) 80%, var(--c2) 100%);
  animation-duration: 0.9s;
}
.button[data-status="loading"] .halo { opacity: 0.35; animation-duration: 0.9s; }

.button[data-status="success"] .ring,
.button[data-status="error"] .ring,
.button[data-status="success"] .halo,
.button[data-status="error"] .halo { opacity: 0; }

/* Result colour floods out from the centre. */
.fill { inset: 0; clip-path: circle(0% at 50% 50%); transition: clip-path 0.45s var(--ease); }
.fill[data-tone="success"] { background: radial-gradient(circle at 50% 25%, #32d583, var(--success) 70%); }
.fill[data-tone="error"] { background: radial-gradient(circle at 50% 25%, #f97066, var(--error) 70%); }
.button[data-status="success"] .fill[data-tone="success"],
.button[data-status="error"] .fill[data-tone="error"] { clip-path: circle(75% at 50% 50%); }

.button[data-status="success"] { box-shadow: 0 12px 30px -8px color-mix(in srgb, var(--success) 75%, transparent); }
.button[data-status="error"] {
  box-shadow: 0 12px 30px -8px color-mix(in srgb, var(--error) 75%, transparent);
  animation: shake 0.5s 0.3s var(--ease);
}

.pulse { inset: 0; border: 2px solid var(--success); opacity: 0; }
.button[data-status="success"] .pulse { animation: pulse 0.9s 0.25s ease-out; }
.button[data-status="error"] .pulse { border-color: var(--error); animation: pulse 0.9s 0.25s ease-out; }

/* Label and paper plane */
/* Out of the grid flow so the hidden label can't widen the collapsed 54px circle and push the icons off-centre. */
.content {
  position: absolute;
  top: 50%;
  left: 50%;
  display: inline-flex;
  align-items: center;
  gap: 10px;
  white-space: nowrap;
  transform: translate(-50%, -50%);
}
.label { transition: opacity 0.25s ease 0.3s, transform 0.25s ease 0.3s; }
.button:not([data-status="idle"]) .label { opacity: 0; transform: translateX(-10px); transition-delay: 0s; transition-duration: 0.15s; }

.plane {
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  /* Coming back: snap home while invisible, then fade in. */
  transition: transform 0s 0.3s, opacity 0.3s ease 0.35s;
}
.button[data-status="idle"]:hover .plane { transform: translate(3px, -3px) rotate(-6deg); transition: transform 0.3s ease; }
.button:not([data-status="idle"]) .plane {
  opacity: 0;
  transform: translate(70px, -46px) rotate(-18deg) scale(0.6);
  transition: transform 0.5s cubic-bezier(0.5, 0, 0.75, 0), opacity 0.4s ease 0.1s;
}

/* Tick and cross draw themselves in. */
.check,
.cross {
  position: relative;
  width: 26px;
  height: 26px;
  fill: none;
  stroke: #fff;
  stroke-width: 2.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.check path { stroke-dasharray: 24; stroke-dashoffset: 24; transition: stroke-dashoffset 0.45s var(--ease) 0.3s; }
.button[data-status="success"] .check path { stroke-dashoffset: 0; }
.cross path { stroke-dasharray: 30; stroke-dashoffset: 30; transition: stroke-dashoffset 0.35s var(--ease) 0.25s; }
.button[data-status="error"] .cross path { stroke-dashoffset: 0; }
.button:not([data-status="success"]) .check path,
.button:not([data-status="error"]) .cross path { transition-delay: 0s; transition-duration: 0.15s; }

.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

@keyframes rotate { to { --morph-angle: 360deg; } }
@keyframes pulse {
  from { opacity: 0.8; transform: scale(1); }
  to { opacity: 0; transform: scale(2); }
}
@keyframes shake {
  20%, 60% { transform: translateX(-7px); }
  40%, 80% { transform: translateX(7px); }
}

@media (prefers-reduced-motion: reduce) {
  .button,
  .plane,
  .label { transition: none; }
  .halo,
  .ring { animation: none; }
  .button[data-status="loading"] .ring { animation: rotate 2s linear infinite; }
  .button[data-status="error"],
  .pulse { animation: none !important; }
}
MorphButton.tsx
"use client";

import { useEffect, useRef, type ButtonHTMLAttributes } from "react";
import styles from "./MorphButton.module.css";

export type Status = "idle" | "loading" | "success" | "error";

type Props = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
  status: Status;
  label: string;
  /** Stretch to the container's width while idle. */
  block?: boolean;
  /** Read out by screen readers for each state. */
  messages?: Partial<Record<Exclude<Status, "idle">, string>>;
};

const COLORS = ["#8b7dff", "#4cc9f0", "#f72585", "#ffd166", "#06d6a0", "#ffffff"];
const LIFETIME = 110;

type Particle = {
  x: number;
  y: number;
  vx: number;
  vy: number;
  w: number;
  h: number;
  spin: number;
  angle: number;
  flip: number;
  flipSpeed: number;
  color: string;
  round: boolean;
};

function burst(canvas: HTMLCanvasElement) {
  const ctx = canvas.getContext("2d");
  if (!ctx) return () => {};
  const dpr = window.devicePixelRatio || 1;
  const { width, height } = canvas.getBoundingClientRect();
  canvas.width = width * dpr;
  canvas.height = height * dpr;
  ctx.scale(dpr, dpr);

  const particles: Particle[] = Array.from({ length: 110 }, () => {
    // Two fans, up-left and up-right, so the burst frames the button.
    const side = Math.random() < 0.5 ? -1 : 1;
    const angle = -Math.PI / 2 + side * (0.25 + Math.random() * 0.75);
    const speed = 5 + Math.random() * 7;
    return {
      x: width / 2,
      y: height / 2,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      w: 5 + Math.random() * 4,
      h: 8 + Math.random() * 6,
      spin: (Math.random() - 0.5) * 0.3,
      angle: Math.random() * Math.PI,
      flip: Math.random() * Math.PI,
      flipSpeed: 0.15 + Math.random() * 0.2,
      color: COLORS[Math.floor(Math.random() * COLORS.length)],
      round: Math.random() < 0.25,
    };
  });

  let frame = 0;
  let raf = 0;
  const tick = () => {
    frame++;
    ctx.clearRect(0, 0, width, height);
    // Full strength for most of the flight, then fade out.
    ctx.globalAlpha = frame < 75 ? 1 : Math.max(0, 1 - (frame - 75) / (LIFETIME - 75));
    for (const p of particles) {
      p.vy += 0.2;
      p.vx *= 0.97;
      p.vy *= 0.985;
      p.x += p.vx;
      p.y += p.vy;
      p.angle += p.spin;
      p.flip += p.flipSpeed;
      ctx.save();
      ctx.translate(p.x, p.y);
      ctx.rotate(p.angle);
      ctx.fillStyle = p.color;
      if (p.round) {
        ctx.beginPath();
        ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2);
        ctx.fill();
      } else {
        // Squashing the height makes the paper look like it's flipping.
        const h = p.h * Math.abs(Math.cos(p.flip));
        ctx.fillRect(-p.w / 2, -h / 2, p.w, Math.max(1, h));
      }
      ctx.restore();
    }
    if (frame < LIFETIME) raf = requestAnimationFrame(tick);
    else ctx.clearRect(0, 0, width, height);
  };
  raf = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(raf);
}

export default function MorphButton({ status, label, block = false, messages, className, ...rest }: Props) {
  const confetti = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    if (status !== "success" || !confetti.current) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    return burst(confetti.current);
  }, [status]);

  const busy = status !== "idle";
  const announce = status === "idle" ? "" : messages?.[status] ?? "";

  return (
    <span className={styles.wrap} data-block={block}>
      <canvas ref={confetti} className={styles.confetti} aria-hidden="true" />
      <button
        {...rest}
        className={`${styles.button} ${className ?? ""}`}
        data-status={status}
        aria-disabled={busy}
        onClick={busy ? (e) => e.preventDefault() : rest.onClick}
      >
        <span className={styles.halo} aria-hidden="true" />
        <span className={styles.ring} aria-hidden="true" />
        <span className={styles.surface} aria-hidden="true" />
        <span className={styles.fill} data-tone="success" aria-hidden="true" />
        <span className={styles.fill} data-tone="error" aria-hidden="true" />
        <span className={styles.pulse} aria-hidden="true" />
        <span className={styles.content}>
          <span className={styles.label}>{label}</span>
          <svg className={styles.plane} viewBox="0 0 24 24" aria-hidden="true">
            <path d="M21 3 10.5 13.5M21 3l-6.5 18-4-7.5L3 9.5 21 3Z" />
          </svg>
        </span>
        <svg className={styles.check} viewBox="0 0 24 24" aria-hidden="true">
          <path d="M5 12.5 10 17.5 19 7" />
        </svg>
        <svg className={styles.cross} viewBox="0 0 24 24" aria-hidden="true">
          <path d="M7 7l10 10M17 7 7 17" />
        </svg>
      </button>
      <span className={styles.srOnly} aria-live="polite">
        {announce}
      </span>
    </span>
  );
}