Copy Feedback
The copy icon pops into a check mark and the label confirms, then both settle back, so you know the clipboard worked.
bun add gsapCreate a copy-to-clipboard button for a code snippet in React and CSS. On click, call navigator.clipboard.writeText inside a try/catch, then set a "copied" state for 1.6s. Stack a copy icon and a check icon in the same grid cell. In the copied state, the copy icon scales to 0.4 and fades out while the check scales from 0.4 with a slight overshoot (cubic-bezier(0.34, 1.56, 0.64, 1), 260ms) and draws its stroke with stroke-dashoffset. Swap the label from "Copy" to "Copied" and announce it with aria-live="polite". Clear the timeout on unmount. Under prefers-reduced-motion, swap the icons instantly. The snippet reads "bun add gsap".
Source
'use client'
import { useEffect, useRef, useState } from 'react'
import s from './demo.module.css'
export default function CopyFeedback({ labels }: { labels: Record<string, string> }) {
const [copied, setCopied] = useState(false)
const timer = useRef(0)
useEffect(() => () => clearTimeout(timer.current), [])
const copy = async () => {
try {
await navigator.clipboard.writeText(labels.code)
} catch {
// Clipboard can be blocked (iframe, permissions); still show the feedback for the demo.
}
setCopied(true)
clearTimeout(timer.current)
timer.current = window.setTimeout(() => setCopied(false), 1600)
}
return (
<div className={s.snippet}>
<code className={s.code}>{labels.code}</code>
<button type="button" className={s.button} data-copied={copied || undefined} onClick={copy}>
<span className={s.icons} aria-hidden="true">
<svg className={s.copyIcon} viewBox="0 0 24 24">
<rect x="8" y="8" width="12" height="12" rx="2" />
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
</svg>
<svg className={s.checkIcon} viewBox="0 0 24 24">
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
</span>
<span aria-live="polite">{copied ? labels.copied : labels.copy}</span>
</button>
</div>
)
}
.snippet {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 8px 8px 16px;
border: 1px solid var(--stage-rule);
border-radius: var(--radius-sm);
background: var(--stage-surface);
}
.code {
font: 500 var(--text-sm) / 1 var(--font-mono);
color: var(--stage-ink);
white-space: nowrap;
}
.button {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 96px;
height: 34px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: var(--stage-bg);
color: var(--stage-ink);
font: 500 var(--text-sm) / 1 var(--font-body);
white-space: nowrap;
cursor: pointer;
}
.button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.icons { display: grid; width: 16px; height: 16px; }
.icons svg {
grid-area: 1 / 1;
width: 100%;
height: 100%;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 180ms;
}
.checkIcon {
stroke: var(--stage-accent) !important;
stroke-dasharray: 24;
stroke-dashoffset: 24;
transform: scale(0.4);
opacity: 0;
}
.button[data-copied] .copyIcon { transform: scale(0.4); opacity: 0; }
.button[data-copied] .checkIcon {
transform: scale(1);
opacity: 1;
stroke-dashoffset: 0;
transition: transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 180ms, stroke-dashoffset 320ms var(--ease-out) 60ms;
}
@media (prefers-reduced-motion: reduce) {
.icons svg, .button[data-copied] .checkIcon { transition: none; }
}