01Copy button that says it copied
Pressing the button at the top right of the code pane hands the text to navigator.clipboard, and the label swaps in a single frame. The new label is mirrored into an aria-live region, so screen reader users learn the copy happened too.
Best forInstall commands and config docs meant to be pasted and run
navigator.clipboardaria-livesteps(1, end)

<main class="stage">
<div class="ccb is-demo" id="root">
<div class="pane">
<div class="pane__head">
<span class="pane__lang">CSS</span>
<div class="pane__actions">
<span class="pane__chip" id="chip" aria-hidden="true">Saved to clipboard</span>
<button class="pane__copy" id="copyBtn" type="button" aria-label="Copy code">
<span class="copy__state copy__state--idle" id="stIdle">
<svg class="ic" viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><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 id="labelIdle">Copy</span>
</span>
<span class="copy__state copy__state--done" id="stDone">
<svg class="ic" viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span>Copied</span>
</span>
</button>
</div>
</div>
<div class="pane__body">
<span class="pane__sel" id="sel" aria-hidden="true"></span>
<pre id="codeText"><code><span class="k">.btn</span> {
<span class="k">display</span>: <span class="s">inline-flex</span>;
<span class="k">align-items</span>: <span class="s">center</span>;
<span class="k">gap</span>: <span class="n">8px</span>;
<span class="k">padding</span>: <span class="n">8px</span> <span class="n">16px</span>;
<span class="k">border-radius</span>: <span class="n">12px</span>;
}</code></pre>
</div>
</div>
<span class="ccb__cursor" id="cursor" aria-hidden="true"></span>
<span class="sr" id="live" role="status" aria-live="polite"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.ccb {
position: relative;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
color: $subject-ink;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
height: 28px;
padding: 0 $sp-3;
background: rgba(23, 20, 26, .06);
border-bottom: 1px solid rgba(23, 20, 26, .12);
}
.pane__lang {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: .02em;
color: rgba($subject-ink, .72);
text-transform: uppercase;
}
.pane__actions {
display: flex;
align-items: center;
gap: $sp-1;
}
// 칩 — 버튼처럼 안 보이게 배경·알약을 떼고 흐린 글자만 (자리는 opacity·transform 으로만 뜨고 진다)
.pane__chip {
box-sizing: border-box;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
color: rgba(23, 20, 26, .45);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 10px;
font-weight: 700;
opacity: 0;
transform: translateY(8px);
transition: opacity $duration $easing, transform $duration $easing;
}
.pane__copy {
position: relative;
box-sizing: border-box;
width: 76px;
height: 24px;
border: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
color: inherit;
cursor: pointer;
transition: transform $duration $easing;
}
.pane__copy:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.pane__copy:hover { background: rgba(23, 20, 26, .14); }
.copy__state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-1;
border-radius: $r-pill;
pointer-events: none;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
transition: opacity $duration $easing;
}
.copy__state--idle { opacity: 1; color: $subject-ink; }
.copy__state--done { opacity: 0; background: $color; color: $subject-cream; }
.pane__copy.is-copied .copy__state--idle { opacity: 0; }
.pane__copy.is-copied .copy__state--done { opacity: 1; }
.pane__body {
position: relative;
box-sizing: border-box;
padding: $sp-3;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.pane__body pre, .pane__body code { margin: 0; font: inherit; white-space: pre; }
.k { color: $subject-orange-deep; }
.s { color: $subject-blue-deep; }
.n { color: $error; }
// 전체 선택 하이라이트 — 배경이 아니라 겹친 레이어의 opacity 만 움직인다
.pane__sel {
position: absolute;
inset: $sp-1;
border-radius: $r-xs;
background: rgba($color, .18);
opacity: 0;
pointer-events: none;
}
.ccb__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 3;
width: 12px;
height: 12px;
border: 2px solid $subject-cream;
border-radius: 50%;
background: $subject-ink;
box-shadow: $shadow-press;
pointer-events: none;
opacity: 0;
transform: translate(16px, 128px);
}
.ccb.is-demo .ccb__cursor { opacity: 1; }
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 20% 컷: 복사됨 + 전체 하이라이트 ON(면적 최대) · 86% 컷: 복사로 복귀
.ccb.is-demo .copy__state--idle,
.ccb.is-demo .copy__state--done,
.ccb.is-demo .pane__sel {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ccb.is-demo .copy__state--idle { animation-name: ccb-idle; }
.ccb.is-demo .copy__state--done { animation-name: ccb-done; }
.ccb.is-demo .pane__sel { animation-name: ccb-sel; }
.ccb.is-demo .pane__copy {
animation-name: ccb-press;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ccb.is-demo .pane__chip {
animation-name: ccb-chip;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ccb.is-demo .ccb__cursor {
// 커서 이동만은 오버슈트 없는 이징을 쓴다 — $ease-pop(탄성)은 좌표가 목표치를 넘어 튀어
// 뷰포트 경계(320px 폰 폭)를 순간적으로 넘길 수 있다(실측: 커서 우측 끝 322px)
animation-name: ccb-cursor;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
@keyframes ccb-idle {
0% { opacity: 1; }
20% { opacity: 0; }
86% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes ccb-done {
0% { opacity: 0; }
20% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes ccb-sel {
0% { opacity: 0; }
20% { opacity: 1; }
28% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes ccb-press {
0%, 14% { transform: scale(1); }
18% { transform: scale(.94); }
22%, 100% { transform: scale(1); }
}
@keyframes ccb-chip {
0%, 20% { opacity: 0; transform: translateY(8px); }
22%, 34% { opacity: 1; transform: translateY(0); }
72% { opacity: 1; transform: translateY(0); }
84%, 100% { opacity: 0; transform: translateY(8px); }
}
@keyframes ccb-cursor {
// 쉬는 자리는 버튼(x 200~276, y 2~26) 왼쪽 아래 바깥 8px — 체크 아이콘·글자를 가리지 않는다
0%, 2% { transform: translate(16px, 128px); }
16%, 34% { transform: translate(192px, 34px); }
70%, 100% { transform: translate(16px, 128px); }
}
@media (max-width: 340px) {
.pane { width: 100%; }
.pane__body { font-size: 10px; line-height: 16px; padding: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.ccb.is-demo .copy__state--idle,
.ccb.is-demo .copy__state--done,
.ccb.is-demo .pane__sel,
.ccb.is-demo .pane__copy,
.ccb.is-demo .pane__chip,
.ccb.is-demo .ccb__cursor { animation: none !important; }
.ccb.is-demo .copy__state--idle { opacity: 0; }
.ccb.is-demo .copy__state--done { opacity: 1; }
.ccb.is-demo .ccb__cursor { opacity: 0; }
}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);
}
.ccb {
position: relative;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
color: #17141a;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
height: 28px;
padding: 0 12px;
background: rgba(23, 20, 26, 0.06);
border-bottom: 1px solid rgba(23, 20, 26, 0.12);
}
.pane__lang {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
color: rgba(23, 20, 26, 0.72);
text-transform: uppercase;
}
.pane__actions {
display: flex;
align-items: center;
gap: 4px;
}
.pane__chip {
box-sizing: border-box;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
color: rgba(23, 20, 26, 0.45);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 10px;
font-weight: 700;
opacity: 0;
transform: translateY(8px);
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pane__copy {
position: relative;
box-sizing: border-box;
width: 76px;
height: 24px;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: inherit;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pane__copy:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pane__copy:hover {
background: rgba(23, 20, 26, 0.14);
}
.copy__state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
border-radius: 999px;
pointer-events: none;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.copy__state--idle {
opacity: 1;
color: #17141a;
}
.copy__state--done {
opacity: 0;
background: #2f6df6;
color: #fff7e6;
}
.pane__copy.is-copied .copy__state--idle {
opacity: 0;
}
.pane__copy.is-copied .copy__state--done {
opacity: 1;
}
.pane__body {
position: relative;
box-sizing: border-box;
padding: 12px;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.pane__body pre, .pane__body code {
margin: 0;
font: inherit;
white-space: pre;
}
.k {
color: #c93000;
}
.s {
color: #0a4fe8;
}
.n {
color: #c2361a;
}
.pane__sel {
position: absolute;
inset: 4px;
border-radius: 4px;
background: rgba(47, 109, 246, 0.18);
opacity: 0;
pointer-events: none;
}
.ccb__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 3;
width: 12px;
height: 12px;
border: 2px solid #fff7e6;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
pointer-events: none;
opacity: 0;
transform: translate(16px, 128px);
}
.ccb.is-demo .ccb__cursor {
opacity: 1;
}
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.ccb.is-demo .copy__state--idle,
.ccb.is-demo .copy__state--done,
.ccb.is-demo .pane__sel {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ccb.is-demo .copy__state--idle {
animation-name: ccb-idle;
}
.ccb.is-demo .copy__state--done {
animation-name: ccb-done;
}
.ccb.is-demo .pane__sel {
animation-name: ccb-sel;
}
.ccb.is-demo .pane__copy {
animation-name: ccb-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.ccb.is-demo .pane__chip {
animation-name: ccb-chip;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.ccb.is-demo .ccb__cursor {
animation-name: ccb-cursor;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes ccb-idle {
0% {
opacity: 1;
}
20% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes ccb-done {
0% {
opacity: 0;
}
20% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ccb-sel {
0% {
opacity: 0;
}
20% {
opacity: 1;
}
28% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ccb-press {
0%, 14% {
transform: scale(1);
}
18% {
transform: scale(0.94);
}
22%, 100% {
transform: scale(1);
}
}
@keyframes ccb-chip {
0%, 20% {
opacity: 0;
transform: translateY(8px);
}
22%, 34% {
opacity: 1;
transform: translateY(0);
}
72% {
opacity: 1;
transform: translateY(0);
}
84%, 100% {
opacity: 0;
transform: translateY(8px);
}
}
@keyframes ccb-cursor {
0%, 2% {
transform: translate(16px, 128px);
}
16%, 34% {
transform: translate(192px, 34px);
}
70%, 100% {
transform: translate(16px, 128px);
}
}
@media (max-width: 340px) {
.pane {
width: 100%;
}
.pane__body {
font-size: 10px;
line-height: 16px;
padding: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ccb.is-demo .copy__state--idle,
.ccb.is-demo .copy__state--done,
.ccb.is-demo .pane__sel,
.ccb.is-demo .pane__copy,
.ccb.is-demo .pane__chip,
.ccb.is-demo .ccb__cursor {
animation: none !important;
}
.ccb.is-demo .copy__state--idle {
opacity: 0;
}
.ccb.is-demo .copy__state--done {
opacity: 1;
}
.ccb.is-demo .ccb__cursor {
opacity: 0;
}
}// 실제 동작: navigator.clipboard 가 없으면 조용히 넘어간다
var root = document.getElementById('root');
var btn = document.getElementById('copyBtn');
var codeText = document.getElementById('codeText');
var live = document.getElementById('live');
var timer = null;
btn.addEventListener('click', function () {
root.classList.remove('is-demo'); // 사람이 누르면 자동 루프를 멈춘다
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(codeText.textContent).catch(function () {});
}
btn.classList.add('is-copied');
live.textContent = 'Copied';
clearTimeout(timer);
timer = setTimeout(function () {
btn.classList.remove('is-copied');
live.textContent = '';
}, 1600);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CopyButtonState.module.scss";
export type CopyButtonStateProps = {
langLabel?: string;
idleLabel?: string;
doneLabel?: string;
chipLabel?: string;
ariaLabel?: string;
code?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const Copy = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<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>
);
const Check = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" />
<path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" />
</g>
</svg>
);
const DEFAULT_CODE = `.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 12px;
}`;
export default function CopyButtonState({
langLabel = "CSS",
idleLabel = "Copy",
doneLabel = "Copied",
chipLabel = "Saved to clipboard",
ariaLabel = "Copy code",
code = DEFAULT_CODE,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#0a4fe8",
}: CopyButtonStateProps) {
const [live, setLive] = useState(demo);
const [copied, setCopied] = useState(false);
const timer = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timer.current !== null) window.clearTimeout(timer.current);
};
}, []);
function onClick() {
setLive(false);
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(code).catch(() => {});
}
setCopied(true);
if (timer.current !== null) window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), 1600);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={[styles.ccb, live ? styles.isDemo : ""].join(" ")} style={vars}>
<div className={styles.pane}>
<div className={styles.paneHead}>
<span className={styles.paneLang}>{langLabel}</span>
<div className={styles.paneActions}>
<span className={styles.paneChip} aria-hidden="true">{chipLabel}</span>
<button
className={[styles.paneCopy, !live && copied ? styles.isCopied : ""].join(" ")}
type="button"
aria-label={ariaLabel}
onClick={onClick}
>
<span className={[styles.copyState, styles.copyStateIdle].join(" ")}>
<Copy />
<span>{idleLabel}</span>
</span>
<span className={[styles.copyState, styles.copyStateDone].join(" ")}>
<Check />
<span>{doneLabel}</span>
</span>
</button>
</div>
</div>
<div className={styles.paneBody}>
<span className={styles.paneSel} aria-hidden="true" />
<pre>
<code>
<span className={styles.k}>.btn</span> {"{"}
{"\n "}
<span className={styles.k}>display</span>: <span className={styles.s}>inline-flex</span>;
{"\n "}
<span className={styles.k}>align-items</span>: <span className={styles.s}>center</span>;
{"\n "}
<span className={styles.k}>gap</span>: <span className={styles.n}>8px</span>;
{"\n "}
<span className={styles.k}>padding</span>: <span className={styles.n}>8px</span> <span className={styles.n}>16px</span>;
{"\n "}
<span className={styles.k}>border-radius</span>: <span className={styles.n}>12px</span>;
{"\n}"}
</code>
</pre>
</div>
</div>
<span className={styles.ccbCursor} aria-hidden="true" />
<span className={styles.sr} role="status" aria-live="polite">{!live && copied ? doneLabel : ""}</span>
</div>
);
}01 Copy button that says it copied — Measured area 28.962% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · 9 Code Block UI Patterns — No Highlighter Library
The post this demo lives in
9 Code Block UI Patterns — No Highlighter LibraryA code block UI is the small frame built around a snippet — header, copy button, tabs — that tells a reader what they ar See all demos in this post


