02한쪽 언어에만 있는 키
두 열의 키 칸이 순서대로 불이 들어오듯 채워지다가, 없는 쪽 칸은 그대로 어두운 채 붉은 결함 표시가 떠서 차이를 지목한다.
쓰임영어판에서만 버튼이 비어 있을 때, 새 키를 한국어에만 추가했을 때
animation-delay: calc()차집합Set

<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 포함 · 홈페이지 다국어 처리 검수 9가지 — 언어팩 키 누락·번역 누락 잡기
이 데모가 속한 글
홈페이지 다국어 처리 검수 9가지 — 언어팩 키 누락·번역 누락 잡기홈페이지 다국어 처리는 번역문을 언어팩 파일에 넣었다고 끝나지 않는다. 키가 한쪽에만 있으면 영어판 버튼이 빈 채로 남고, 자리표시자가 어긋나면 이름이 빠진 인사말이 나간다. 여기 아홉 가지 검사를 브라우저 콘솔에 이 글의 데모 전부 보기


