复制反馈
复制图标弹成一个对勾,文字随之确认,然后两者恢复原样,让你知道剪贴板已经生效。
bun add gsap用 React 和 CSS 为代码片段制作复制到剪贴板的按钮。点击时在 try/catch 中调用 navigator.clipboard.writeText,然后把“已复制”状态保持 1.6s。复制图标和对勾图标叠放在同一个 grid 单元格中。已复制状态下,复制图标缩放到 0.4 并淡出,对勾从 0.4 带一点回弹地放大(cubic-bezier(0.34, 1.56, 0.64, 1),260ms),同时用 stroke-dashoffset 画出描边。按钮文字从“复制”换成“已复制”,并用 aria-live="polite" 播报。组件卸载时清除 timeout。在 prefers-reduced-motion 下直接切换图标。代码片段为“bun add gsap”。
源代码
'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; }
}