Morph Loader
A loader that keeps changing shape and ends on a check.
Preview
"use client";
import { useEffect, useState } from "react";
import { useAutoplay, useDemo } from "../demo-context";
import { MorphLoader } from "./MorphLoader";
export default function Demo() {
const [done, setDone] = useState(false);
const { auto } = useDemo();
useAutoplay(() => setDone((d) => !d), 2200);
useEffect(() => {
if (!auto) setDone(true);
}, [auto]);
return (
<button
type="button"
onClick={() => setDone((d) => !d)}
aria-label={done ? "Load again" : "Finish"}
style={{ border: 0, background: "none", padding: 8, borderRadius: 16, cursor: "pointer", color: "inherit" }}
>
<MorphLoader done={done} />
</button>
);
}Installation
npx @usefluid/cli add morph-loaderpnpm dlx @usefluid/cli add morph-loaderyarn dlx @usefluid/cli add morph-loaderbunx @usefluid/cli add morph-loaderInstall the engine.
Terminal npm i @usefluid/motionCopy these files into your project.
components/fluid/morph-loader/MorphLoader.tsx "use client"; import { shapes } from "@usefluid/motion"; import { MorphPath, useReducedMotion } from "@usefluid/motion/react"; import { useEffect, useState } from "react"; import styles from "./MorphLoader.module.css"; const ROUTE = [shapes.circle, shapes.square, shapes.triangle, shapes.flower, shapes.capsule, shapes.drop]; const CHECK = "M18 62L32 48L50 66L88 28L102 42L50 94Z"; type Props = { done: boolean; label?: string; doneLabel?: string }; export function MorphLoader({ done, label = "Loading", doneLabel = "Done" }: Props) { const reduced = useReducedMotion(); const [i, setI] = useState(0); useEffect(() => { if (done || reduced) return; const timer = setInterval(() => setI((n) => n + 1), 420); return () => clearInterval(timer); }, [done, reduced]); return ( <span className={styles.root} role="status"> <svg viewBox="-6 -6 132 132" aria-hidden className={styles.shape} data-done={done}> <MorphPath d={done ? CHECK : ROUTE[i % ROUTE.length]} fill="currentColor" config={{ w: 16, z: 0.72, points: 120 }} /> </svg> <span className={styles.srOnly}>{done ? doneLabel : label}</span> </span> ); }components/fluid/morph-loader/MorphLoader.module.css .root { display: inline-grid; place-items: center; } .shape { width: 72px; height: 72px; color: var(--fluid-accent); animation: turn 2.4s linear infinite; } .shape[data-done="true"] { animation: none; } @keyframes turn { to { transform: rotate(360deg); } } .srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } @media (prefers-reduced-motion: reduce) { .shape { animation: none; } }Import the theme once, then point its variables at your own tokens.
layout.tsx import "@usefluid/motion/theme.css";
Usage
import { MorphLoader } from "./MorphLoader";
<MorphLoader done={!loading} label="Loading orders" doneLabel="Orders loaded" />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
done | boolean | — | False while loading; true lands on the check. |
label | string | "Loading" | Announced while loading. |
doneLabel | string | "Done" | Announced when done. |
Keyboard and screen readers
A status region. Reduced motion shows a still shape.
Notes
It loops only while done is false, which is the one honest place for a loop.
Every colour comes from the --fluid-* variables, and the piece is yours to change.
How it is made
While loading, the same contour goes through several shapes with the morph engine. When it is done, it flows into a check. The change is announced through a status region, without depending on the animation.
For your agent
For your agent
Add Morph Loader to my project with the Fluid library. Use MorphLoader on loads that take longer than a second. 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.