08언어별 주소와 hreflang
/, /en/, /ja/ 세 노드 사이를 링크 궤적이 순회하며 서로를 가리키다가, 끊긴 한 방향에서만 붉은 물음표 표식이 떠 단방향 링크를 잡아낸다.
쓰임구글에 영어판만 색인될 때, 언어 탭을 눌렀는데 404 가 뜰 때
hreflang 쌍언어별 URLreturn 링크

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


