06실패 되돌리기 — 실패 후 흐른 시간을 세어 농도를 원래대로 보간하는 버튼
복사가 실패한 상태가 되면 버튼이 오류 빛으로 물들었다가, 실패 뒤 흐른 시간을 기준으로 농도를 원래 값까지 조금씩 보간해 저절로 되돌아갑니다.
쓰임보안이 엄격한 관리자 화면의 복사 버튼
backgroundColorclassList.addclassList.remove

<main class="stage">
<section class="panel">
<button class="copyBtn" id="copyBtn" type="button" aria-label="코드 복사">
<svg class="icon" viewBox="0 0 256 256" 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 class="label">코드 복사</span>
</button>
<p class="hint" id="hint">전송 실패 · 되돌리는 중 2.4s</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.copyBtn {
display: inline-flex;
align-items: center;
gap: $sp-2;
border: 0;
border-radius: $r-pill;
padding: $sp-3 $sp-5;
background: $error;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 14px;
line-height: 1.3;
cursor: pointer;
box-shadow: $shadow-raised;
}
.icon {
width: 18px;
height: 18px;
flex: none;
}
.label {
white-space: nowrap;
}
.hint {
margin: 0;
color: rgba($subject-cream, .72);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.4;
}
.hint.is-ok {
color: $subject-mint;
}
@media (max-width: 340px) {
.copyBtn {
padding: $sp-2 $sp-4;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.copyBtn {
transition: none;
}
}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);
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.copyBtn {
display: inline-flex;
align-items: center;
gap: 8px;
border: 0;
border-radius: 999px;
padding: 12px 20px;
background: #c2361a;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 14px;
line-height: 1.3;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.icon {
width: 18px;
height: 18px;
flex: none;
}
.label {
white-space: nowrap;
}
.hint {
margin: 0;
color: rgba(255, 247, 230, 0.72);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.4;
}
.hint.is-ok {
color: #4cd4a6;
}
@media (max-width: 340px) {
.copyBtn {
padding: 8px 16px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.copyBtn {
transition: none;
}
}// 실패 되돌리기 — 복사가 실패하면 버튼이 오류 빛으로 물들고, 실패 뒤 흐른 시간을 기준으로
// 농도를 원래 색까지 조금씩 보간해 저절로 되돌아간다. 되돌리기 시간은 RECOVER_MS.
// 계산 겹: 경과시간 → 보간 비율(0~1) → RGB 채널별 lerp → backgroundColor 재계산.
// 자동 시연만, 실사용자 리스너 없음. 실 클립보드 API 호출 없음 — 상태는 시뮬레이션.
(function () {
var btn = document.getElementById('copyBtn');
var hint = document.getElementById('hint');
if (!btn || !hint) return;
var RECOVER_MS = 2400;
var ERROR_RGB = [194, 54, 26];
var NORMAL_RGB = [10, 79, 232];
function lerp(a, b, p) {
return Math.round(a + (b - a) * p);
}
function apply(t) {
var ratio = Math.min(Math.max(t / RECOVER_MS, 0), 1);
var r = lerp(ERROR_RGB[0], NORMAL_RGB[0], ratio);
var g = lerp(ERROR_RGB[1], NORMAL_RGB[1], ratio);
var b = lerp(ERROR_RGB[2], NORMAL_RGB[2], ratio);
btn.style.backgroundColor = 'rgb(' + r + ',' + g + ',' + b + ')';
if (ratio < 1) {
var remain = ((1 - ratio) * RECOVER_MS / 1000).toFixed(1);
hint.textContent = '전송 실패 · 되돌리는 중 ' + remain + 's';
hint.classList.remove('is-ok');
} else {
hint.textContent = '다시 복사할 수 있어요';
hint.classList.add('is-ok');
}
}
apply(0);
var HOLD_MS = 400;
var CYCLE_MS = RECOVER_MS + HOLD_MS;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return;
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./CopyFailRetryCountdown.module.scss";
// 실패 되돌리기 — 복사가 실패하면 버튼이 오류 빛으로 물들고, 실패 뒤 흐른 시간을 기준으로
// 농도를 원래 색까지 조금씩 보간해 저절로 되돌아간다. 되돌리기 시간은 RECOVER_MS.
// 계산 겹: 경과시간 → 보간 비율(0~1) → RGB 채널별 lerp → backgroundColor 재계산.
// 자동 시연만, 실사용자 리스너 없음. 실 클립보드 API 호출 없음 — 상태는 시뮬레이션.
const RECOVER_MS = 2400;
const HOLD_MS = 400;
const CYCLE_MS = RECOVER_MS + HOLD_MS;
const ERROR_RGB: [number, number, number] = [194, 54, 26];
const NORMAL_RGB: [number, number, number] = [10, 79, 232];
function lerp(a: number, b: number, p: number) {
return Math.round(a + (b - a) * p);
}
export default function CopyFailRetryCountdown() {
const btnRef = useRef<HTMLButtonElement>(null);
const hintRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const btn = btnRef.current;
const hint = hintRef.current;
if (!btn || !hint) return;
function apply(t: number) {
const ratio = Math.min(Math.max(t / RECOVER_MS, 0), 1);
const r = lerp(ERROR_RGB[0], NORMAL_RGB[0], ratio);
const g = lerp(ERROR_RGB[1], NORMAL_RGB[1], ratio);
const b = lerp(ERROR_RGB[2], NORMAL_RGB[2], ratio);
btn!.style.backgroundColor = `rgb(${r},${g},${b})`;
if (ratio < 1) {
const remain = (((1 - ratio) * RECOVER_MS) / 1000).toFixed(1);
hint!.textContent = `전송 실패 · 되돌리는 중 ${remain}s`;
hint!.classList.remove(styles.isOk);
} else {
hint!.textContent = "다시 복사할 수 있어요";
hint!.classList.add(styles.isOk);
}
}
apply(0);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className={styles.stage}>
<section className={styles.panel}>
<button ref={btnRef} className={styles.copyBtn} type="button" aria-label="코드 복사">
<svg className={styles.icon} viewBox="0 0 256 256" 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 className={styles.label}>코드 복사</span>
</button>
<p ref={hintRef} className={styles.hint}>전송 실패 · 되돌리는 중 2.4s</p>
</section>
</div>
);
}06 실패 되돌리기 — 실패 후 흐른 시간을 세어 농도를 원래대로 보간하는 버튼 — 측정 누적면적 4.012% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 클립보드 복사 버튼 UI 9종 — 코드·링크·텍스트 복사 피드백까지
이 데모가 속한 글
클립보드 복사 버튼 UI 9종 — 코드·링크·텍스트 복사 피드백까지아홉 개 중 하나는 복사 결과를 버튼 두 개가 나눠 갖고, 하나는 복사된 글자 수를 세어 자릿수만큼 표시를 채우고, 또 하나는 실패한 복사를 되돌리는 시간을 스스로 잽니다. 이 클립보드 복사 버튼 아홉 개는 실제 n 이 글의 데모 전부 보기


