09Button that says copied for a moment
A press hands the text to the clipboard and the label cuts to copied for a moment before returning, and because that moment must not be visual only, the same words also go to a live region.
Best forCopy buttons beside code blocks, invite links, and coupon codes
navigator.clipboardwriteTextaria-live

<main class="stage">
<div class="cp is-demo">
<div class="cp__row">
<code class="cp__code" id="cp-src">npm i godrich-ui</code>
<button class="cp__btn" id="cp-btn" type="button" aria-label="Copy the command to the clipboard">
<span class="cp__ring" aria-hidden="true"></span>
<span class="cp__face" data-face="idle">
<span class="cp__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg></span>
<span class="cp__t">Copy</span>
</span>
<span class="cp__face" data-face="done">
<span class="cp__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<span class="cp__t">Copied</span>
</span>
</button>
</div>
<p class="cp__live" role="status" aria-live="polite">
<span class="cp__word">Copied the command</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-mint;
@include stage($stage-ink);
.cp {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cp__row {
display: flex;
align-items: stretch;
gap: $sp-2;
}
.cp__code {
box-sizing: border-box;
display: flex;
align-items: center;
padding: 0 $sp-3;
height: 44px;
border-radius: $r-sm;
background-color: $subject-cream;
color: $subject-ink;
font-family: "Space Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.cp__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 92px;
height: 44px;
overflow: visible;
border: 0;
border-radius: $r-control;
background-color: $subject-cream;
color: $subject-ink;
cursor: pointer;
}
.cp__btn:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
// 복사된 순간에만 한 번 퍼지는 테두리 — 되돌아올 때는 없으므로 대표 프레임이 '복사됨' 쪽으로 잡힌다
.cp__ring {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
border: 3px solid $color;
border-radius: $r-control;
opacity: 0;
}
.cp__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
opacity: 0;
}
.cp__ic { display: inline-flex; flex: 0 0 auto; }
.cp__ic svg { display: block; }
.cp__t {
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
// 사람이 실제로 누른 경로
.cp:not(.is-demo) .cp__face[data-face="idle"] { opacity: 1; }
.cp:not(.is-demo)[data-copied="1"] .cp__face[data-face="idle"] { opacity: 0; }
.cp:not(.is-demo)[data-copied="1"] .cp__face[data-face="done"] { opacity: 1; }
.cp:not(.is-demo)[data-copied="1"] .cp__btn { background-color: $color; }
.cp__live {
margin: 0;
height: 18px;
}
.cp__word {
display: block;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: $subject-cream;
opacity: 0;
}
.cp:not(.is-demo)[data-copied="1"] .cp__word { opacity: 1; }
// ── 자동 루프: 35% 에 복사로 끊고, 되돌림은 76%(아이콘)·84%(색·라벨) 로 나눠 한 번에 커지지 않게 한다 ──
.cp.is-demo .cp__face {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cp.is-demo .cp__face[data-face="idle"] { animation-name: cp-idle; }
.cp.is-demo .cp__face[data-face="done"] { animation-name: cp-done; }
@keyframes cp-idle {
0% { opacity: 1; }
35% { opacity: 0; }
76% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes cp-done {
0% { opacity: 0; }
35% { opacity: 1; }
76% { opacity: 0; }
100% { opacity: 0; }
}
.cp.is-demo .cp__btn {
animation-name: cp-skin, cp-tap;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
@keyframes cp-skin {
0% { background-color: $subject-cream; }
35% { background-color: $color; }
84% { background-color: $subject-cream; }
100% { background-color: $subject-cream; }
}
@keyframes cp-tap {
0% { transform: scale(1); }
30% { transform: scale(1); }
35% { transform: scale(.92); }
44% { transform: scale(1.04); }
54% { transform: scale(1); }
100% { transform: scale(1); }
}
.cp.is-demo .cp__ring {
animation-name: cp-ring;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cp-ring {
0% { opacity: 0; transform: scale(1); }
35% { opacity: 1; transform: scale(1); }
52% { opacity: 0; transform: scale(1.4); }
100% { opacity: 0; transform: scale(1.4); }
}
.cp.is-demo .cp__word {
animation-name: cp-say;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cp-say {
0% { opacity: 0; }
35% { opacity: 1; }
84% { opacity: 0; }
100% { opacity: 0; }
}
@media (max-width: 340px) {
.cp { gap: $sp-2; }
.cp__row { gap: $sp-1; }
.cp__code { font-size: 11px; line-height: 16px; padding: 0 $sp-2; }
.cp__btn { width: 76px; }
.cp__t { font-size: 11px; line-height: 16px; }
.cp__word { font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.cp.is-demo .cp__face,
.cp.is-demo .cp__btn,
.cp.is-demo .cp__ring,
.cp.is-demo .cp__word { animation: none !important; }
.cp.is-demo .cp__face[data-face="idle"] { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cp {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cp__row {
display: flex;
align-items: stretch;
gap: 8px;
}
.cp__code {
box-sizing: border-box;
display: flex;
align-items: center;
padding: 0 12px;
height: 44px;
border-radius: 8px;
background-color: #fff7e6;
color: #17141a;
font-family: "Space Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.cp__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 92px;
height: 44px;
overflow: visible;
border: 0;
border-radius: 12px;
background-color: #fff7e6;
color: #17141a;
cursor: pointer;
}
.cp__btn:focus-visible {
outline: 3px solid #4cd4a6;
outline-offset: 3px;
}
.cp__ring {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
border: 3px solid #4cd4a6;
border-radius: 12px;
opacity: 0;
}
.cp__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
}
.cp__ic {
display: inline-flex;
flex: 0 0 auto;
}
.cp__ic svg {
display: block;
}
.cp__t {
font-size: 13px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.cp:not(.is-demo) .cp__face[data-face=idle] {
opacity: 1;
}
.cp:not(.is-demo)[data-copied="1"] .cp__face[data-face=idle] {
opacity: 0;
}
.cp:not(.is-demo)[data-copied="1"] .cp__face[data-face=done] {
opacity: 1;
}
.cp:not(.is-demo)[data-copied="1"] .cp__btn {
background-color: #4cd4a6;
}
.cp__live {
margin: 0;
height: 18px;
}
.cp__word {
display: block;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: #fff7e6;
opacity: 0;
}
.cp:not(.is-demo)[data-copied="1"] .cp__word {
opacity: 1;
}
.cp.is-demo .cp__face {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cp.is-demo .cp__face[data-face=idle] {
animation-name: cp-idle;
}
.cp.is-demo .cp__face[data-face=done] {
animation-name: cp-done;
}
@keyframes cp-idle {
0% {
opacity: 1;
}
35% {
opacity: 0;
}
76% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes cp-done {
0% {
opacity: 0;
}
35% {
opacity: 1;
}
76% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.cp.is-demo .cp__btn {
animation-name: cp-skin, cp-tap;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes cp-skin {
0% {
background-color: #fff7e6;
}
35% {
background-color: #4cd4a6;
}
84% {
background-color: #fff7e6;
}
100% {
background-color: #fff7e6;
}
}
@keyframes cp-tap {
0% {
transform: scale(1);
}
30% {
transform: scale(1);
}
35% {
transform: scale(0.92);
}
44% {
transform: scale(1.04);
}
54% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
.cp.is-demo .cp__ring {
animation-name: cp-ring;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes cp-ring {
0% {
opacity: 0;
transform: scale(1);
}
35% {
opacity: 1;
transform: scale(1);
}
52% {
opacity: 0;
transform: scale(1.4);
}
100% {
opacity: 0;
transform: scale(1.4);
}
}
.cp.is-demo .cp__word {
animation-name: cp-say;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cp-say {
0% {
opacity: 0;
}
35% {
opacity: 1;
}
84% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.cp {
gap: 8px;
}
.cp__row {
gap: 4px;
}
.cp__code {
font-size: 11px;
line-height: 16px;
padding: 0 8px;
}
.cp__btn {
width: 76px;
}
.cp__t {
font-size: 11px;
line-height: 16px;
}
.cp__word {
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.cp.is-demo .cp__face,
.cp.is-demo .cp__btn,
.cp.is-demo .cp__ring,
.cp.is-demo .cp__word {
animation: none !important;
}
.cp.is-demo .cp__face[data-face=idle] {
opacity: 1;
}
}// 실제 동작: 클립보드로 실제로 넘긴 뒤에만 라벨을 바꾼다. 같은 말이 읽히는 영역으로도 한 번 나간다
var root = document.querySelector('.cp');
var btn = document.getElementById('cp-btn');
var src = document.getElementById('cp-src');
var resetTimer = 0;
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var finish = function () {
root.setAttribute('data-copied', '1');
clearTimeout(resetTimer);
resetTimer = setTimeout(function () { root.removeAttribute('data-copied'); }, 1600);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(src.textContent).then(finish, finish);
} else {
finish();
}
});import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CopyFeedbackButton.module.scss";
export type CopyFeedbackButtonProps = {
code?: string;
idleLabel?: string;
doneLabel?: string;
liveMessage?: string;
buttonLabel?: string;
holdMs?: number;
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
const COPY_ICON = (
<g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2" /><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" /></g>
);
const DONE_ICON = (
<g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" /><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" /></g>
);
export default function CopyFeedbackButton({
code = "npm i godrich-ui",
idleLabel = "Copy",
doneLabel = "Copied",
liveMessage = "Command copied",
buttonLabel = "Copy the command to the clipboard",
holdMs = 1600,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
}: CopyFeedbackButtonProps) {
const [demo, setDemo] = useState(true); // 처음엔 자동 루프, 사람이 건드리면 끈다
const [copied, setCopied] = useState(false);
const timer = useRef<number | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
useEffect(() => () => {
if (timer.current !== null) window.clearTimeout(timer.current);
}, []);
// 클립보드로 실제로 넘긴 뒤에만 라벨을 바꾼다. 실패해도 같은 길로 되돌아온다
const copy = useCallback(() => {
setDemo(false);
const finish = () => {
setCopied(true);
if (timer.current !== null) window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), holdMs);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(code).then(finish, finish);
} else {
finish();
}
}, [code, holdMs]);
return (
<div
className={`${styles.cp} ${demo ? styles.isDemo : ""}`}
style={vars}
data-copied={copied ? "1" : "0"}
>
<div className={styles.row}>
<code className={styles.code}>{code}</code>
<button className={styles.btn} type="button" aria-label={buttonLabel} onClick={copy}>
<span className={styles.ring} aria-hidden="true" />
<span className={styles.face} data-face="idle">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18">{COPY_ICON}</svg>
</span>
<span className={styles.t}>{idleLabel}</span>
</span>
<span className={styles.face} data-face="done">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18">{DONE_ICON}</svg>
</span>
<span className={styles.t}>{doneLabel}</span>
</span>
</button>
</div>
{/* 같은 말이 읽히는 영역으로도 한 번 나간다 */}
<p className={styles.live} role="status" aria-live="polite">
<span className={styles.word}>{liveMessage}</span>
</p>
</div>
);
}09 Button that says copied for a moment — Measured area 4.763% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · 9 button state css patterns, loading to done
The post this demo lives in
9 button state css patterns, loading to doneA button state css pattern decides what a button looks like between the press and the result. All nine here actually loc See all demos in this post
If you like this effect - similar demos
Buttons08 Help button that opens a chat launcher9 Icon Button CSS Patterns — Press, Drag, Expand
Buttons06 Drag-to-confirm — a button that reaches a confirmed state once a dragged position value passes a thresholdCTA Button Design: 9 Interaction Patterns
Buttons02 Crack-line Button That Splits With a Press-state ValueButton Click Effect CSS: 9 Canvas Pixel Patterns