Copy Morph Button
A copy button whose icon morphs into a check and says Copied, then resets.
Preview and code
index.html
<button class="cp" type="button" data-text="npm i ownlyplans"><span class="cp__i"><svg class="cp__copy" viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/></svg><svg class="cp__ok" viewBox="0 0 24 24"><path d="m5 12 5 5 9-10"/></svg></span><span class="cp__t" aria-live="polite">Copy</span></button>
styles.css
.cp{display:inline-flex;align-items:center;gap:10px;padding:12px 18px;border:1px solid var(--line);border-radius:12px;background:transparent;color:var(--ink);font:600 15px/1 system-ui,sans-serif;cursor:pointer}
.cp:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
.cp__i{position:relative;width:18px;height:18px}
.cp svg{position:absolute;inset:0;width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;transition:opacity .25s,transform .35s cubic-bezier(.2,.8,.2,1)}
.cp__ok{opacity:0;transform:scale(.4);stroke:#16a34a}
.cp.is-done .cp__copy{opacity:0;transform:scale(.4)}
.cp.is-done .cp__ok{opacity:1;transform:none}
script.js
const c=document.querySelector('.cp');const t=c.querySelector('.cp__t');let timer;
c.addEventListener('click',()=>{try{navigator.clipboard.writeText(c.dataset.text).catch(()=>{})}catch(e){}
c.classList.add('is-done');t.textContent='Copied';clearTimeout(timer);timer=setTimeout(()=>{c.classList.remove('is-done');t.textContent='Copy'},1600)});
copy-morph-button.tsx
"use client";
import { useEffect, useRef, useState } from "react";
const css = `
.cp{display:inline-flex;align-items:center;gap:10px;padding:12px 18px;border:1px solid #d8deeb;border-radius:12px;background:transparent;color:inherit;font:600 15px/1 system-ui,sans-serif;cursor:pointer}
.cp:focus-visible{outline:2px solid #1659f5;outline-offset:3px}
.cp__i{position:relative;width:18px;height:18px}
.cp svg{position:absolute;inset:0;width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;transition:opacity .25s,transform .35s cubic-bezier(.2,.8,.2,1)}
.cp__ok{opacity:0;transform:scale(.4);stroke:#16a34a}
.cp.is-done .cp__copy{opacity:0;transform:scale(.4)}
.cp.is-done .cp__ok{opacity:1;transform:none}`;
export function CopyMorphButton({ text, label = "Copy", done = "Copied" }: { text: string; label?: string; done?: string }) {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const copy = async () => {
try { await navigator.clipboard.writeText(text); } catch {}
setCopied(true);
clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 1600);
};
return (
<>
<style>{css}</style>
<button type="button" className={`cp${copied ? " is-done" : ""}`} onClick={copy}>
<span className="cp__i" aria-hidden="true">
<svg className="cp__copy" viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V6a2 2 0 0 1 2-2h9" /></svg>
<svg className="cp__ok" viewBox="0 0 24 24"><path d="m5 12 5 5 9-10" /></svg>
</span>
<span aria-live="polite">{copied ? done : label}</span>
</button>
</>
);
}
export default CopyMorphButton;
Install
npx shadcn@latest add https://ownlyplans.com/r/bhaskar/copy-morph-button.json
Adds the React files to components/ownlyplans/ in a project set up with shadcn and Tailwind CSS. Or copy them from the Code tab.
About
The two icons cross-fade with a small scale so the change is clear without a toast. The label update is announced to screen readers.