Copy Morph
A copy button where the two sheets flow into a check.
Preview
"use client";
import { useState } from "react";
import { useAutoplay } from "../demo-context";
import { CopyMorph } from "./CopyMorph";
export default function Demo() {
const [done, setDone] = useState<boolean | undefined>(undefined);
useAutoplay(() => setDone((d) => !d), 1400);
return (
<span style={{ display: "contents" }} onClickCapture={() => setDone(undefined)}>
<CopyMorph text="npm i @usefluid/motion" done={done} />
</span>
);
}Installation
npx @usefluid/cli add copy-morphpnpm dlx @usefluid/cli add copy-morphyarn dlx @usefluid/cli add copy-morphbunx @usefluid/cli add copy-morphInstall the engine.
Terminal npm i @usefluid/motionCopy these files into your project.
components/fluid/copy-morph/CopyMorph.tsx "use client"; import { MorphPath, useSpring } from "@usefluid/motion/react"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import styles from "./CopyMorph.module.css"; const COPY = "M44 14h48a14 14 0 0 1 14 14v48H92V28H44z M28 40h44a14 14 0 0 1 14 14v44a14 14 0 0 1-14 14H28a14 14 0 0 1-14-14V54a14 14 0 0 1 14-14z"; const CHECK = "M14 62L30 46L50 66L90 26L106 42L50 98Z"; async function copy(text: string) { try { await navigator.clipboard.writeText(text); return true; } catch { const area = document.createElement("textarea"); area.value = text; area.setAttribute("readonly", ""); area.style.cssText = "position:fixed;opacity:0;pointer-events:none"; document.body.append(area); area.select(); const ok = document.execCommand("copy"); area.remove(); return ok; } } type Props = { text: string; label?: string; doneLabel?: string; done?: boolean; }; export function CopyMorph({ text, label = "Copy", doneLabel = "Copied", done: forced }: Props) { const [copied, setCopied] = useState(false); const done = forced ?? copied; const button = useRef<HTMLButtonElement>(null); const measure = useRef<HTMLSpanElement>(null); const [widths, setWidths] = useState({ idle: 0, done: 0 }); useLayoutEffect(() => { const spans = measure.current?.children; if (!spans) return; setWidths({ idle: (spans[0] as HTMLElement).offsetWidth, done: (spans[1] as HTMLElement).offsetWidth }); }, [label, doneLabel]); useEffect(() => { if (!copied) return; const timer = setTimeout(() => setCopied(false), 1800); return () => clearTimeout(timer); }, [copied]); useSpring( { w: done ? widths.done : widths.idle }, ({ w }) => { if (button.current && w > 0) button.current.style.width = `${w}px`; }, { w: 18, z: 0.8, immediate: widths.idle === 0 }, ); const content = (word: string) => ( <> <span className={styles.icon} /> {word} </> ); return ( <> <span ref={measure} className={styles.measure} aria-hidden> <span className={styles.inner}>{content(label)}</span> <span className={styles.inner}>{content(doneLabel)}</span> </span> <button ref={button} type="button" className={styles.button} data-done={done} onClick={async () => { if (await copy(text)) setCopied(true); }} > <span className={styles.inner}> <svg viewBox="0 0 120 120" aria-hidden className={styles.icon}> <MorphPath d={done ? CHECK : COPY} fill="currentColor" config={{ w: 20, z: 0.76, points: 96 }} /> </svg> <span aria-live="polite">{done ? doneLabel : label}</span> </span> </button> </> ); }components/fluid/copy-morph/CopyMorph.module.css .button { display: inline-flex; align-items: 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: 10px; padding: 0 22px 0 18px; } .icon { display: inline-block; flex: none; width: 18px; height: 18px; } .measure { position: absolute; visibility: hidden; pointer-events: none; font: 500 15px/1 var(--fluid-font); white-space: nowrap; } .measure > span { position: absolute; }Import the theme once, then point its variables at your own tokens.
layout.tsx import "@usefluid/motion/theme.css";
Usage
import { CopyMorph } from "./CopyMorph";
<CopyMorph text="npm i @usefluid/motion" />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | — | What goes to the clipboard. |
label | string | "Copy" | Accessible name. |
doneLabel | string | "Copied" | Announced after copying. |
done | boolean | — | Force the copied state, for showcases. |
Keyboard and screen readers
A real button; the result is announced.
Notes
Needs a secure context (https or localhost) for the clipboard.
Every colour comes from the --fluid-* variables, and the piece is yours to change.
How it is made
The icon is a contour of the engine: the two sheets become a check, one of them shrinking away. The pill's width follows the new word on a spring. The word is announced to screen readers, and after a moment everything goes back.
For your agent
For your agent
Add Copy Morph to my project with the Fluid library. Use CopyMorph wherever people copy codes, links or keys in my product. 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.