03Backup codes struck through once they are used
Six nine-character codes sit in a two-column grid inside the card, and a code already spent keeps a line through its middle and fades. The button below hands the whole list to the clipboard and announces that moment in a live region as well.
Best forThe one-time code screen handed over right after two-factor is switched on
line-throughnavigator.clipboardaria-live

<main class="stage">
<section class="bc is-demo" id="bc" data-copied="0">
<h2 class="bc__title">Backup codes</h2>
<ul class="bc__grid">
<li class="bc__code" data-code="1">D4K9-P2M7<span class="bc__strike" data-strike="1"></span></li>
<li class="bc__code" data-code="2">X8R3-T6W1<span class="bc__strike" data-strike="2"></span></li>
<li class="bc__code bc__code--used" data-code="3">Q5N8-J2H4<span class="bc__strike" data-strike="3"></span></li>
<li class="bc__code" data-code="4">M7V2-C9L5<span class="bc__strike" data-strike="4"></span></li>
<li class="bc__code" data-code="5">R3F6-B8K2<span class="bc__strike" data-strike="5"></span></li>
<li class="bc__code bc__code--used" data-code="6">H9T4-N5D7<span class="bc__strike" data-strike="6"></span></li>
</ul>
<button class="bc__copy" type="button" id="bc-copy">
<span class="bc__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><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="bc__labels">
<span class="bc__label" data-label="copy">Copy all</span>
<span class="bc__label" data-label="done">Copied</span>
</span>
</button>
<p class="bc__live" role="status" aria-live="polite"><span class="bc__say">Copied 6 backup codes to the clipboard</span></p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-orange);
.bc {
box-sizing: border-box;
width: min(320px, 88vw);
padding: $sp-3;
border-radius: $r-card;
background-color: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.bc__title {
margin: 0 0 $sp-2;
font-size: 13px;
line-height: 18px;
font-weight: 700;
}
// 코드 6개 — 2열 격자
.bc__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $sp-1;
margin: 0 0 $sp-3;
padding: 0;
list-style: none;
}
.bc__code {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
height: 28px;
border-radius: $r-sm;
background-color: rgba($subject-ink, .08);
font-size: 13px;
line-height: 18px;
font-weight: 700;
letter-spacing: 1px;
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
}
// 이미 쓴 코드 — 가운데 줄이 그어지고 흐려진다
.bc__code--used {
color: rgba($subject-ink, .72); // AA 4.5 (.38 은 6.50 기준 2.35:1 이었다)
text-decoration: line-through 2px rgba($subject-ink, .72);
}
// 취소선은 왼쪽부터 자라는 막대로도 덧얹는다 (scaleX)
.bc__strike {
position: absolute;
left: $sp-2;
right: $sp-2;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: $r-pill;
background-color: $error;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 좌변서 그어짐
transition: transform $duration $easing;
}
.bc__copy {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-1;
width: 100%;
height: 34px;
margin: 0;
padding: 0;
border: 0;
border-radius: $r-control;
background-color: $color;
color: $subject-ink;
font-size: 13px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.bc__copy:focus-visible { outline: 3px solid $subject-cream; outline-offset: 2px; }
.bc__ic { display: inline-flex; flex: 0 0 auto; }
.bc__ic svg { display: block; }
// 두 라벨을 같은 격자 칸에 겹쳐 둔다 — 절대배치로 띄우면 복사 아이콘 위에 글자가 얹힌다
.bc__labels { display: grid; }
.bc__label { grid-area: 1 / 1; opacity: 0; }
// 사람이 실제로 누른 경로 — 복사되면 라벨이 갈렸다가 1.6초 뒤 돌아온다
.bc:not(.is-demo)[data-copied="1"] .bc__label[data-label="done"],
.bc:not(.is-demo)[data-copied="0"] .bc__label[data-label="copy"] { opacity: 1; }
.bc:not(.is-demo)[data-copied="1"] .bc__copy { background-color: $subject-blue; color: $subject-cream; }
.bc__live {
margin: $sp-2 0 0;
height: 15px;
font-size: 10px;
line-height: 15px;
font-weight: 500;
color: rgba($subject-ink, .64);
white-space: nowrap;
opacity: 0;
}
.bc:not(.is-demo)[data-copied="1"] .bc__live { opacity: 1; }
// ── 자동 루프 ──
// 취소선이 한 칸씩 자라다가(0~46%) 마지막 칸에서 복사 완료로 갈린다(52%). 복사 표시는 지우지 않고 86% 까지 유지한다
.bc.is-demo .bc__strike[data-strike="3"] {
animation-name: bc-grow3;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes bc-grow3 {
0% { transform: scaleX(0); }
4% { transform: scaleX(0); }
14% { transform: scaleX(1); }
86% { transform: scaleX(1); }
92% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
.bc.is-demo .bc__strike[data-strike="6"] {
animation-name: bc-grow6;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes bc-grow6 {
0% { transform: scaleX(0); }
16% { transform: scaleX(0); }
26% { transform: scaleX(1); }
86% { transform: scaleX(1); }
92% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
.bc.is-demo .bc__strike[data-strike="1"] {
animation-name: bc-grow1;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: 0s;
}
@keyframes bc-grow1 {
0% { transform: scaleX(0); }
28% { transform: scaleX(0); }
38% { transform: scaleX(1); }
86% { transform: scaleX(1); }
92% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
.bc.is-demo .bc__strike[data-strike="4"] {
animation-name: bc-grow4;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes bc-grow4 {
0% { transform: scaleX(0); }
40% { transform: scaleX(0); }
46% { transform: scaleX(1); }
86% { transform: scaleX(1); }
92% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
// 복사 완료 컷 — 라벨과 알림 줄은 steps 로 한 프레임에 갈린다. 퇴장 재현 금지: 86% 이후로 끌어내리지 않는다
.bc.is-demo .bc__label,
.bc.is-demo .bc__live,
.bc.is-demo .bc__copy {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.bc.is-demo .bc__label[data-label="copy"] { animation-name: bc-label-copy; }
.bc.is-demo .bc__label[data-label="done"] { animation-name: bc-label-done; }
@keyframes bc-label-copy {
0% { opacity: 1; }
52% { opacity: 0; }
86% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes bc-label-done {
0% { opacity: 0; }
52% { opacity: 1; }
86% { opacity: 1; }
100% { opacity: 0; }
}
.bc.is-demo .bc__live { animation-name: bc-say; }
@keyframes bc-say {
0% { opacity: 0; }
52% { opacity: 1; }
86% { opacity: 1; }
100% { opacity: 0; }
}
.bc.is-demo .bc__copy { animation-name: bc-skin; }
@keyframes bc-skin {
0% { background-color: $color; color: $subject-ink; }
52% { background-color: $subject-blue; color: $subject-cream; }
86% { background-color: $subject-blue; color: $subject-cream; }
100% { background-color: $color; color: $subject-ink; }
}
@media (max-width: 340px) {
.bc { padding: $sp-2; }
.bc__title { margin: 0 0 $sp-1; font-size: 12px; line-height: 16px; }
.bc__grid { gap: 4px; margin: 0 0 $sp-2; }
.bc__code { height: 22px; font-size: 11px; line-height: 16px; letter-spacing: 0; }
.bc__copy { height: 28px; font-size: 12px; line-height: 16px; }
.bc__live { margin: $sp-1 0 0; height: 14px; font-size: 9px; line-height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.bc.is-demo .bc__strike,
.bc.is-demo .bc__label,
.bc.is-demo .bc__live,
.bc.is-demo .bc__copy { animation: none !important; }
.bc__strike { transition: none !important; }
.bc.is-demo .bc__label[data-label="copy"] { opacity: 1 !important; }
.bc.is-demo .bc__strike[data-strike="3"],
.bc.is-demo .bc__strike[data-strike="6"] { transform: scaleX(1) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bc {
box-sizing: border-box;
width: min(320px, 88vw);
padding: 12px;
border-radius: 16px;
background-color: #fff7e6;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.bc__title {
margin: 0 0 8px;
font-size: 13px;
line-height: 18px;
font-weight: 700;
}
.bc__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
margin: 0 0 12px;
padding: 0;
list-style: none;
}
.bc__code {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
height: 28px;
border-radius: 8px;
background-color: rgba(23, 20, 26, 0.08);
font-size: 13px;
line-height: 18px;
font-weight: 700;
letter-spacing: 1px;
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
}
.bc__code--used {
color: rgba(23, 20, 26, 0.72);
text-decoration: line-through 2px rgba(23, 20, 26, 0.72);
}
.bc__strike {
position: absolute;
left: 8px;
right: 8px;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: 999px;
background-color: #c2361a;
transform: scaleX(0);
transform-origin: left center;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bc__copy {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
height: 34px;
margin: 0;
padding: 0;
border: 0;
border-radius: 12px;
background-color: #4cd4a6;
color: #17141a;
font-size: 13px;
line-height: 18px;
font-weight: 700;
cursor: pointer;
}
.bc__copy:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 2px;
}
.bc__ic {
display: inline-flex;
flex: 0 0 auto;
}
.bc__ic svg {
display: block;
}
.bc__labels {
display: grid;
}
.bc__label {
grid-area: 1/1;
opacity: 0;
}
.bc:not(.is-demo)[data-copied="1"] .bc__label[data-label=done],
.bc:not(.is-demo)[data-copied="0"] .bc__label[data-label=copy] {
opacity: 1;
}
.bc:not(.is-demo)[data-copied="1"] .bc__copy {
background-color: #2f6df6;
color: #fff7e6;
}
.bc__live {
margin: 8px 0 0;
height: 15px;
font-size: 10px;
line-height: 15px;
font-weight: 500;
color: rgba(23, 20, 26, 0.64);
white-space: nowrap;
opacity: 0;
}
.bc:not(.is-demo)[data-copied="1"] .bc__live {
opacity: 1;
}
.bc.is-demo .bc__strike[data-strike="3"] {
animation-name: bc-grow3;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes bc-grow3 {
0% {
transform: scaleX(0);
}
4% {
transform: scaleX(0);
}
14% {
transform: scaleX(1);
}
86% {
transform: scaleX(1);
}
92% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
.bc.is-demo .bc__strike[data-strike="6"] {
animation-name: bc-grow6;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes bc-grow6 {
0% {
transform: scaleX(0);
}
16% {
transform: scaleX(0);
}
26% {
transform: scaleX(1);
}
86% {
transform: scaleX(1);
}
92% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
.bc.is-demo .bc__strike[data-strike="1"] {
animation-name: bc-grow1;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: 0s;
}
@keyframes bc-grow1 {
0% {
transform: scaleX(0);
}
28% {
transform: scaleX(0);
}
38% {
transform: scaleX(1);
}
86% {
transform: scaleX(1);
}
92% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
.bc.is-demo .bc__strike[data-strike="4"] {
animation-name: bc-grow4;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes bc-grow4 {
0% {
transform: scaleX(0);
}
40% {
transform: scaleX(0);
}
46% {
transform: scaleX(1);
}
86% {
transform: scaleX(1);
}
92% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
.bc.is-demo .bc__label,
.bc.is-demo .bc__live,
.bc.is-demo .bc__copy {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.bc.is-demo .bc__label[data-label=copy] {
animation-name: bc-label-copy;
}
.bc.is-demo .bc__label[data-label=done] {
animation-name: bc-label-done;
}
@keyframes bc-label-copy {
0% {
opacity: 1;
}
52% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes bc-label-done {
0% {
opacity: 0;
}
52% {
opacity: 1;
}
86% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.bc.is-demo .bc__live {
animation-name: bc-say;
}
@keyframes bc-say {
0% {
opacity: 0;
}
52% {
opacity: 1;
}
86% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.bc.is-demo .bc__copy {
animation-name: bc-skin;
}
@keyframes bc-skin {
0% {
background-color: #4cd4a6;
color: #17141a;
}
52% {
background-color: #2f6df6;
color: #fff7e6;
}
86% {
background-color: #2f6df6;
color: #fff7e6;
}
100% {
background-color: #4cd4a6;
color: #17141a;
}
}
@media (max-width: 340px) {
.bc {
padding: 8px;
}
.bc__title {
margin: 0 0 4px;
font-size: 12px;
line-height: 16px;
}
.bc__grid {
gap: 4px;
margin: 0 0 8px;
}
.bc__code {
height: 22px;
font-size: 11px;
line-height: 16px;
letter-spacing: 0;
}
.bc__copy {
height: 28px;
font-size: 12px;
line-height: 16px;
}
.bc__live {
margin: 4px 0 0;
height: 14px;
font-size: 9px;
line-height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.bc.is-demo .bc__strike,
.bc.is-demo .bc__label,
.bc.is-demo .bc__live,
.bc.is-demo .bc__copy {
animation: none !important;
}
.bc__strike {
transition: none !important;
}
.bc.is-demo .bc__label[data-label=copy] {
opacity: 1 !important;
}
.bc.is-demo .bc__strike[data-strike="3"],
.bc.is-demo .bc__strike[data-strike="6"] {
transform: scaleX(1) !important;
}
}// 실제 동작: 전체 복사는 navigator.clipboard.writeText 로 실제로 쓰고, 라벨이 잠깐 갈렸다 돌아오며 같은 말이 읽힌다
var root = document.getElementById('bc');
var btn = document.getElementById('bc-copy');
var live = root.querySelector('.bc__live');
var t = 0;
var fullCode = function () {
var cs = root.querySelectorAll('.bc__code');
var out = [];
for (var i = 0; i < cs.length; i++) out.push(cs[i].firstChild.nodeValue);
return out.join('\n');
};
btn.addEventListener('click', function () {
root.classList.remove('is-demo'); // 사람이 만졌으니 자동 루프를 끈다
clearTimeout(t);
root.setAttribute('data-copied', '1');
var txt = fullCode();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(txt);
}
live.textContent = 'Copied 6 backup codes to the clipboard';
t = setTimeout(function () { root.setAttribute('data-copied', '0'); }, 1600);
});import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./BackupCodesCard.module.scss";
export type BackupCode = { id: string; value: string; used?: boolean };
export type BackupCodesCardProps = {
title?: string;
codes?: BackupCode[];
copyLabel?: string;
doneLabel?: string;
sayText?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Timer = ReturnType<typeof setTimeout>;
const COPY: ReactNode = (
<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 DEFAULT_CODES: BackupCode[] = [
{ id: "c1", value: "D4K9-P2M7" },
{ id: "c2", value: "X8R3-T6W1" },
{ id: "c3", value: "Q5N8-J2H4", used: true },
{ id: "c4", value: "M7V2-C9L5" },
{ id: "c5", value: "R3F6-B8K2" },
{ id: "c6", value: "H9T4-N5D7", used: true },
];
export default function BackupCodesCard({
title = "백업 코드",
codes = DEFAULT_CODES,
copyLabel = "전체 복사",
doneLabel = "복사됨",
sayText = "백업 코드 6개를 클립보드에 복사했습니다",
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: BackupCodesCardProps) {
const [demo, setDemo] = useState(true);
const [copied, setCopied] = useState(false);
const t = useRef<Timer>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 전체 복사는 navigator.clipboard.writeText 로 실제로 쓰고, 라벨이 잠깐 갈렸다 돌아오며 같은 말이 읽힌다
function copy() {
setDemo(false); // 사람이 만졌으니 자동 루프를 끈다
if (t.current !== null) clearTimeout(t.current);
setCopied(true);
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(codes.map((c: BackupCode) => c.value).join("\n"));
}
t.current = setTimeout(() => setCopied(false), 1600);
}
return (
<section className={`${styles.bc} ${demo ? styles.isDemo : ""}`} style={vars} data-copied={copied ? "1" : "0"}>
<h2 className={styles.title}>{title}</h2>
<ul className={styles.grid}>
{codes.map((c: BackupCode) => (
<li key={c.id} className={`${styles.code} ${c.used ? styles.codeUsed : ""}`} data-code={c.id}>
{c.value}
<span className={styles.strike} data-strike={c.id} />
</li>
))}
</ul>
<button className={styles.copyBtn} type="button" onClick={copy}>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="16" height="16">{COPY}</svg>
</span>
<span className={styles.labels}>
<span className={styles.label} data-label="copy">{copyLabel}</span>
<span className={styles.label} data-label="done">{doneLabel}</span>
</span>
</button>
<p className={styles.live} role="status" aria-live="polite">{sayText}</p>
</section>
);
}03 Backup codes struck through once they are used — Measured area 8.319% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · 9 account security settings UI patterns
The post this demo lives in
9 account security settings UI patternsAccount security settings cover everything after the login screen: devices still signed in, two-factor, the password. Al See all demos in this post

