Icon Morph
Icons that flow into each other: menu and close, play and pause.
Preview
"use client";
import { useState } from "react";
import { useAutoplay } from "../demo-context";
import { IconMorph, icons } from "./IconMorph";
const pairs = [
{ off: icons.menu, on: icons.close, label: "Menu" },
{ off: icons.play, on: icons.pause, label: "Play" },
{ off: icons.plus, on: icons.check, label: "Add" },
];
export default function Demo() {
const [state, setState] = useState([false, false, false]);
const [turn, setTurn] = useState(0);
useAutoplay(() => {
setState((s) => s.map((v, i) => (i === turn % 3 ? !v : v)));
setTurn((t) => t + 1);
}, 700);
return (
<div style={{ display: "flex", gap: 12 }}>
{pairs.map((p, i) => (
<IconMorph
key={p.label}
on={state[i]}
off={p.off}
onIcon={p.on}
label={p.label}
onClick={() => setState((s) => s.map((v, j) => (j === i ? !v : v)))}
/>
))}
</div>
);
}Installation
npx @usefluid/cli add icon-morphpnpm dlx @usefluid/cli add icon-morphyarn dlx @usefluid/cli add icon-morphbunx @usefluid/cli add icon-morphInstall the engine.
Terminal npm i @usefluid/motionCopy these files into your project.
components/fluid/icon-morph/IconMorph.tsx "use client"; import { MorphPath } from "@usefluid/motion/react"; import type { ButtonHTMLAttributes } from "react"; import styles from "./IconMorph.module.css"; export const icons = { menu: "M20 28h80v14H20zM20 53h80v14H20zM20 78h80v14H20z", close: "M31 21L60 50L89 21L99 31L70 60L99 89L89 99L60 70L31 99L21 89L50 60L21 31Z", play: "M36 20L100 60L36 100Z", pause: "M28 22h24v76H28zM68 22h24v76H68z", plus: "M52 20h16v32h32v16H68v32H52V68H20V52h32z", check: "M18 62L32 48L50 66L88 28L102 42L50 94Z", }; type Props = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { on: boolean; off: string; onIcon: string; label: string; }; export function IconMorph({ on, off, onIcon, label, className, ...props }: Props) { return ( <button type="button" aria-pressed={on} aria-label={label} className={[styles.button, className].filter(Boolean).join(" ")} {...props} > <svg viewBox="0 0 120 120" aria-hidden className={styles.icon}> <MorphPath d={on ? onIcon : off} fill="currentColor" config={{ w: 20, z: 0.76, points: 96 }} /> </svg> </button> ); }components/fluid/icon-morph/IconMorph.module.css .button { display: grid; place-items: center; width: 56px; height: 56px; border: 0; border-radius: 999px; background: var(--fluid-accent); color: var(--fluid-on-accent); cursor: pointer; touch-action: manipulation; } .button:focus-visible { outline: 2px solid var(--fluid-focus); outline-offset: 3px; } .icon { width: 24px; height: 24px; }Import the theme once, then point its variables at your own tokens.
layout.tsx import "@usefluid/motion/theme.css";
Usage
import { IconMorph, icons } from "./IconMorph";
<IconMorph on={open} off={icons.menu} onIcon={icons.close} label="Menu" onClick={() => setOpen(!open)} />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
on | boolean | — | Which icon is showing. |
off | string | — | Path shown when on is false. Ready ones: icons.menu, play, plus. |
onIcon | string | — | Path shown when on is true. Ready ones: icons.close, pause, check. |
label | string | — | Accessible name of the button. |
...button props | — | Anything a <button> takes, like onClick. |
Keyboard and screen readers
A real button with aria-pressed. Draw your own icons in a 120 x 120 box, filled, not stroked.
Notes
For a menu that also opens, use MenuMorph.
Every colour comes from the --fluid-* variables, and the piece is yours to change.
How it is made
The same engine as the logo, in a 56 px button. The three menu bars become the X: one bar flows into the new shape and the other two shrink into a point. The button reports its state with aria-pressed.
For your agent
For your agent
Add Icon Morph to my project with the Fluid library. Replace the open and close, play and pause icons in my project with IconMorph. 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.