홈페이지 다국어 처리 검수 9가지 — 언어팩 키 누락·번역 누락 잡기
홈페이지 다국어 처리는 번역문을 언어팩 파일에 넣었다고 끝나지 않는다. 키가 한쪽에만 있으면 영어판 버튼이 빈 채로 남고, 자리표시자가 어긋나면 이름이 빠진 인사말이 나간다. 여기 아홉 가지 검사를 브라우저 콘솔에 붙여 넣으면 코드 몰라도 3분 검수가 끝난다.
01 language-pack-json
02 missing-key-check
03 placeholder-mismatch
04 korean-residue-scan
05 t-function
06 hardcoded-scan
07 fallback-key
08 locale-url
09 date-number-format
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 언어팩 두 파일 뼈대
- 02 한쪽 언어에만 있는 키
- 03 {이름} 자리표시자 불일치
- 04 번역 안 된 한글 잔존
- 05 언어팩을 붙이는 t() 함수
- 06 언어팩을 안 타는 하드코딩 글자
- 07 없는 키가 화면에 그대로 노출
- 08 언어별 주소와 hreflang
- 09 날짜·숫자 형식이 언어마다 다를 때
아홉 개는 실제로 두 번째 언어를 붙일 때 겪는 순서대로 놓았다. 다국어 번역 파일, 곧 언어팩 두 파일의 뼈대(01)를 갖추고 한쪽에만 있는 키(02)를 잡은 다음, 문장 안을 들여다보는 자리표시자 불일치(03)와 번역 안 된 한글 잔존(04)을 확인한다. 화면과 언어팩을 잇는 t() 함수(05)를 살피고 언어팩을 아예 안 타는 하드코딩 글자(06)와 없는 키의 노출(07)까지 막으면, 마지막으로 바깥 세계를 본다 — 언어별 주소와 hreflang(08), 그리고 날짜·숫자 형식(09)이다.
01언어팩 두 파일 뼈대
다국어 홈페이지 제작에서 무엇이 보이나
ko.json 과 en.json 이 같은 층으로 펼쳐지며 서로 짝을 맞추고, 한쪽에만 있는 키는 자리가 빈 채로 남아 곧바로 눈에 잡히는 데모다.

<main class="stage is-demo">
<div class="pack">
<section class="file">
<header class="file__head"><span class="file__name">ko.json</span></header>
<ul class="file__rows">
<li class="row" style="--i:0"><span class="row__key">"title"</span><span class="row__val" data-t="내 서비스"></span></li>
<li class="row" style="--i:1"><span class="row__key">"cta"</span><span class="row__val" data-t="시작하기"></span></li>
<li class="row" style="--i:2"><span class="row__key">"nav.home"</span><span class="row__val" data-t="홈"></span></li>
<li class="row" style="--i:3"><span class="row__key">"footer.contact"</span><span class="row__val" data-t="문의하기"></span></li>
</ul>
<span class="scan"></span>
</section>
<section class="file">
<header class="file__head"><span class="file__name">en.json</span></header>
<ul class="file__rows">
<li class="row" style="--i:0"><span class="row__key">"title"</span><span class="row__val" data-t="My Service"></span></li>
<li class="row" style="--i:1"><span class="row__key">"cta"</span><span class="row__val" data-t="Get started"></span></li>
<li class="row" style="--i:2"><span class="row__key">"nav.home"</span><span class="row__val" data-t="Home"></span></li>
<li class="row row--miss" style="--i:3"><span class="row__key">"footer.contact"</span><span class="row__val row__val--empty"></span><span class="miss-badge">!</span></li>
</ul>
<span class="scan"></span>
</section>
<p class="pack__note">짝이 안 맞는 키 1개</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 키 짝 맞추기 한 주기
$easing: $ease-out;
$color: $subject-cream; // 파일 카드
@include stage($stage-ink);
.pack {
width: min(440px, 94vw);
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: $sp-3;
row-gap: $sp-2;
}
.file {
position: relative;
border-radius: $r-card;
background: $color;
box-shadow: $shadow-raised;
overflow: hidden;
}
.file__head {
padding: $sp-1 $sp-2;
border-bottom: 2px solid rgba($subject-ink, .12);
}
.file__name {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 700;
color: $subject-ink;
}
.file__rows {
list-style: none;
margin: 0;
padding: $sp-1 $sp-2 $sp-2;
display: grid;
row-gap: $sp-1;
}
.row {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
column-gap: $sp-1;
border-radius: $r-xs;
padding: 0 $sp-1;
min-height: clamp(18px, 6vw, 28px);
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(8.5px, 2vw, 12px);
}
.row__key { color: $subject-blue-deep; font-weight: 700; white-space: nowrap; } // 크림 면 위 파란 글자 — 5.98:1
.row__val { color: $subject-ink; white-space: nowrap; }
.row__val::before { content: attr(data-t); }
.row__val--empty {
flex: 1;
min-height: 14px;
border: 2px dashed rgba($error, .55);
border-radius: $r-xs;
}
.miss-badge {
position: absolute;
right: -$sp-1;
top: 50%;
width: 20px; height: 20px;
margin-top: -10px;
border-radius: 50%;
background: $error;
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 800;
font-size: 13px;
line-height: 20px;
text-align: center;
box-shadow: $shadow-press;
}
.pack__note {
grid-column: 1 / -1;
margin: 0;
justify-self: center;
border-radius: $r-pill;
background: $error-soft;
color: $subject-ink; // $error 글자는 note-in 애니메이션의 낮은 opacity 에서 3.22:1 로 깨진다 — 잉크로
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 700;
padding: $sp-1 $sp-3;
}
// 스캔 줄 — 카드 높이와 같은 오버레이에 그라데이션 한 줄을 넣고 위치만 이동
.scan {
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(to bottom,
transparent 0 calc(50% - 8px),
rgba($subject-blue, .28) calc(50% - 8px) calc(50% + 8px),
transparent calc(50% + 8px) 100%);
background-size: 100% 220%;
opacity: 0;
}
.stage.is-demo .scan { animation: scan-down $duration $easing infinite; }
.stage.is-demo .row { animation: row-flash $duration linear infinite; animation-delay: calc(var(--i) * .12s); }
.stage.is-demo .row--miss .row__val--empty { animation: empty-alarm $duration $easing infinite; }
.stage.is-demo .miss-badge { animation: miss-pop $duration $easing infinite; }
.stage.is-demo .pack__note { animation: note-in $duration $easing infinite; }
@keyframes scan-down {
0% { opacity: 1; background-position: 0 -130%; }
55% { opacity: 1; background-position: 0 130%; }
60%, 100% { opacity: 0; background-position: 0 130%; }
}
@keyframes row-flash {
0%, 4% { background: transparent; }
13% { background: rgba($subject-blue, .24); }
27%, 100% { background: transparent; }
}
@keyframes empty-alarm {
0%, 42% { background: transparent; }
50% { background: rgba($error, .30); }
60% { background: rgba($error, .10); }
68% { background: rgba($error, .30); }
76% { background: rgba($error, .10); }
84%, 100% { background: rgba($error, .24); }
}
@keyframes miss-pop {
0%, 42% { transform: scale(0); opacity: 0; }
50% { transform: scale(1.3) rotate(0deg); opacity: 1; }
56% { transform: scale(1) rotate(0deg); }
66% { transform: scale(1) rotate(7deg); }
72% { transform: scale(1) rotate(-7deg); }
78% { transform: scale(1) rotate(4deg); }
84%, 96% { transform: scale(1) rotate(0deg); opacity: 1; }
100% { transform: scale(.5); opacity: 0; }
}
@keyframes note-in {
0%, 48% { transform: translateY(6px); opacity: 0; }
58%, 94% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(6px); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .scan,
.stage.is-demo .row,
.stage.is-demo .row--miss .row__val--empty,
.stage.is-demo .miss-badge,
.stage.is-demo .pack__note { animation: none; }
.scan { 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);
}
.pack {
width: min(440px, 94vw);
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 12px;
row-gap: 8px;
}
.file {
position: relative;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.file__head {
padding: 4px 8px;
border-bottom: 2px solid rgba(23, 20, 26, 0.12);
}
.file__name {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 700;
color: #17141a;
}
.file__rows {
list-style: none;
margin: 0;
padding: 4px 8px 8px;
display: grid;
row-gap: 4px;
}
.row {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
column-gap: 4px;
border-radius: 4px;
padding: 0 4px;
min-height: clamp(18px, 6vw, 28px);
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(8.5px, 2vw, 12px);
}
.row__key {
color: #0a4fe8;
font-weight: 700;
white-space: nowrap;
}
.row__val {
color: #17141a;
white-space: nowrap;
}
.row__val::before {
content: attr(data-t);
}
.row__val--empty {
flex: 1;
min-height: 14px;
border: 2px dashed rgba(194, 54, 26, 0.55);
border-radius: 4px;
}
.miss-badge {
position: absolute;
right: -4px;
top: 50%;
width: 20px;
height: 20px;
margin-top: -10px;
border-radius: 50%;
background: #c2361a;
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 800;
font-size: 13px;
line-height: 20px;
text-align: center;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.pack__note {
grid-column: 1/-1;
margin: 0;
justify-self: center;
border-radius: 999px;
background: #ffe0d8;
color: #17141a;
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 700;
padding: 4px 12px;
}
.scan {
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(to bottom, transparent 0 calc(50% - 8px), rgba(47, 109, 246, 0.28) calc(50% - 8px) calc(50% + 8px), transparent calc(50% + 8px) 100%);
background-size: 100% 220%;
opacity: 0;
}
.stage.is-demo .scan {
animation: scan-down 2s ease-out infinite;
}
.stage.is-demo .row {
animation: row-flash 2s linear infinite;
animation-delay: calc(var(--i) * 0.12s);
}
.stage.is-demo .row--miss .row__val--empty {
animation: empty-alarm 2s ease-out infinite;
}
.stage.is-demo .miss-badge {
animation: miss-pop 2s ease-out infinite;
}
.stage.is-demo .pack__note {
animation: note-in 2s ease-out infinite;
}
@keyframes scan-down {
0% {
opacity: 1;
background-position: 0 -130%;
}
55% {
opacity: 1;
background-position: 0 130%;
}
60%, 100% {
opacity: 0;
background-position: 0 130%;
}
}
@keyframes row-flash {
0%, 4% {
background: transparent;
}
13% {
background: rgba(47, 109, 246, 0.24);
}
27%, 100% {
background: transparent;
}
}
@keyframes empty-alarm {
0%, 42% {
background: transparent;
}
50% {
background: rgba(194, 54, 26, 0.3);
}
60% {
background: rgba(194, 54, 26, 0.1);
}
68% {
background: rgba(194, 54, 26, 0.3);
}
76% {
background: rgba(194, 54, 26, 0.1);
}
84%, 100% {
background: rgba(194, 54, 26, 0.24);
}
}
@keyframes miss-pop {
0%, 42% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.3) rotate(0deg);
opacity: 1;
}
56% {
transform: scale(1) rotate(0deg);
}
66% {
transform: scale(1) rotate(7deg);
}
72% {
transform: scale(1) rotate(-7deg);
}
78% {
transform: scale(1) rotate(4deg);
}
84%, 96% {
transform: scale(1) rotate(0deg);
opacity: 1;
}
100% {
transform: scale(0.5);
opacity: 0;
}
}
@keyframes note-in {
0%, 48% {
transform: translateY(6px);
opacity: 0;
}
58%, 94% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(6px);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .scan,
.stage.is-demo .row,
.stage.is-demo .row--miss .row__val--empty,
.stage.is-demo .miss-badge,
.stage.is-demo .pack__note {
animation: none;
}
.scan {
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./LanguagePackJson.module.scss";
export type LanguagePackJsonProps = {
duration?: number;
easing?: string;
color?: string;
note?: string;
};
const KO_ROWS = ["내 서비스", "시작하기", "홈", "문의하기"];
const EN_ROWS = ["My Service", "Get started", "Home", ""];
const KEYS = ["title", "cta", "nav.home", "footer.contact"];
export default function LanguagePackJson({
duration = 2000,
easing = "ease-out",
color = "#fff7e6",
note = "짝이 안 맞는 키 1개",
}: LanguagePackJsonProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.stage} style={vars} aria-label="언어팩 두 파일 비교 데모" role="img">
<div className={styles.pack}>
<section className={styles.file}>
<header className={styles.head}><span className={styles.name}>ko.json</span></header>
<ul className={styles.rows}>
{KEYS.map((k, i) => (
<li key={k} className={styles.row} style={{ "--i": i } as CSSProperties}>
<span className={styles.key}>"{k}"</span>
<span className={styles.val}>{KO_ROWS[i]}</span>
</li>
))}
</ul>
<span className={styles.scan} />
</section>
<section className={styles.file}>
<header className={styles.head}><span className={styles.name}>en.json</span></header>
<ul className={styles.rows}>
{KEYS.map((k, i) => (
<li
key={k}
className={`${styles.row} ${i === 3 ? styles.rowMiss : ""}`}
style={{ "--i": i } as CSSProperties}
>
<span className={styles.key}>"{k}"</span>
{EN_ROWS[i]
? <span className={styles.val}>{EN_ROWS[i]}</span>
: <span className={`${styles.val} ${styles.valEmpty}`} />}
{i === 3 && <span className={styles.miss}>!</span>}
</li>
))}
</ul>
<span className={styles.scan} />
</section>
<p className={styles.note}>{note}</p>
</div>
</div>
);
}01 · 언어팩 두 파일 뼈대 — 측정 누적면적 46.337% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
다국어 사이트를 처음 세팅하거나 번역 파일을 새로 추가할 때 제일 먼저 돌려보는 검사다.
const flatten = (o, p = "") =>
Object.entries(o).flatMap(([k, v]) =>
v && typeof v === "object"
? flatten(v, p ? `${p}.${k}` : k)
: [`${p ? p + "." : ""}${k}`]
);
const load = async (l) =>
flatten(await (await fetch(`/locales/${l}.json`)).json());
const ko = await load("ko");
const en = await load("en");
console.log(ko.length, en.length); // 줄 수가 다르면 뼈대부터 확인
02한쪽 언어에만 있는 키
화면에서 일어나는 일
두 열의 키 칸이 순서대로 불이 들어오듯 채워지다가, 없는 쪽 칸은 그대로 어두운 채 붉은 결함 표시가 뜬다.

<main class="stage is-demo">
<div class="board">
<div class="col">
<p class="col__head">ko</p>
<ul class="col__rows">
<li class="pill" style="--i:0">nav.home</li>
<li class="pill" style="--i:1">nav.about</li>
<li class="pill" style="--i:2">nav.cart</li>
<li class="pill" style="--i:3">nav.mypage</li>
</ul>
</div>
<div class="col">
<p class="col__head">en</p>
<ul class="col__rows">
<li class="pill pill--en" style="--i:0">nav.home</li>
<li class="pill pill--en" style="--i:1">nav.about</li>
<li class="pill pill--en" style="--i:2">nav.cart</li>
<li class="slot" style="--i:3"><span class="slot__badge">누락</span></li>
</ul>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 칸 채우기 한 주기
$easing: $ease-pop;
$color: $subject-ink; // ko 알약
@include stage($stage-yellow);
.board {
width: min(400px, 92vw);
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: $sp-4;
}
.col__head {
margin: 0 0 $sp-2;
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(12px, 2.8vw, 15px);
font-weight: 700;
color: $subject-ink;
text-align: center;
}
.col__rows {
list-style: none;
margin: 0; padding: 0;
display: grid;
row-gap: $sp-2;
}
.pill, .slot {
border-radius: $r-control;
min-height: clamp(24px, 8vw, 34px);
display: flex;
align-items: center;
justify-content: center;
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(9.5px, 2.3vw, 13px);
font-weight: 700;
}
.pill { background: $color; color: $stage-yellow; box-shadow: $shadow-press; }
.pill--en { background: $subject-cream; color: $subject-ink; }
.slot {
position: relative;
border: 2px dashed rgba($error, .65);
}
.slot__badge {
border-radius: $r-pill;
background: $error;
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 800;
padding: $sp-1 $sp-2;
}
// 칸이 하나씩 채워진다 — ko 가 반 박자 앞선다
.stage.is-demo .pill { animation: fill-in $duration $easing infinite; animation-delay: calc(var(--i) * .16s); }
.stage.is-demo .pill--en { animation-delay: calc(var(--i) * .16s + .24s); }
.stage.is-demo .slot { animation: slot-alarm $duration $ease-out infinite; animation-delay: calc(var(--i) * .16s + .24s); }
.stage.is-demo .slot__badge { animation: badge-pop $duration $easing infinite; animation-delay: calc(var(--i) * .16s + .3s); }
@keyframes fill-in {
0% { transform: scale(0); opacity: 0; }
9% { transform: scale(1.12); opacity: 1; }
14%, 92% { transform: scale(1); opacity: 1; }
100% { transform: scale(.7); opacity: 0; }
}
@keyframes slot-alarm {
0%, 12% { background: transparent; }
20% { background: rgba($error, .28); }
28% { background: rgba($error, .08); }
36% { background: rgba($error, .28); }
44%, 96% { background: rgba($error, .20); }
100% { background: transparent; }
}
@keyframes badge-pop {
0%, 12% { transform: scale(0) rotate(0deg); opacity: 0; }
20% { transform: scale(1.25) rotate(0deg); opacity: 1; }
26% { transform: scale(1) rotate(0deg); }
36% { transform: scale(1) rotate(8deg); }
44% { transform: scale(1) rotate(-8deg); }
52% { transform: scale(1) rotate(4deg); }
58%, 94% { transform: scale(1) rotate(0deg); opacity: 1; }
100% { transform: scale(.5); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .pill,
.stage.is-demo .slot,
.stage.is-demo .slot__badge { animation: none; }
.slot { background: rgba($error, .20); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.board {
width: min(400px, 92vw);
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 16px;
}
.col__head {
margin: 0 0 8px;
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(12px, 2.8vw, 15px);
font-weight: 700;
color: #17141a;
text-align: center;
}
.col__rows {
list-style: none;
margin: 0;
padding: 0;
display: grid;
row-gap: 8px;
}
.pill, .slot {
border-radius: 12px;
min-height: clamp(24px, 8vw, 34px);
display: flex;
align-items: center;
justify-content: center;
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(9.5px, 2.3vw, 13px);
font-weight: 700;
}
.pill {
background: #17141a;
color: #ffd23f;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.pill--en {
background: #fff7e6;
color: #17141a;
}
.slot {
position: relative;
border: 2px dashed rgba(194, 54, 26, 0.65);
}
.slot__badge {
border-radius: 999px;
background: #c2361a;
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 800;
padding: 4px 8px;
}
.stage.is-demo .pill {
animation: fill-in 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
animation-delay: calc(var(--i) * 0.16s);
}
.stage.is-demo .pill--en {
animation-delay: calc(var(--i) * 0.16s + 0.24s);
}
.stage.is-demo .slot {
animation: slot-alarm 2s ease-out infinite;
animation-delay: calc(var(--i) * 0.16s + 0.24s);
}
.stage.is-demo .slot__badge {
animation: badge-pop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
animation-delay: calc(var(--i) * 0.16s + 0.3s);
}
@keyframes fill-in {
0% {
transform: scale(0);
opacity: 0;
}
9% {
transform: scale(1.12);
opacity: 1;
}
14%, 92% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(0.7);
opacity: 0;
}
}
@keyframes slot-alarm {
0%, 12% {
background: transparent;
}
20% {
background: rgba(194, 54, 26, 0.28);
}
28% {
background: rgba(194, 54, 26, 0.08);
}
36% {
background: rgba(194, 54, 26, 0.28);
}
44%, 96% {
background: rgba(194, 54, 26, 0.2);
}
100% {
background: transparent;
}
}
@keyframes badge-pop {
0%, 12% {
transform: scale(0) rotate(0deg);
opacity: 0;
}
20% {
transform: scale(1.25) rotate(0deg);
opacity: 1;
}
26% {
transform: scale(1) rotate(0deg);
}
36% {
transform: scale(1) rotate(8deg);
}
44% {
transform: scale(1) rotate(-8deg);
}
52% {
transform: scale(1) rotate(4deg);
}
58%, 94% {
transform: scale(1) rotate(0deg);
opacity: 1;
}
100% {
transform: scale(0.5);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .pill,
.stage.is-demo .slot,
.stage.is-demo .slot__badge {
animation: none;
}
.slot {
background: rgba(194, 54, 26, 0.2);
}
}import type { CSSProperties } from "react";
import styles from "./MissingKeyCheck.module.scss";
export type MissingKeyCheckProps = {
duration?: number;
easing?: string;
color?: string;
badge?: string;
};
const KO_KEYS = ["nav.home", "nav.about", "nav.cart", "nav.mypage"];
const EN_KEYS = ["nav.home", "nav.about", "nav.cart"];
export default function MissingKeyCheck({
duration = 2000,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#17141a",
badge = "누락",
}: MissingKeyCheckProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.stage} style={vars} aria-label="한쪽 언어에만 있는 키 데모" role="img">
<div className={styles.board}>
<div className={styles.col}>
<p className={styles.head}>ko</p>
<ul className={styles.rows}>
{KO_KEYS.map((k, i) => (
<li key={k} className={styles.pill} style={{ "--i": i } as CSSProperties}>{k}</li>
))}
</ul>
</div>
<div className={styles.col}>
<p className={styles.head}>en</p>
<ul className={styles.rows}>
{EN_KEYS.map((k, i) => (
<li key={k} className={`${styles.pill} ${styles.pillEn}`} style={{ "--i": i } as CSSProperties}>{k}</li>
))}
<li className={styles.slot} style={{ "--i": 3 } as CSSProperties}>
<span className={styles.badge}>{badge}</span>
</li>
</ul>
</div>
</div>
</div>
);
}02 · 한쪽 언어에만 있는 키 — 측정 누적면적 52.565% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
영어판에서만 버튼이 비어 있거나 새 키를 한국어에만 추가했을 때 이 검사가 답을 준다.
const onlyIn = (a, b, label) => {
const set = new Set(b);
return a.filter((k) => !set.has(k)).map((k) => `${label}: ${k}`);
};
const gaps = [
...onlyIn(ko, en, "ko에만"),
...onlyIn(en, ko, "en에만"),
];
console.log(gaps);
// → ["ko에만: nav.mypage"] 처럼 한쪽에만 있는 키가 줄로 나온다
03{이름} 자리표시자 불일치
어떤 움직임인가
문장 속 {name} 칩이 형광펜처럼 강조되며 언어 사이에 같은 칩이 있는지 훑고, 빠진 문장에는 경고 배지가 달라붙는다.

<main class="stage is-demo">
<div class="stack">
<section class="card">
<div class="lines">
<p class="line line--ko"><span class="ph" style="--i:0">{name}</span><span class="txt" data-t="님, 주문이 "></span><span class="ph" style="--i:1">{count}</span><span class="txt" data-t="건 접수되었습니다"></span></p>
<p class="line line--en"><span class="ph" style="--i:0">{count}</span><span class="txt" data-t=" orders placed for "></span><span class="ph" style="--i:1">{name}</span></p>
</div>
<span class="verdict verdict--ok">일치</span>
</section>
<section class="card">
<div class="lines">
<p class="line line--ko"><span class="ph" style="--i:0">{date}</span><span class="txt" data-t="에 환불이 완료되었습니다"></span></p>
<p class="line line--en"><span class="txt" data-t="Refund completed"></span><span class="ph ph--ghost" style="--i:0">{date}</span></p>
</div>
<span class="verdict verdict--ng">불일치</span>
</section>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 형광펜 한 주기
$easing: $ease-out;
$color: $subject-blue; // 자리표시자 칩
@include stage($stage-orange);
.stack {
width: min(440px, 94vw);
display: grid;
row-gap: $sp-3;
}
.card {
position: relative;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
padding: $sp-2 $sp-3;
display: grid;
row-gap: $sp-1;
}
.line {
margin: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
column-gap: $sp-1;
row-gap: $sp-1;
font-size: clamp(9.5px, 2.4vw, 13.5px);
font-weight: 600;
color: $subject-ink;
}
.line--ko { font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; }
.line--en { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; }
.txt::before { content: attr(data-t); }
.ph {
border-radius: $r-xs;
background: $color;
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-weight: 700;
font-size: clamp(9px, 2.2vw, 12.5px);
padding: 0 $sp-1;
min-height: 16px;
display: inline-flex;
align-items: center;
}
.ph--ghost {
background: transparent;
color: rgba($subject-ink, .72); // .38 은 크림 면 위 2.41:1 이었다 — AA 4.5
border: 2px dashed rgba($error, .6);
}
.verdict {
position: absolute;
top: -$sp-2;
right: $sp-2;
border-radius: $r-pill;
font-size: clamp(9px, 2.2vw, 12px);
font-weight: 800;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: $sp-1 $sp-2;
box-shadow: $shadow-press;
}
.verdict--ok { background: #50a384; color: $subject-ink; } // 흰 글자는 3.40:1 — 잉크로 5.36:1
.verdict--ng { background: $error; color: #fff; }
// 형광펜이 칩을 차례로 짚는다
.stage.is-demo .ph { animation: mark $duration $easing infinite; animation-delay: calc(var(--i) * .2s); }
.stage.is-demo .ph--ghost { animation-name: mark-ghost; }
.stage.is-demo .verdict--ok { animation: ok-in $duration $ease-pop infinite; }
.stage.is-demo .verdict--ng { animation: ng-in $duration $easing infinite; }
@keyframes mark {
0%, 8% { filter: brightness(1); transform: scale(1); }
16% { filter: brightness(1.45); transform: scale(1.14); }
26%, 92%{ filter: brightness(1); transform: scale(1); }
100% { filter: brightness(1); transform: scale(.9); }
}
@keyframes mark-ghost {
0%, 36% { transform: scale(1); opacity: .55; }
46% { transform: scale(1.14); opacity: 1; }
56%, 92%{ transform: scale(1); opacity: 1; }
100% { transform: scale(.9); opacity: .55; }
}
@keyframes ok-in {
0%, 30% { transform: scale(0) rotate(0deg); opacity: 0; }
38% { transform: scale(1.2) rotate(0deg); opacity: 1; }
44%, 94%{ transform: scale(1) rotate(0deg); opacity: 1; }
100% { transform: scale(.5); opacity: 0; }
}
@keyframes ng-in {
0%, 46% { transform: scale(0) rotate(0deg); opacity: 0; }
54% { transform: scale(1.25) rotate(0deg); opacity: 1; }
60% { transform: scale(1) rotate(0deg); }
68% { transform: scale(1) rotate(9deg); }
76% { transform: scale(1) rotate(-9deg); }
84% { transform: scale(1) rotate(5deg); }
90%, 96%{ transform: scale(1) rotate(0deg); opacity: 1; }
100% { transform: scale(.5); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .ph,
.stage.is-demo .verdict--ok,
.stage.is-demo .verdict--ng { animation: none; }
}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);
}
.stack {
width: min(440px, 94vw);
display: grid;
row-gap: 12px;
}
.card {
position: relative;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 8px 12px;
display: grid;
row-gap: 4px;
}
.line {
margin: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
column-gap: 4px;
row-gap: 4px;
font-size: clamp(9.5px, 2.4vw, 13.5px);
font-weight: 600;
color: #17141a;
}
.line--ko {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.line--en {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
}
.txt::before {
content: attr(data-t);
}
.ph {
border-radius: 4px;
background: #2f6df6;
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-weight: 700;
font-size: clamp(9px, 2.2vw, 12.5px);
padding: 0 4px;
min-height: 16px;
display: inline-flex;
align-items: center;
}
.ph--ghost {
background: transparent;
color: rgba(23, 20, 26, 0.72);
border: 2px dashed rgba(194, 54, 26, 0.6);
}
.verdict {
position: absolute;
top: -8px;
right: 8px;
border-radius: 999px;
font-size: clamp(9px, 2.2vw, 12px);
font-weight: 800;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: 4px 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.verdict--ok {
background: #50a384;
color: #17141a;
}
.verdict--ng {
background: #c2361a;
color: #fff;
}
.stage.is-demo .ph {
animation: mark 2s ease-out infinite;
animation-delay: calc(var(--i) * 0.2s);
}
.stage.is-demo .ph--ghost {
animation-name: mark-ghost;
}
.stage.is-demo .verdict--ok {
animation: ok-in 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.stage.is-demo .verdict--ng {
animation: ng-in 2s ease-out infinite;
}
@keyframes mark {
0%, 8% {
filter: brightness(1);
transform: scale(1);
}
16% {
filter: brightness(1.45);
transform: scale(1.14);
}
26%, 92% {
filter: brightness(1);
transform: scale(1);
}
100% {
filter: brightness(1);
transform: scale(0.9);
}
}
@keyframes mark-ghost {
0%, 36% {
transform: scale(1);
opacity: 0.55;
}
46% {
transform: scale(1.14);
opacity: 1;
}
56%, 92% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(0.9);
opacity: 0.55;
}
}
@keyframes ok-in {
0%, 30% {
transform: scale(0) rotate(0deg);
opacity: 0;
}
38% {
transform: scale(1.2) rotate(0deg);
opacity: 1;
}
44%, 94% {
transform: scale(1) rotate(0deg);
opacity: 1;
}
100% {
transform: scale(0.5);
opacity: 0;
}
}
@keyframes ng-in {
0%, 46% {
transform: scale(0) rotate(0deg);
opacity: 0;
}
54% {
transform: scale(1.25) rotate(0deg);
opacity: 1;
}
60% {
transform: scale(1) rotate(0deg);
}
68% {
transform: scale(1) rotate(9deg);
}
76% {
transform: scale(1) rotate(-9deg);
}
84% {
transform: scale(1) rotate(5deg);
}
90%, 96% {
transform: scale(1) rotate(0deg);
opacity: 1;
}
100% {
transform: scale(0.5);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .ph,
.stage.is-demo .verdict--ok,
.stage.is-demo .verdict--ng {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./PlaceholderMismatch.module.scss";
export type PlaceholderMismatchProps = {
duration?: number;
easing?: string;
color?: string;
okLabel?: string;
ngLabel?: string;
};
export default function PlaceholderMismatch({
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
okLabel = "일치",
ngLabel = "불일치",
}: PlaceholderMismatchProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.stage} style={vars} aria-label="자리표시자 불일치 데모" role="img">
<div className={styles.stack}>
<section className={styles.card}>
<div className={styles.lines}>
<p className={`${styles.line} ${styles.lineKo}`}>
<span className={styles.ph} style={{ "--i": 0 } as CSSProperties}>{"{name}"}</span>
<span className={styles.txt}>님, 주문이 </span>
<span className={styles.ph} style={{ "--i": 1 } as CSSProperties}>{"{count}"}</span>
<span className={styles.txt}>건 접수되었습니다</span>
</p>
<p className={`${styles.line} ${styles.lineEn}`}>
<span className={styles.ph} style={{ "--i": 0 } as CSSProperties}>{"{count}"}</span>
<span className={styles.txt}> orders placed for </span>
<span className={styles.ph} style={{ "--i": 1 } as CSSProperties}>{"{name}"}</span>
</p>
</div>
<span className={`${styles.verdict} ${styles.ok}`}>{okLabel}</span>
</section>
<section className={styles.card}>
<div className={styles.lines}>
<p className={`${styles.line} ${styles.lineKo}`}>
<span className={styles.ph} style={{ "--i": 0 } as CSSProperties}>{"{date}"}</span>
<span className={styles.txt}>에 환불이 완료되었습니다</span>
</p>
<p className={`${styles.line} ${styles.lineEn}`}>
<span className={styles.txt}>Refund completed</span>
<span className={`${styles.ph} ${styles.ghost}`} style={{ "--i": 0 } as CSSProperties}>{"{date}"}</span>
</p>
</div>
<span className={`${styles.verdict} ${styles.ng}`}>{ngLabel}</span>
</section>
</div>
</div>
);
}03 · {이름} 자리표시자 불일치 — 측정 누적면적 6.635% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
인사말에서 이름이 안 들어가는 언어가 있거나 수량·날짜 변수가 그대로 튀어나올 때 쓴다.
const ph = (s) => [...String(s).matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
const sameSet = (a, b) =>
a.length === b.length && a.every((x) => b.includes(x));
const koOrder = "{name}님, 주문이 {count}건 접수되었습니다";
const enOrder = "{count} orders placed for {name}";
console.log(sameSet(ph(koOrder), ph(enOrder))); // true
const koRefund = "{date}에 환불이 완료되었습니다";
const enRefund = "Refund completed";
console.log(sameSet(ph(koRefund), ph(enRefund))); // false: {date} 누락
04번역 안 된 한글 잔존
무엇이 보이나
스캔 라인이 영어 문장 줄을 위에서 아래로 훑으며 한글 음절만 붉게 물들이고 잔존 개수를 센다.

<main class="stage is-demo">
<div class="doc">
<header class="doc__head"><span class="doc__name">en.json</span></header>
<ul class="doc__rows">
<li class="ln" style="--i:0"><span class="ln__txt" data-t='"welcome": "Welcome back!"'></span></li>
<li class="ln" style="--i:1"><span class="ln__txt" data-t='"orders": "View your orders"'></span></li>
<li class="ln ln--hit" style="--i:2"><span class="ln__txt" data-t='"shipping": "배송이 시작되었습니다."'></span></li>
<li class="ln" style="--i:3"><span class="ln__txt" data-t='"account": "My account"'></span></li>
<li class="ln ln--hit" style="--i:4"><span class="ln__txt" data-t='"refund": "환불이 처리되었습니다."'></span></li>
</ul>
<span class="doc__scan"></span>
<p class="doc__badge">한글 잔존 2건</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 스캔 한 주기
$easing: $ease-out;
$color: $subject-blue; // 스캔 라인
@include stage($stage-paper);
.doc {
position: relative;
width: min(410px, 94vw);
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
border: 2px solid rgba($subject-ink, .10);
overflow: hidden;
}
.doc__head {
padding: $sp-1 $sp-3;
border-bottom: 2px solid rgba($subject-ink, .10);
}
.doc__name {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 700;
color: $subject-ink;
}
.doc__rows {
list-style: none;
margin: 0;
padding: $sp-2 $sp-3 $sp-2;
display: grid;
row-gap: $sp-1;
}
.ln {
border-radius: $r-xs;
padding: $sp-1;
min-height: clamp(18px, 6vw, 30px);
display: flex;
align-items: center;
}
.ln__txt {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(8.5px, 2.1vw, 12.5px);
color: $subject-ink;
white-space: nowrap;
}
.ln__txt::before { content: attr(data-t); }
.doc__scan {
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(to bottom,
transparent 0 calc(50% - 7px),
rgba($color, .30) calc(50% - 7px) calc(50% + 7px),
transparent calc(50% + 7px) 100%);
background-size: 100% 220%;
opacity: 0;
}
.doc__badge {
position: absolute;
right: $sp-2;
bottom: $sp-2;
margin: 0;
border-radius: $r-pill;
background: $error;
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 800;
padding: $sp-1 $sp-3;
box-shadow: $shadow-press;
}
// 스캔 라인이 내려가며 줄을 훑고, 한글 줄은 지나가는 순간 붉게 굳는다
.stage.is-demo .doc__scan { animation: scan-down $duration $easing infinite; }
.stage.is-demo .ln { animation: ln-pass $duration linear infinite; animation-delay: calc(var(--i) * .19s); }
.stage.is-demo .ln--hit { animation-name: ln-hit; }
.stage.is-demo .doc__badge { animation: badge-in $duration $ease-pop infinite; }
@keyframes scan-down {
0% { opacity: 1; background-position: 0 -130%; }
70% { opacity: 1; background-position: 0 130%; }
75%, 100% { opacity: 0; background-position: 0 130%; }
}
@keyframes ln-pass {
0%, 6% { background: transparent; }
15% { background: rgba($color, .18); }
26%, 100% { background: transparent; }
}
@keyframes ln-hit {
0%, 6% { background: transparent; }
15% { background: rgba($error, .45); }
26% { background: rgba($error, .18); }
36% { background: rgba($error, .45); }
46%, 100% { background: rgba($error, .32); }
}
@keyframes badge-in {
0%, 64% { transform: scale(0) translateY(4px); opacity: 0; }
72% { transform: scale(1.2) translateY(0); opacity: 1; }
78%, 95%{ transform: scale(1) translateY(0); opacity: 1; }
100% { transform: scale(.5); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .doc__scan,
.stage.is-demo .ln,
.stage.is-demo .doc__badge { animation: none; }
.doc__scan { opacity: 0; }
.ln--hit { background: rgba($error, .32); }
}
// 좁은 화면(320px 검수) — 줄 간격을 줄여 세로로 맞춘다
@media (max-width: 359px) {
.doc__head { padding: 0 $sp-2; }
.doc__rows { padding: $sp-1 $sp-2; row-gap: $sp-1; }
.ln { min-height: 16px; padding: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.doc {
position: relative;
width: min(410px, 94vw);
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 2px solid rgba(23, 20, 26, 0.1);
overflow: hidden;
}
.doc__head {
padding: 4px 12px;
border-bottom: 2px solid rgba(23, 20, 26, 0.1);
}
.doc__name {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 700;
color: #17141a;
}
.doc__rows {
list-style: none;
margin: 0;
padding: 8px 12px 8px;
display: grid;
row-gap: 4px;
}
.ln {
border-radius: 4px;
padding: 4px;
min-height: clamp(18px, 6vw, 30px);
display: flex;
align-items: center;
}
.ln__txt {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(8.5px, 2.1vw, 12.5px);
color: #17141a;
white-space: nowrap;
}
.ln__txt::before {
content: attr(data-t);
}
.doc__scan {
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(to bottom, transparent 0 calc(50% - 7px), rgba(47, 109, 246, 0.3) calc(50% - 7px) calc(50% + 7px), transparent calc(50% + 7px) 100%);
background-size: 100% 220%;
opacity: 0;
}
.doc__badge {
position: absolute;
right: 8px;
bottom: 8px;
margin: 0;
border-radius: 999px;
background: #c2361a;
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 800;
padding: 4px 12px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.stage.is-demo .doc__scan {
animation: scan-down 2s ease-out infinite;
}
.stage.is-demo .ln {
animation: ln-pass 2s linear infinite;
animation-delay: calc(var(--i) * 0.19s);
}
.stage.is-demo .ln--hit {
animation-name: ln-hit;
}
.stage.is-demo .doc__badge {
animation: badge-in 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes scan-down {
0% {
opacity: 1;
background-position: 0 -130%;
}
70% {
opacity: 1;
background-position: 0 130%;
}
75%, 100% {
opacity: 0;
background-position: 0 130%;
}
}
@keyframes ln-pass {
0%, 6% {
background: transparent;
}
15% {
background: rgba(47, 109, 246, 0.18);
}
26%, 100% {
background: transparent;
}
}
@keyframes ln-hit {
0%, 6% {
background: transparent;
}
15% {
background: rgba(194, 54, 26, 0.45);
}
26% {
background: rgba(194, 54, 26, 0.18);
}
36% {
background: rgba(194, 54, 26, 0.45);
}
46%, 100% {
background: rgba(194, 54, 26, 0.32);
}
}
@keyframes badge-in {
0%, 64% {
transform: scale(0) translateY(4px);
opacity: 0;
}
72% {
transform: scale(1.2) translateY(0);
opacity: 1;
}
78%, 95% {
transform: scale(1) translateY(0);
opacity: 1;
}
100% {
transform: scale(0.5);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .doc__scan,
.stage.is-demo .ln,
.stage.is-demo .doc__badge {
animation: none;
}
.doc__scan {
opacity: 0;
}
.ln--hit {
background: rgba(194, 54, 26, 0.32);
}
}
@media (max-width: 359px) {
.doc__head {
padding: 0 8px;
}
.doc__rows {
padding: 4px 8px;
row-gap: 4px;
}
.ln {
min-height: 16px;
padding: 0;
}
}import type { CSSProperties } from "react";
import styles from "./KoreanResidueScan.module.scss";
export type KoreanResidueScanProps = {
duration?: number;
easing?: string;
color?: string;
badge?: string;
};
const LINES = [
{ text: '"welcome": "Welcome back!"', hit: false },
{ text: '"orders": "View your orders"', hit: false },
{ text: '"shipping": "배송이 시작되었습니다."', hit: true },
{ text: '"account": "My account"', hit: false },
{ text: '"refund": "환불이 처리되었습니다."', hit: true },
];
export default function KoreanResidueScan({
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
badge = "한글 잔존 2건",
}: KoreanResidueScanProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.stage} style={vars} aria-label="번역 안 된 한글 잔존 데모" role="img">
<div className={styles.doc}>
<header className={styles.head}><span className={styles.name}>en.json</span></header>
<ul className={styles.rows}>
{LINES.map((l, i) => (
<li
key={i}
className={`${styles.ln} ${l.hit ? styles.hit : ""}`}
style={{ "--i": i } as CSSProperties}
>
<span className={styles.txt}>{l.text}</span>
</li>
))}
</ul>
<span className={styles.scan} />
<p className={styles.badge}>{badge}</p>
</div>
</div>
);
}04 · 번역 안 된 한글 잔존 — 측정 누적면적 59.183% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
기계번역을 돌리고 나서, 영어판에서 한국어가 그대로 보일 때 걸리는 문제다.
const hangul = /[가-힣]/;
const enJson = await (await fetch("/locales/en.json")).json();
const residue = Object.entries(enJson).filter(
([k, v]) => typeof v === "string" && hangul.test(v)
);
console.log(residue);
// → [["shipping", "배송이 시작되었습니다."], ["refund", "환불이 처리되었습니다."]]
05언어팩을 붙이는 t() 함수
화면에서 일어나는 일
키 카드가 t() 상자를 지나 현재 언어의 문구로 바뀌어 나오는 흐름을 보여준다.

<main class="stage is-demo">
<div class="pipe">
<div class="node node--key"><span class="node__tag">key</span><span class="node__txt">cta.start</span></div>
<span class="wire wire--in"></span>
<div class="node node--fn"><span class="node__txt">t()</span></div>
<span class="wire wire--out"></span>
<div class="node node--out"><span class="node__tag">ko</span><span class="node__val">시작하기</span></div>
</div>
<div class="belt" aria-hidden="true"></div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 변환 흐름 한 주기
$easing: $ease-out;
$color: $subject-blue; // t() 상자
@include stage($stage-ink);
.stage { display: grid; place-items: center; align-content: center; row-gap: $sp-4; }
.pipe {
display: flex;
align-items: center;
column-gap: $sp-2;
width: min(450px, 94vw);
}
.node {
position: relative;
border-radius: $r-card;
min-height: clamp(44px, 13vw, 64px);
flex: 1;
display: grid;
place-items: center;
align-content: center;
row-gap: $sp-1;
padding: $sp-1;
}
.node__tag {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(8px, 1.9vw, 10.5px);
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
opacity: 1; // .62 는 크림 면 위 파랑/빨강 태그를 2.38:1 로 깨뜨렸다 — AA 4.5
}
.node--key { background: $subject-cream; color: $subject-ink; }
.node--key .node__tag { color: $subject-blue-deep; } // 밝은 면 위 파란 글자 — 5.98:1
.node--key .node__txt { color: $subject-ink; }
.node--fn { background: $color; box-shadow: $shadow-raised; flex: .8; }
.node--fn .node__txt {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(14px, 3.4vw, 20px);
font-weight: 700;
color: #fff;
}
.node--out { background: $subject-cream; }
.node--out .node__tag { color: $error; } // 크림 면 위 5.13:1 — 통과
.node__val {
font-size: clamp(13px, 3.2vw, 19px);
font-weight: 800;
color: $subject-ink;
}
.node__txt {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(10px, 2.5vw, 14.5px);
font-weight: 700;
}
.wire {
flex: .5;
height: 4px;
border-radius: $r-pill;
background: repeating-linear-gradient(90deg,
$subject-cream 0 10px,
rgba($subject-cream, .18) 10px 20px);
}
.belt {
width: min(450px, 94vw);
height: 10px;
border-radius: $r-pill;
background: repeating-linear-gradient(90deg,
rgba($subject-cream, .35) 0 14px,
rgba($subject-cream, .06) 14px 28px);
}
// 키가 들어가 t() 를 지나 문구로 나오는 흐름
.stage.is-demo .node--key { animation: key-pulse $duration $easing infinite; }
.stage.is-demo .wire--in { animation: wire-flow $duration linear infinite; }
.stage.is-demo .node--fn { animation: fn-glow $duration $easing infinite; }
.stage.is-demo .wire--out { animation: wire-flow $duration linear infinite; animation-delay: .34s; }
.stage.is-demo .node--out { animation: out-pop $duration $ease-pop infinite; }
.stage.is-demo .belt { animation: belt-run $dur-base linear infinite; }
@keyframes key-pulse {
0%, 4% { transform: scale(1); }
12% { transform: scale(1.06); }
20%, 100%{ transform: scale(1); }
}
@keyframes wire-flow {
0%, 8% { opacity: .25; background-position: 0 0; }
12% { opacity: 1; }
34% { opacity: 1; background-position: 40px 0; }
40%, 100%{ opacity: .25; background-position: 40px 0; }
}
@keyframes fn-glow {
0%, 26% { transform: scale(1); box-shadow: $shadow-press; }
38% { transform: scale(1.08); box-shadow: 0 0 0 8px rgba($subject-blue, .35), $shadow-raised; }
52%, 100%{ transform: scale(1); box-shadow: $shadow-press; }
}
@keyframes out-pop {
0%, 44% { transform: scale(.6); opacity: 0; }
56% { transform: scale(1.12); opacity: 1; }
62%, 94% { transform: scale(1); opacity: 1; }
100% { transform: scale(.7); opacity: 0; }
}
@keyframes belt-run {
0% { background-position: 0 0; }
100% { background-position: 28px 0; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .node--key,
.stage.is-demo .wire--in,
.stage.is-demo .node--fn,
.stage.is-demo .wire--out,
.stage.is-demo .node--out,
.stage.is-demo .belt { animation: none; }
.node--out { 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);
}
.stage {
display: grid;
place-items: center;
align-content: center;
row-gap: 16px;
}
.pipe {
display: flex;
align-items: center;
column-gap: 8px;
width: min(450px, 94vw);
}
.node {
position: relative;
border-radius: 16px;
min-height: clamp(44px, 13vw, 64px);
flex: 1;
display: grid;
place-items: center;
align-content: center;
row-gap: 4px;
padding: 4px;
}
.node__tag {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(8px, 1.9vw, 10.5px);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 1;
}
.node--key {
background: #fff7e6;
color: #17141a;
}
.node--key .node__tag {
color: #0a4fe8;
}
.node--key .node__txt {
color: #17141a;
}
.node--fn {
background: #2f6df6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
flex: 0.8;
}
.node--fn .node__txt {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(14px, 3.4vw, 20px);
font-weight: 700;
color: #fff;
}
.node--out {
background: #fff7e6;
}
.node--out .node__tag {
color: #c2361a;
}
.node__val {
font-size: clamp(13px, 3.2vw, 19px);
font-weight: 800;
color: #17141a;
}
.node__txt {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(10px, 2.5vw, 14.5px);
font-weight: 700;
}
.wire {
flex: 0.5;
height: 4px;
border-radius: 999px;
background: repeating-linear-gradient(90deg, #fff7e6 0 10px, rgba(255, 247, 230, 0.18) 10px 20px);
}
.belt {
width: min(450px, 94vw);
height: 10px;
border-radius: 999px;
background: repeating-linear-gradient(90deg, rgba(255, 247, 230, 0.35) 0 14px, rgba(255, 247, 230, 0.06) 14px 28px);
}
.stage.is-demo .node--key {
animation: key-pulse 2s ease-out infinite;
}
.stage.is-demo .wire--in {
animation: wire-flow 2s linear infinite;
}
.stage.is-demo .node--fn {
animation: fn-glow 2s ease-out infinite;
}
.stage.is-demo .wire--out {
animation: wire-flow 2s linear infinite;
animation-delay: 0.34s;
}
.stage.is-demo .node--out {
animation: out-pop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.stage.is-demo .belt {
animation: belt-run 300ms linear infinite;
}
@keyframes key-pulse {
0%, 4% {
transform: scale(1);
}
12% {
transform: scale(1.06);
}
20%, 100% {
transform: scale(1);
}
}
@keyframes wire-flow {
0%, 8% {
opacity: 0.25;
background-position: 0 0;
}
12% {
opacity: 1;
}
34% {
opacity: 1;
background-position: 40px 0;
}
40%, 100% {
opacity: 0.25;
background-position: 40px 0;
}
}
@keyframes fn-glow {
0%, 26% {
transform: scale(1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
38% {
transform: scale(1.08);
box-shadow: 0 0 0 8px rgba(47, 109, 246, 0.35), 0 14px 24px rgba(23, 20, 26, 0.32);
}
52%, 100% {
transform: scale(1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
@keyframes out-pop {
0%, 44% {
transform: scale(0.6);
opacity: 0;
}
56% {
transform: scale(1.12);
opacity: 1;
}
62%, 94% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(0.7);
opacity: 0;
}
}
@keyframes belt-run {
0% {
background-position: 0 0;
}
100% {
background-position: 28px 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .node--key,
.stage.is-demo .wire--in,
.stage.is-demo .node--fn,
.stage.is-demo .wire--out,
.stage.is-demo .node--out,
.stage.is-demo .belt {
animation: none;
}
.node--out {
opacity: 1;
}
}import type { CSSProperties } from "react";
import styles from "./TFunctionFlow.module.scss";
export type TFunctionFlowProps = {
duration?: number;
easing?: string;
color?: string;
keyName?: string;
output?: string;
};
export default function TFunctionFlow({
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
keyName = "cta.start",
output = "시작하기",
}: TFunctionFlowProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.stage} style={vars} aria-label="t() 함수 변환 흐름 데모" role="img">
<div className={styles.pipe}>
<div className={`${styles.node} ${styles.nodeKey}`}>
<span className={styles.tag}>key</span>
<span className={styles.keyTxt}>{keyName}</span>
</div>
<span className={`${styles.wire} ${styles.wireIn}`} />
<div className={`${styles.node} ${styles.nodeFn}`}>
<span className={styles.fnTxt}>t()</span>
</div>
<span className={`${styles.wire} ${styles.wireOut}`} />
<div className={`${styles.node} ${styles.nodeOut}`}>
<span className={styles.tagOut}>ko</span>
<span className={styles.val}>{output}</span>
</div>
</div>
<div className={styles.belt} aria-hidden="true" />
</div>
);
}05 · 언어팩을 붙이는 t() 함수 — 측정 누적면적 14.826% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
언어팩 파일을 화면에 연결할 때, 키 오타로 빈 문구가 나올 때 이 t() 함수가 기준이 된다.
const get = async (l) => await (await fetch(`/locales/${l}.json`)).json();
const packs = { ko: await get("ko"), en: await get("en"), ja: await get("ja") };
let lang = "ko";
function t(key, vars = {}) {
let s = packs[lang][key] ?? packs.ko[key] ?? key;
for (const [k, v] of Object.entries(vars)) {
s = s.replaceAll(`{${k}}`, v);
}
return s;
}
console.log(t("cta.start")); // 시작하기
console.log(t("cta.startX")); // cta.startX 그대로 → 오타 신호
06언어팩을 안 타는 하드코딩 글자
어떤 움직임인가
코드 줄을 스캔 라인이 차례로 지나가는데, 태그 사이에 박힌 하드코딩 한글 줄만 붉게 잡혀 굳는다.

<main class="stage is-demo">
<div class="editor">
<header class="editor__head"><span class="editor__dot"></span><span class="editor__name">index.html</span></header>
<ul class="editor__rows">
<li class="cl" style="--i:0"><span class="cl__bar"></span><span class="cl__txt" data-t="<button>구매하기</button>"></span></li>
<li class="cl" style="--i:1"><span class="cl__bar"></span><span class="cl__txt" data-t="<button>{t('buy.now')}</button>"></span></li>
<li class="cl" style="--i:2"><span class="cl__bar"></span><span class="cl__txt" data-t="<h2>자주 묻는 질문</h2>"></span></li>
<li class="cl" style="--i:3"><span class="cl__bar"></span><span class="cl__txt" data-t="<a href="/en/">{t('nav.faq')}</a>"></span></li>
<li class="cl" style="--i:4"><span class="cl__bar"></span><span class="cl__txt" data-t="<p>배송 안내</p>"></span></li>
</ul>
<span class="editor__scan"></span>
<p class="editor__badge">하드코딩 3곳</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 소스 스캔 한 주기
$easing: $ease-out;
$color: $subject-ink; // 에디터 카드
@include stage($stage-yellow);
.editor {
position: relative;
width: min(420px, 94vw);
border-radius: $r-card;
background: $color;
box-shadow: $shadow-raised;
overflow: hidden;
}
.editor__head {
display: flex;
align-items: center;
column-gap: $sp-2;
padding: $sp-1 $sp-3;
border-bottom: 2px solid rgba($subject-cream, .14);
}
.editor__dot {
width: 10px; height: 10px;
border-radius: 50%;
background: $stage-yellow;
}
.editor__name {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 700;
color: rgba($subject-cream, .85);
}
.editor__rows {
list-style: none;
margin: 0;
padding: $sp-2 $sp-3 $sp-2;
display: grid;
row-gap: $sp-1;
}
.cl {
position: relative;
border-radius: $r-xs;
padding: $sp-1 $sp-1 $sp-1 $sp-2;
min-height: clamp(18px, 6vw, 30px);
display: flex;
align-items: center;
}
.cl__bar {
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 4px;
border-radius: $r-pill;
background: transparent;
}
.cl__txt {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(8.5px, 2.1vw, 12px);
color: rgba($subject-cream, .92);
white-space: nowrap;
}
.cl__txt::before { content: attr(data-t); }
.editor__scan {
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(to bottom,
transparent 0 calc(50% - 7px),
rgba($stage-yellow, .30) calc(50% - 7px) calc(50% + 7px),
transparent calc(50% + 7px) 100%);
background-size: 100% 220%;
opacity: 0;
}
.editor__badge {
position: absolute;
right: $sp-2;
bottom: $sp-2;
margin: 0;
border-radius: $r-pill;
background: $error;
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 800;
padding: $sp-1 $sp-3;
box-shadow: $shadow-press;
}
// t() 를 안 타는 줄만 스캔 라인이 지나가는 순간 잡아 굳는다
.stage.is-demo .editor__scan { animation: scan-down $duration $easing infinite; }
.stage.is-demo .cl { animation: cl-pass $duration linear infinite; animation-delay: calc(var(--i) * .19s); }
.stage.is-demo .cl__bar { animation: bar-grow $duration $easing infinite; animation-delay: calc(var(--i) * .19s); }
.stage.is-demo .editor__badge { animation: badge-in $duration $ease-pop infinite; }
@keyframes scan-down {
0% { opacity: 1; background-position: 0 -130%; }
70% { opacity: 1; background-position: 0 130%; }
75%, 100% { opacity: 0; background-position: 0 130%; }
}
@keyframes cl-pass {
0%, 6% { background: transparent; }
15% { background: rgba($stage-yellow, .16); }
26%, 100% { background: transparent; }
}
@keyframes bar-grow {
0%, 10% { transform: scaleY(0); background: $error; }
20% { transform: scaleY(1); background: $error; }
100% { transform: scaleY(1); background: $error; }
}
@keyframes badge-in {
0%, 64% { transform: scale(0) translateY(4px); opacity: 0; }
72% { transform: scale(1.2) translateY(0); opacity: 1; }
78%, 95%{ transform: scale(1) translateY(0); opacity: 1; }
100% { transform: scale(.5); opacity: 0; }
}
// 줄 구분: 하드코딩 줄(1·3·5)만 붉은 왼쪽 막대를 얻는다
.stage.is-demo .cl:nth-child(2) .cl__bar,
.stage.is-demo .cl:nth-child(4) .cl__bar { animation: none; }
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .editor__scan,
.stage.is-demo .cl,
.stage.is-demo .cl__bar,
.stage.is-demo .editor__badge { animation: none; }
.editor__scan { opacity: 0; }
.cl:nth-child(1), .cl:nth-child(3), .cl:nth-child(5) { background: rgba($error, .30); }
.cl:nth-child(1) .cl__bar, .cl:nth-child(3) .cl__bar, .cl:nth-child(5) .cl__bar { transform: scaleY(1); background: $error; }
}
// 좁은 화면(320px 검수) — 줄 간격을 줄여 세로로 맞춘다
@media (max-width: 359px) {
.editor__head { padding: 0 $sp-2; }
.editor__rows { padding: $sp-1 $sp-2; row-gap: $sp-1; }
.cl { min-height: 16px; padding: 0 0 0 $sp-2; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.editor {
position: relative;
width: min(420px, 94vw);
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.editor__head {
display: flex;
align-items: center;
column-gap: 8px;
padding: 4px 12px;
border-bottom: 2px solid rgba(255, 247, 230, 0.14);
}
.editor__dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #ffd23f;
}
.editor__name {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 700;
color: rgba(255, 247, 230, 0.85);
}
.editor__rows {
list-style: none;
margin: 0;
padding: 8px 12px 8px;
display: grid;
row-gap: 4px;
}
.cl {
position: relative;
border-radius: 4px;
padding: 4px 4px 4px 8px;
min-height: clamp(18px, 6vw, 30px);
display: flex;
align-items: center;
}
.cl__bar {
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 4px;
border-radius: 999px;
background: transparent;
}
.cl__txt {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(8.5px, 2.1vw, 12px);
color: rgba(255, 247, 230, 0.92);
white-space: nowrap;
}
.cl__txt::before {
content: attr(data-t);
}
.editor__scan {
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(to bottom, transparent 0 calc(50% - 7px), rgba(255, 210, 63, 0.3) calc(50% - 7px) calc(50% + 7px), transparent calc(50% + 7px) 100%);
background-size: 100% 220%;
opacity: 0;
}
.editor__badge {
position: absolute;
right: 8px;
bottom: 8px;
margin: 0;
border-radius: 999px;
background: #c2361a;
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 800;
padding: 4px 12px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.stage.is-demo .editor__scan {
animation: scan-down 2s ease-out infinite;
}
.stage.is-demo .cl {
animation: cl-pass 2s linear infinite;
animation-delay: calc(var(--i) * 0.19s);
}
.stage.is-demo .cl__bar {
animation: bar-grow 2s ease-out infinite;
animation-delay: calc(var(--i) * 0.19s);
}
.stage.is-demo .editor__badge {
animation: badge-in 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes scan-down {
0% {
opacity: 1;
background-position: 0 -130%;
}
70% {
opacity: 1;
background-position: 0 130%;
}
75%, 100% {
opacity: 0;
background-position: 0 130%;
}
}
@keyframes cl-pass {
0%, 6% {
background: transparent;
}
15% {
background: rgba(255, 210, 63, 0.16);
}
26%, 100% {
background: transparent;
}
}
@keyframes bar-grow {
0%, 10% {
transform: scaleY(0);
background: #c2361a;
}
20% {
transform: scaleY(1);
background: #c2361a;
}
100% {
transform: scaleY(1);
background: #c2361a;
}
}
@keyframes badge-in {
0%, 64% {
transform: scale(0) translateY(4px);
opacity: 0;
}
72% {
transform: scale(1.2) translateY(0);
opacity: 1;
}
78%, 95% {
transform: scale(1) translateY(0);
opacity: 1;
}
100% {
transform: scale(0.5);
opacity: 0;
}
}
.stage.is-demo .cl:nth-child(2) .cl__bar,
.stage.is-demo .cl:nth-child(4) .cl__bar {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .editor__scan,
.stage.is-demo .cl,
.stage.is-demo .cl__bar,
.stage.is-demo .editor__badge {
animation: none;
}
.editor__scan {
opacity: 0;
}
.cl:nth-child(1), .cl:nth-child(3), .cl:nth-child(5) {
background: rgba(194, 54, 26, 0.3);
}
.cl:nth-child(1) .cl__bar, .cl:nth-child(3) .cl__bar, .cl:nth-child(5) .cl__bar {
transform: scaleY(1);
background: #c2361a;
}
}
@media (max-width: 359px) {
.editor__head {
padding: 0 8px;
}
.editor__rows {
padding: 4px 8px;
row-gap: 4px;
}
.cl {
min-height: 16px;
padding: 0 0 0 8px;
}
}import type { CSSProperties } from "react";
import styles from "./HardcodedScan.module.scss";
export type HardcodedScanProps = {
duration?: number;
easing?: string;
color?: string;
badge?: string;
};
const LINES = [
{ code: "<button>구매하기</button>", hard: true },
{ code: "<button>{t('buy.now')}</button>", hard: false },
{ code: "<h2>자주 묻는 질문</h2>", hard: true },
{ code: "<a href=\"/en/\">{t('nav.faq')}</a>", hard: false },
{ code: "<p>배송 안내</p>", hard: true },
];
export default function HardcodedScan({
duration = 2000,
easing = "ease-out",
color = "#17141a",
badge = "하드코딩 3곳",
}: HardcodedScanProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.stage} style={vars} aria-label="하드코딩 글자 스캔 데모" role="img">
<div className={styles.editor}>
<header className={styles.head}>
<span className={styles.dot} />
<span className={styles.name}>index.html</span>
</header>
<ul className={styles.rows}>
{LINES.map((l, i) => (
<li
key={i}
className={`${styles.cl} ${l.hard ? styles.hard : ""}`}
style={{ "--i": i } as CSSProperties}
>
<span className={styles.bar} />
<span className={styles.txt}>{l.code}</span>
</li>
))}
</ul>
<span className={styles.scan} />
<p className={styles.badge}>{badge}</p>
</div>
</div>
);
}06 · 언어팩을 안 타는 하드코딩 글자 — 측정 누적면적 58.947% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
언어를 바꿔도 안 바뀌는 글자가 남을 때, AI 가 넣어준 안내문을 점검할 때 돌린다.
// 태그 사이에 박힌 한글 = 언어팩을 안 탄 글자
const between = />\s*[가-힣][^<]*</g;
const hits = [...new Set(
(document.body.innerHTML.match(between) || [])
.map((s) => s.slice(1, -1).trim())
)];
console.log(hits);
// → ["구매하기", "자주 묻는 질문", "배송 안내"]
07없는 키가 화면에 그대로 노출
무엇이 보이나
화면 카드에 welcome.title 원시 키가 노출되었다가 폴백 문구로 교체되길 반복해 두 상태를 나란히 보여준다.

<main class="stage is-demo">
<div class="win">
<header class="win__bar"><span class="win__dot"></span><span class="win__dot"></span><span class="win__dot"></span></header>
<div class="win__body">
<div class="slot">
<p class="slot__raw">welcome.title</p>
<p class="slot__fb">다시 오신 걸 환영합니다</p>
</div>
<span class="win__chip">폴백 적용</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 키 노출 ↔ 폴백 한 주기
$easing: $ease-out;
$color: $subject-cream; // 창 카드
@include stage($stage-orange);
.win {
width: min(330px, 92vw);
border-radius: $r-card;
background: $color;
box-shadow: $shadow-raised;
overflow: hidden;
}
.win__bar {
display: flex;
column-gap: $sp-1;
padding: $sp-2 $sp-3 0;
}
.win__dot {
width: 10px; height: 10px;
border-radius: 50%;
background: rgba($subject-ink, .18);
}
.win__body {
position: relative;
padding: $sp-3 $sp-4 $sp-5;
min-height: clamp(96px, 32vw, 150px);
display: grid;
place-items: center;
}
.slot {
position: relative;
width: 100%;
min-height: 64px;
}
.slot__raw, .slot__fb {
position: absolute;
inset: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.slot__raw {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(13px, 3.4vw, 20px);
font-weight: 700;
color: $error;
border: 2px dashed rgba($error, .5);
border-radius: $r-control;
}
.slot__fb {
font-size: clamp(14px, 3.8vw, 22px);
font-weight: 800;
color: $subject-ink;
background: rgba($subject-blue, .14);
border-radius: $r-control;
transform: translateY(14px) scale(.94);
opacity: 0;
}
.win__chip {
position: absolute;
right: $sp-2;
bottom: $sp-2;
border-radius: $r-pill;
background: $subject-blue;
color: #fff;
font-size: clamp(9px, 2.2vw, 12px);
font-weight: 800;
padding: $sp-1 $sp-2;
opacity: 0;
}
// 원시 키가 빠지고, 그 자리에 폴백 문구가 뜬다 — 순서대로만 (겹침 없음)
.stage.is-demo .slot__raw { animation: raw-out $duration $easing infinite; }
.stage.is-demo .slot__fb { animation: fb-in $duration $ease-pop infinite; }
.stage.is-demo .win__chip { animation: chip-in $duration $ease-pop infinite; }
@keyframes raw-out {
0%, 20% { transform: translateY(0) scale(1); opacity: 1; }
28% { transform: translateY(-16px) scale(.94); opacity: 0; }
88% { transform: translateY(-16px) scale(.94); opacity: 0; }
96%, 100%{ transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes fb-in {
0%, 30% { transform: translateY(14px) scale(.9); opacity: 0; }
42% { transform: translateY(0) scale(1.04); opacity: 1; }
48%, 80% { transform: translateY(0) scale(1); opacity: 1; }
88%, 100%{ transform: translateY(14px) scale(.9); opacity: 0; }
}
@keyframes chip-in {
0%, 38% { transform: scale(0); opacity: 0; }
48% { transform: scale(1.18); opacity: 1; }
54%, 78% { transform: scale(1); opacity: 1; }
86%, 100%{ transform: scale(.4); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .slot__raw,
.stage.is-demo .slot__fb,
.stage.is-demo .win__chip { animation: none; }
.slot__raw { transform: translateY(-16px); opacity: 0; }
.slot__fb { transform: translateY(0) scale(1); opacity: 1; }
.win__chip { opacity: 1; }
}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);
}
.win {
width: min(330px, 92vw);
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.win__bar {
display: flex;
column-gap: 4px;
padding: 8px 12px 0;
}
.win__dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.18);
}
.win__body {
position: relative;
padding: 12px 16px 20px;
min-height: clamp(96px, 32vw, 150px);
display: grid;
place-items: center;
}
.slot {
position: relative;
width: 100%;
min-height: 64px;
}
.slot__raw, .slot__fb {
position: absolute;
inset: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.slot__raw {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(13px, 3.4vw, 20px);
font-weight: 700;
color: #c2361a;
border: 2px dashed rgba(194, 54, 26, 0.5);
border-radius: 12px;
}
.slot__fb {
font-size: clamp(14px, 3.8vw, 22px);
font-weight: 800;
color: #17141a;
background: rgba(47, 109, 246, 0.14);
border-radius: 12px;
transform: translateY(14px) scale(0.94);
opacity: 0;
}
.win__chip {
position: absolute;
right: 8px;
bottom: 8px;
border-radius: 999px;
background: #2f6df6;
color: #fff;
font-size: clamp(9px, 2.2vw, 12px);
font-weight: 800;
padding: 4px 8px;
opacity: 0;
}
.stage.is-demo .slot__raw {
animation: raw-out 2s ease-out infinite;
}
.stage.is-demo .slot__fb {
animation: fb-in 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.stage.is-demo .win__chip {
animation: chip-in 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes raw-out {
0%, 20% {
transform: translateY(0) scale(1);
opacity: 1;
}
28% {
transform: translateY(-16px) scale(0.94);
opacity: 0;
}
88% {
transform: translateY(-16px) scale(0.94);
opacity: 0;
}
96%, 100% {
transform: translateY(0) scale(1);
opacity: 1;
}
}
@keyframes fb-in {
0%, 30% {
transform: translateY(14px) scale(0.9);
opacity: 0;
}
42% {
transform: translateY(0) scale(1.04);
opacity: 1;
}
48%, 80% {
transform: translateY(0) scale(1);
opacity: 1;
}
88%, 100% {
transform: translateY(14px) scale(0.9);
opacity: 0;
}
}
@keyframes chip-in {
0%, 38% {
transform: scale(0);
opacity: 0;
}
48% {
transform: scale(1.18);
opacity: 1;
}
54%, 78% {
transform: scale(1);
opacity: 1;
}
86%, 100% {
transform: scale(0.4);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .slot__raw,
.stage.is-demo .slot__fb,
.stage.is-demo .win__chip {
animation: none;
}
.slot__raw {
transform: translateY(-16px);
opacity: 0;
}
.slot__fb {
transform: translateY(0) scale(1);
opacity: 1;
}
.win__chip {
opacity: 1;
}
}import type { CSSProperties } from "react";
import styles from "./FallbackKeySwap.module.scss";
export type FallbackKeySwapProps = {
duration?: number;
easing?: string;
color?: string;
rawKey?: string;
fallback?: string;
chip?: string;
};
export default function FallbackKeySwap({
duration = 2000,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#fff7e6",
rawKey = "welcome.title",
fallback = "다시 오신 걸 환영합니다",
chip = "폴백 적용",
}: FallbackKeySwapProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.stage} style={vars} aria-label="폴백 처리 데모" role="img">
<div className={styles.win}>
<header className={styles.bar}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
</header>
<div className={styles.body}>
<div className={styles.slot}>
<p className={styles.raw}>{rawKey}</p>
<p className={styles.fb}>{fallback}</p>
</div>
<span className={styles.chip}>{chip}</span>
</div>
</div>
</div>
);
}07 · 없는 키가 화면에 그대로 노출 — 측정 누적면적 16.472% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
버튼 자리에 header.title 같은 영문이 그대로 보일 때, 폴백 언어 규칙을 정할 때 쓴다.
const keyShape = /^[a-z0-9]+(\.[a-z0-9]+)+$/;
const leaks = [...document.querySelectorAll("button, a, h2, p, span")]
.filter((el) => el.children.length === 0)
.map((el) => el.textContent.trim())
.filter((s) => keyShape.test(s));
console.log(leaks); // → ["welcome.title"] 처럼 키 모양 그대로 노출된 글자
08언어별 주소와 hreflang
화면에서 일어나는 일
/, /en/, /ja/ 세 주소 사이를 링크 신호가 순회하다가 끊긴 방향에서 붉은 물음표가 뜬다.

<main class="stage is-demo">
<figure class="graph">
<svg class="graph__svg" viewBox="0 0 400 250" role="img" aria-label="locale url graph">
<path class="arc arc--base" d="M200 42 Q120 120 78 208" />
<path class="arc arc--base" d="M200 42 Q280 120 322 208" />
<path class="arc arc--link" d="M78 208 Q200 262 322 208" />
<path class="arc arc--broken" d="M322 208 Q200 170 78 208" />
<path class="arc arc--pulse" d="M78 208 Q120 120 200 42 Q280 120 322 208 Q200 262 78 208" />
<text class="qmark" x="200" y="182">?</text>
<g class="node">
<rect x="166" y="26" width="68" height="32" rx="16" />
<text x="200" y="47">/</text>
</g>
<g class="node">
<rect x="46" y="192" width="64" height="32" rx="16" />
<text x="78" y="213">/en/</text>
</g>
<g class="node">
<rect x="290" y="192" width="64" height="32" rx="16" />
<text x="322" y="213">/ja/</text>
</g>
</svg>
<figcaption class="graph__note">hreflang 없음</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 링크 순회 한 주기
$easing: $ease-out;
$color: $subject-blue; // 정상 링크
@include stage($stage-paper);
.graph {
position: relative;
margin: 0;
width: min(360px, 80vw);
}
.graph__svg { width: 100%; height: auto; display: block; }
.arc { fill: none; }
.arc--base { stroke: rgba($subject-ink, .22); stroke-width: 2; }
.arc--link { stroke: $color; stroke-width: 3; }
.arc--broken {
stroke: $error;
stroke-width: 2;
stroke-dasharray: 6 6;
}
.arc--pulse {
stroke: $color;
stroke-width: 5;
stroke-linecap: round;
stroke-dasharray: 18 900;
stroke-dashoffset: 0;
opacity: 0;
}
.qmark {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 20px;
font-weight: 700;
fill: $error;
text-anchor: middle;
opacity: 0;
}
.node rect {
fill: $subject-ink;
rx: 16;
}
.node text {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 14px;
font-weight: 700;
fill: #fff;
text-anchor: middle;
}
.graph__note {
position: absolute;
left: 50%;
top: 55%;
transform: translate(-50%, -50%);
margin: 0;
border-radius: $r-pill;
background: $error-soft;
color: $error;
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 800;
padding: $sp-1 $sp-3;
opacity: 0;
}
// 링크 신호가 세 주소를 순회하고, 끊긴 돌아오는 길에서 물음표가 뜬다
.stage.is-demo .arc--pulse { animation: pulse-run $duration linear infinite; }
.stage.is-demo .arc--broken { animation: broken-blink $duration $easing infinite; }
.stage.is-demo .qmark { animation: q-pop $duration $ease-pop infinite; }
.stage.is-demo .graph__note { animation: note-pop $duration $ease-pop infinite; }
@keyframes pulse-run {
0% { opacity: 1; stroke-dashoffset: 918; }
88% { opacity: 1; stroke-dashoffset: 0; }
94%, 100% { opacity: 0; stroke-dashoffset: 0; }
}
@keyframes broken-blink {
0%, 56% { opacity: .45; }
64% { opacity: 1; }
72% { opacity: .45; }
80% { opacity: 1; }
88%, 100% { opacity: .8; }
}
@keyframes q-pop {
0%, 56% { opacity: 0; transform: scale(.4); transform-origin: 200px 182px; } // 규칙8: SVG 좌표로 글자 중심 지정
66% { opacity: 1; transform: scale(1.25); transform-origin: 200px 182px; } // 규칙8: SVG 좌표로 글자 중심 지정
72%, 94%{ opacity: 1; transform: scale(1); transform-origin: 200px 182px; } // 규칙8: SVG 좌표로 글자 중심 지정
100% { opacity: 0; transform: scale(.5); transform-origin: 200px 182px; } // 규칙8: SVG 좌표로 글자 중심 지정
}
@keyframes note-pop {
0%, 60% { opacity: 0; transform: translate(-50%, -36%) scale(.6); }
70% { opacity: 1; transform: translate(-50%, -50%) scale(1.12); }
76%, 94%{ opacity: 1; transform: translate(-50%, -50%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -36%) scale(.6); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .arc--pulse,
.stage.is-demo .arc--broken,
.stage.is-demo .qmark,
.stage.is-demo .graph__note { animation: none; }
.arc--pulse { opacity: 0; }
.arc--broken { opacity: .8; }
.qmark { opacity: 1; }
.graph__note { opacity: 1; transform: translate(-50%, -50%); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.graph {
position: relative;
margin: 0;
width: min(360px, 80vw);
}
.graph__svg {
width: 100%;
height: auto;
display: block;
}
.arc {
fill: none;
}
.arc--base {
stroke: rgba(23, 20, 26, 0.22);
stroke-width: 2;
}
.arc--link {
stroke: #2f6df6;
stroke-width: 3;
}
.arc--broken {
stroke: #c2361a;
stroke-width: 2;
stroke-dasharray: 6 6;
}
.arc--pulse {
stroke: #2f6df6;
stroke-width: 5;
stroke-linecap: round;
stroke-dasharray: 18 900;
stroke-dashoffset: 0;
opacity: 0;
}
.qmark {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 20px;
font-weight: 700;
fill: #c2361a;
text-anchor: middle;
opacity: 0;
}
.node rect {
fill: #17141a;
rx: 16;
}
.node text {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 14px;
font-weight: 700;
fill: #fff;
text-anchor: middle;
}
.graph__note {
position: absolute;
left: 50%;
top: 55%;
transform: translate(-50%, -50%);
margin: 0;
border-radius: 999px;
background: #ffe0d8;
color: #c2361a;
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 800;
padding: 4px 12px;
opacity: 0;
}
.stage.is-demo .arc--pulse {
animation: pulse-run 2s linear infinite;
}
.stage.is-demo .arc--broken {
animation: broken-blink 2s ease-out infinite;
}
.stage.is-demo .qmark {
animation: q-pop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.stage.is-demo .graph__note {
animation: note-pop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes pulse-run {
0% {
opacity: 1;
stroke-dashoffset: 918;
}
88% {
opacity: 1;
stroke-dashoffset: 0;
}
94%, 100% {
opacity: 0;
stroke-dashoffset: 0;
}
}
@keyframes broken-blink {
0%, 56% {
opacity: 0.45;
}
64% {
opacity: 1;
}
72% {
opacity: 0.45;
}
80% {
opacity: 1;
}
88%, 100% {
opacity: 0.8;
}
}
@keyframes q-pop {
0%, 56% {
opacity: 0;
transform: scale(0.4);
transform-origin: 200px 182px;
}
66% {
opacity: 1;
transform: scale(1.25);
transform-origin: 200px 182px;
}
72%, 94% {
opacity: 1;
transform: scale(1);
transform-origin: 200px 182px;
}
100% {
opacity: 0;
transform: scale(0.5);
transform-origin: 200px 182px;
}
}
@keyframes note-pop {
0%, 60% {
opacity: 0;
transform: translate(-50%, -36%) scale(0.6);
}
70% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.12);
}
76%, 94% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -36%) scale(0.6);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .arc--pulse,
.stage.is-demo .arc--broken,
.stage.is-demo .qmark,
.stage.is-demo .graph__note {
animation: none;
}
.arc--pulse {
opacity: 0;
}
.arc--broken {
opacity: 0.8;
}
.qmark {
opacity: 1;
}
.graph__note {
opacity: 1;
transform: translate(-50%, -50%);
}
}import type { CSSProperties } from "react";
import styles from "./LocaleUrlGraph.module.scss";
export type LocaleUrlGraphProps = {
duration?: number;
easing?: string;
color?: string;
note?: string;
};
export default function LocaleUrlGraph({
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
note = "hreflang 없음",
}: LocaleUrlGraphProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.stage} style={vars} aria-label="언어별 주소와 hreflang 데모" role="img">
<figure className={styles.graph}>
<svg className={styles.svg} viewBox="0 0 400 250" role="img" aria-label="locale url graph">
<path className={styles.arcBase} d="M200 42 Q120 120 78 208" />
<path className={styles.arcBase} d="M200 42 Q280 120 322 208" />
<path className={styles.arcLink} d="M78 208 Q200 262 322 208" />
<path className={styles.arcBroken} d="M322 208 Q200 170 78 208" />
<path className={styles.arcPulse} d="M78 208 Q120 120 200 42 Q280 120 322 208 Q200 262 78 208" />
<text className={styles.qmark} x="200" y="182">?</text>
<g>
<rect className={styles.nodeRect} x="166" y="26" width="68" height="32" rx="16" />
<text className={styles.nodeTxt} x="200" y="47">/</text>
</g>
<g>
<rect className={styles.nodeRect} x="46" y="192" width="64" height="32" rx="16" />
<text className={styles.nodeTxt} x="78" y="213">/en/</text>
</g>
<g>
<rect className={styles.nodeRect} x="290" y="192" width="64" height="32" rx="16" />
<text className={styles.nodeTxt} x="322" y="213">/ja/</text>
</g>
</svg>
<figcaption className={styles.graphNote}>{note}</figcaption>
</figure>
</div>
);
}08 · 언어별 주소와 hreflang — 측정 누적면적 3.252% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
구글에 영어판만 색인되거나 언어 탭을 눌렀는데 404 가 뜰 때 이 페이지의 link 태그를 점검한다.
const want = ["ko", "en", "ja"];
const have = new Set(
[...document.querySelectorAll('link[rel="alternate"][hreflang]')]
.map((t) => t.getAttribute("hreflang"))
);
const missing = want.filter((l) => !have.has(l));
console.log(missing); // → ["ja"] 면 ja 로 건너갈 다리가 이 페이지에 없다
09날짜·숫자 형식이 언어마다 다를 때
어떤 움직임인가
같은 날짜 카드가 ko·en·ja 서식으로 플립처럼 뒤집히며 세 가지 표기를 차례로 보여준다. 주문내역의 날짜가 이상하게 보이거나 가격의 쉼표 자리가 어긋날 때 쓴다.

<main class="stage is-demo">
<div class="flipwrap">
<div class="flip">
<div class="face face--ko"><span class="face__tag">ko</span><span class="face__date">2026. 9. 11.</span></div>
<div class="face face--en"><span class="face__tag">en</span><span class="face__date">Sep 11, 2026</span></div>
<div class="face face--ja"><span class="face__tag">ja</span><span class="face__date">2026/09/11</span></div>
</div>
<p class="langs"><span class="langs__item" style="--i:0">ko</span><span class="langs__item" style="--i:1">en</span><span class="langs__item" style="--i:2">ja</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 세 서식 한 바퀴
$easing: $ease-out;
$color: $subject-cream; // 플립 카드
@include stage($stage-ink);
.flipwrap {
display: grid;
justify-items: center;
row-gap: $sp-3;
perspective: 640px;
}
.flip {
position: relative;
width: min(250px, 84vw);
height: clamp(84px, 30vw, 118px);
transform-style: preserve-3d;
}
.face {
position: absolute;
inset: 0;
border-radius: $r-card;
background: $color;
box-shadow: $shadow-raised;
display: grid;
place-items: center;
align-content: center;
row-gap: $sp-1;
backface-visibility: hidden;
}
.face__tag {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(9px, 2.2vw, 11.5px);
font-weight: 700;
letter-spacing: .1em;
color: $subject-ink; // 크림 면 위 — 파랑은 4.25:1 미달이라 잉크로
}
.face__date {
font-size: clamp(19px, 5.2vw, 31px);
font-weight: 800;
color: $subject-ink;
white-space: nowrap;
}
.langs {
display: flex;
column-gap: $sp-2;
margin: 0;
}
.langs__item {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 700;
color: rgba($subject-cream, .72); // .34 는 먹 무대 위 2.99:1 이었다 — AA 4.5
border-radius: $r-pill;
padding: 0 $sp-2;
}
// 같은 날짜가 세 서식으로 뒤집힌다
.stage.is-demo .face--ko { animation: flap $duration $easing infinite; }
.stage.is-demo .face--en { animation: flap $duration $easing infinite; animation-delay: calc($duration / -3); }
.stage.is-demo .face--ja { animation: flap $duration $easing infinite; animation-delay: calc($duration / -3 * 2); }
.stage.is-demo .langs__item { animation: lang-on $duration linear infinite; animation-delay: calc(var(--i) * #{$duration / 3}); }
@keyframes flap {
0% { transform: rotateX(-92deg); opacity: 0; }
6% { opacity: 1; }
12% { transform: rotateX(0deg); }
27% { transform: rotateX(0deg); opacity: 1; }
34% { transform: rotateX(92deg); opacity: 0; }
100% { transform: rotateX(92deg); opacity: 0; }
}
@keyframes lang-on {
0%, 4% { color: rgba($subject-cream, .72); transform: scale(1); }
10% { color: $subject-cream; transform: scale(1.12); }
16% { color: $subject-cream; transform: scale(1); }
30% { color: $subject-cream; }
36%, 100%{ color: rgba($subject-cream, .72); transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .face--ko,
.stage.is-demo .face--en,
.stage.is-demo .face--ja,
.stage.is-demo .langs__item { animation: none; }
.face--en, .face--ja { opacity: 0; }
.face--ko { transform: rotateX(0deg); }
}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);
}
.flipwrap {
display: grid;
justify-items: center;
row-gap: 12px;
perspective: 640px;
}
.flip {
position: relative;
width: min(250px, 84vw);
height: clamp(84px, 30vw, 118px);
transform-style: preserve-3d;
}
.face {
position: absolute;
inset: 0;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: grid;
place-items: center;
align-content: center;
row-gap: 4px;
backface-visibility: hidden;
}
.face__tag {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(9px, 2.2vw, 11.5px);
font-weight: 700;
letter-spacing: 0.1em;
color: #17141a;
}
.face__date {
font-size: clamp(19px, 5.2vw, 31px);
font-weight: 800;
color: #17141a;
white-space: nowrap;
}
.langs {
display: flex;
column-gap: 8px;
margin: 0;
}
.langs__item {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: clamp(10px, 2.4vw, 13px);
font-weight: 700;
color: rgba(255, 247, 230, 0.72);
border-radius: 999px;
padding: 0 8px;
}
.stage.is-demo .face--ko {
animation: flap 2s ease-out infinite;
}
.stage.is-demo .face--en {
animation: flap 2s ease-out infinite;
animation-delay: -0.6666666667s;
}
.stage.is-demo .face--ja {
animation: flap 2s ease-out infinite;
animation-delay: -1.3333333333s;
}
.stage.is-demo .langs__item {
animation: lang-on 2s linear infinite;
animation-delay: calc(var(--i) * 0.6666666667s);
}
@keyframes flap {
0% {
transform: rotateX(-92deg);
opacity: 0;
}
6% {
opacity: 1;
}
12% {
transform: rotateX(0deg);
}
27% {
transform: rotateX(0deg);
opacity: 1;
}
34% {
transform: rotateX(92deg);
opacity: 0;
}
100% {
transform: rotateX(92deg);
opacity: 0;
}
}
@keyframes lang-on {
0%, 4% {
color: rgba(255, 247, 230, 0.72);
transform: scale(1);
}
10% {
color: #fff7e6;
transform: scale(1.12);
}
16% {
color: #fff7e6;
transform: scale(1);
}
30% {
color: #fff7e6;
}
36%, 100% {
color: rgba(255, 247, 230, 0.72);
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .face--ko,
.stage.is-demo .face--en,
.stage.is-demo .face--ja,
.stage.is-demo .langs__item {
animation: none;
}
.face--en, .face--ja {
opacity: 0;
}
.face--ko {
transform: rotateX(0deg);
}
}import type { CSSProperties } from "react";
import styles from "./DateFormatFlip.module.scss";
export type DateFormatFlipProps = {
duration?: number;
easing?: string;
color?: string;
formats?: { lang: string; date: string }[];
};
const DEFAULT_FORMATS = [
{ lang: "ko", date: "2026. 9. 11." },
{ lang: "en", date: "Sep 11, 2026" },
{ lang: "ja", date: "2026/09/11" },
];
export default function DateFormatFlip({
duration = 2000,
easing = "ease-out",
color = "#fff7e6",
formats = DEFAULT_FORMATS,
}: DateFormatFlipProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const faceClass = [styles.faceKo, styles.faceEn, styles.faceJa];
return (
<div className={styles.stage} style={vars} aria-label="날짜 형식 플립 데모" role="img">
<div className={styles.flipwrap}>
<div className={styles.flip}>
{formats.slice(0, 3).map((f, i) => (
<div key={f.lang} className={`${styles.face} ${faceClass[i]}`}>
<span className={styles.tag}>{f.lang}</span>
<span className={styles.date}>{f.date}</span>
</div>
))}
</div>
<p className={styles.langs}>
{formats.slice(0, 3).map((f, i) => (
<span key={f.lang} className={styles.langsItem} style={{ "--i": i } as CSSProperties}>{f.lang}</span>
))}
</p>
</div>
</div>
);
}09 · 날짜·숫자 형식이 언어마다 다를 때 — 측정 누적면적 21.252% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
서식을 손으로 짜지 말고 Intl 에 맡기면 끝난다.
const d = new Date(2026, 8, 11); // 9월 11일 (달은 0부터 센다)
const price = 1234567;
for (const lang of ["ko", "en", "ja"]) {
const date = new Intl.DateTimeFormat(lang, { dateStyle: "long" }).format(d);
const num = new Intl.NumberFormat(lang).format(price);
console.log(lang, date, num);
}
어디서 깨지는가 — 점 찍힌 영어는 다 키로 보인다
07번 검사를 이 사이트에 직접 돌렸을 때 있었던 일이다. 화면에 노출된 원시 키는 잡아냈지만, 링크 글자가 그냥 "godrichstory.com" 인 부분도 같이 걸렸다. 점으로 이어진 소문자라는 키의 생김새가 도메인과 완전히 같기 때문이다. 키 모양만으로 걸면 이런 오탐이 반드시 섞인다. 해법은 걸린 문구를 언어팩 키 목록과 다시 대조하는 것 — 02번의 flatten 결과에 있는 이름이면 진짜 누수이고, 없으면 도메인이나 버전 표기처럼 원래 점 찍힌 영문이다. zip 을 열 때 묻는 비밀번호는 fnn2hd5e 인데, 화면에 보이는 대로 그대로 입력하면 아홉 항목 폴더가 한 번에 풀린다. 08번에도 짝이 있는 함정이 있다 — hreflang 태그가 페이지마다 있는지만 보면 놓치는 게 언어별 주소가 서로를 가리키는지다. 이 페이지에서 ja 로 건너갈 수 있어도 ja 페이지에서 돌아오는 링크가 없으면 검색엔진은 그 관계를 쌍으로 보지 않는다.
접근성
아홉 개 전부 prefers-reduced-motion: reduce 에서 animation: none 으로 떨어지되, 멈춘 자리에서도 뜻이 읽히도록 정지 값을 정해 뒀다. 01·02·04·06번은 스캔·채우기 움직임만 멈추고 빈 칸과 붉은 잔존·배지가 그대로 남아 무엇이 문제인지 보인다. 07번은 폴백 문구가 적용된 상태로 멈추고, 08번은 끊긴 호와 물음표가 보이는 상태로 멈춘다. 09번은 ko 서식 카드를 정지 화면으로 보여준다. 이 설정의 구조는 MDN 의 prefers-reduced-motion 문서 에 정리돼 있다.
FAQ
이 검사들은 코드를 몰라도 쓸 수 있나요?
네, 아홉 개 전부 브라우저의 개발자 도구 콘솔에 그대로 붙여 넣으면 결과가 바로 찍힙니다. zip 안에는 이 검사들을 한 파일로 묶은 스크립트도 넣어 두어서, 사이트 주소만 바꾸면 됩니다. 이 사이트가 데모와 스크립트를 어떻게 검증하는지는 소개 페이지 에 적어 두었습니다.
검사를 돌리면 사이트가 바뀌거나 느려지나요?
아니요, 전부 화면과 파일을 읽기만 합니다. 값을 고치거나 서버로 보내는 명령은 없습니다. fetch 로 언어팩 JSON 을 가져오는 부분도 브라우저가 이미 받아 둔 파일을 다시 읽는 수준이라 체감 속도는 그대로입니다.
다국어 지원 api 없이 React 로 만든 사이트에서도 그대로 되나요?
됩니다. 01~04번은 언어팩 JSON 파일만 보므로 화면을 무엇으로 만들었는지와 무관합니다. 05번의 t() 는 바닐라 예시이고 react-i18next 같은 라이브러리를 쓴다면 그 함수 기준으로 같은 생각을 적용하면 됩니다. 더 많은 검수 글은 사이트 검색 에서 "다국어"로 찾아볼 수 있습니다.









