Morph Button
A button that shrinks into a ball while it sends and opens again with a check.
Preview
"use client";
import { useEffect, useState } from "react";
import { useAutoplay } from "../demo-context";
import { MorphButton, type MorphButtonState } from "./MorphButton";
const NEXT: Record<MorphButtonState, MorphButtonState> = { idle: "loading", loading: "done", done: "idle" };
export default function Demo() {
const [state, setState] = useState<MorphButtonState>("idle");
useAutoplay(() => setState((s) => NEXT[s]), 1500);
const [clicked, setClicked] = useState(0);
useEffect(() => {
if (!clicked) return;
const timer = setTimeout(() => setState("done"), 1600);
return () => clearTimeout(timer);
}, [clicked]);
return (
<MorphButton
state={state}
labels={{ idle: "Send order", done: "Order sent" }}
onClick={() => {
if (state === "loading") return;
if (state === "done") return setState("idle");
setState("loading");
setClicked((c) => c + 1);
}}
/>
);
}Installation
npx @usefluid/cli add morph-buttonpnpm dlx @usefluid/cli add morph-buttonyarn dlx @usefluid/cli add morph-buttonbunx @usefluid/cli add morph-buttonInstall the engine.
Terminal npm i @usefluid/motionCopy these files into your project.
components/fluid/morph-button/MorphButton.tsx "use client"; import { shapes } from "@usefluid/motion"; import { MorphPath, useSpring } from "@usefluid/motion/react"; import { type ButtonHTMLAttributes, useEffect, useLayoutEffect, useRef, useState } from "react"; import styles from "./MorphButton.module.css"; const ARROW = "M14 52h62L54 30l12-12 42 42-42 42-12-12 22-22H14z"; const CHECK = "M18 62L32 48L50 66L88 28L102 42L50 94Z"; const SENDING = [shapes.circle, shapes.square, shapes.triangle, shapes.flower]; const ROUND = 48; export type MorphButtonState = "idle" | "loading" | "done"; type Props = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { state: MorphButtonState; labels?: { idle: string; loading?: string; done: string }; }; export function MorphButton({ state, labels = { idle: "Send", done: "Sent" }, className, onClick, ...props }: Props) { const button = useRef<HTMLButtonElement>(null); const measure = useRef<HTMLSpanElement>(null); const [widths, setWidths] = useState({ idle: 0, done: 0 }); const [tick, setTick] = useState(0); useLayoutEffect(() => { const spans = measure.current?.children; if (!spans) return; setWidths({ idle: (spans[0] as HTMLElement).offsetWidth, done: (spans[1] as HTMLElement).offsetWidth }); }, [labels.idle, labels.done]); useEffect(() => { if (state !== "loading") return; const timer = setInterval(() => setTick((t) => t + 1), 360); return () => clearInterval(timer); }, [state]); const width = state === "loading" ? ROUND : state === "done" ? widths.done : widths.idle; useSpring( { w: width }, ({ w }) => { if (button.current && w > 0) button.current.style.width = `${w}px`; }, { w: 18, z: 0.8, immediate: widths.idle === 0 }, ); const icon = state === "loading" ? SENDING[tick % SENDING.length] : state === "done" ? CHECK : ARROW; const text = state === "loading" ? (labels.loading ?? "Sending") : labels[state]; return ( <> <span ref={measure} className={styles.measure} aria-hidden> <span className={styles.inner}><span className={styles.iconBox} />{labels.idle}</span> <span className={styles.inner}><span className={styles.iconBox} />{labels.done}</span> </span> <button {...props} ref={button} type={props.type ?? "button"} className={[styles.button, className].filter(Boolean).join(" ")} data-state={state} aria-busy={state === "loading"} aria-disabled={state === "loading" || undefined} onClick={(e) => { if (state === "loading") return; onClick?.(e); }} > <span className={styles.inner} aria-hidden> <svg viewBox="0 0 120 120" className={styles.iconBox}> <MorphPath d={icon} fill="currentColor" config={{ w: 20, z: 0.76, points: 96 }} /> </svg> <span className={styles.label}>{text}</span> </span> <span className={styles.srOnly}>{text}</span> </button> <span className={styles.srOnly} role="status">{state === "idle" ? "" : text}</span> </> ); }components/fluid/morph-button/MorphButton.module.css .button { display: inline-flex; align-items: center; justify-content: center; height: 48px; padding: 0; overflow: hidden; border: 0; border-radius: 999px; background: var(--fluid-accent); color: var(--fluid-on-accent); font: 500 15px/1 var(--fluid-font); white-space: nowrap; cursor: pointer; touch-action: manipulation; } .button:focus-visible { outline: 2px solid var(--fluid-focus); outline-offset: 3px; } .inner { display: inline-flex; align-items: center; gap: 8px; padding: 0 20px 0 16px; } .button[data-state="loading"] .inner { padding: 0; gap: 0; } .iconBox { flex: none; width: 16px; height: 16px; } .label { transition: opacity 160ms ease; } .button[data-state="loading"] .label { position: absolute; opacity: 0; width: 1px; overflow: hidden; } .measure { position: absolute; visibility: hidden; pointer-events: none; font: 500 15px/1 var(--fluid-font); white-space: nowrap; } .measure > span { position: absolute; } .srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }Import the theme once, then point its variables at your own tokens.
layout.tsx import "@usefluid/motion/theme.css";
Usage
import { MorphButton, type MorphButtonState } from "./MorphButton";
const [state, setState] = useState<MorphButtonState>("idle");
<MorphButton
state={state}
labels={{ idle: "Place order", loading: "Sending", done: "Order sent" }}
onClick={async () => {
setState("loading");
await placeOrder();
setState("done");
}}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
state | "idle" | "loading" | "done" | — | Drive it from your real request. |
labels | { idle, loading?, done } | Send / Sending / Sent | The words for each state. |
...button props | — | Anything a <button> takes. |
Keyboard and screen readers
aria-busy while loading, and a status region announces Sending and Sent.
Notes
It stays on done until you set state back to idle.
Every colour comes from the --fluid-* variables, and the piece is yours to change.
How it is made
The button's width is a spring that goes from the size of the text down to 48 px and back. Inside, the icon is a contour of the engine: an arrow, shapes while sending, and a check at the end. The state text goes to screen readers.
For your agent
For your agent
Add Morph Button to my project with the Fluid library. Use MorphButton on the form submissions in my project. 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.