웹사이트 푸터 디자인 패턴 9종, 복사해서 바로 쓰는
웹사이트 푸터 디자인은 페이지 맨 아래에서 방문자를 붙잡거나 내보내는 마지막 화면입니다. 여기 모은 아홉 가지는 링크 나열로 끝나지 않는 푸터를 HTML 과 CSS 만으로 짜 둔 것이라, 그대로 복사해 붙이면 바로 움직입니다.
01 footer-sitemap-mega
02 footer-newsletter-signup
03 footer-oneline-minimal
04 footer-mobile-dock
05 footer-social-tiles
06 footer-linktree-steps
07 footer-contact-card
08 footer-wave-top
09 footer-ink-contrast
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 다단 사이트맵 푸터
- 02 뉴스레터 통합 푸터
- 03 미니멀 원라인 푸터
- 04 스티키 하단 앱바
- 05 소셜 타일 푸터
- 06 법적·링크 트리 푸터
- 07 연락처·지도 카드 푸터
- 08 웨이브 경계 푸터
- 09 다크 잉크 대비 푸터
아홉 개는 유행 순서가 아니라 방문자가 푸터에서 겪는 순서로 놓았습니다. 먼저 링크가 가장 많은 다단 사이트맵(01)으로 길을 펼치고, 그 옆에서 이메일을 받아 관계를 잇고(02), 반대로 아무것도 묻지 않고 한 줄로 닫는 쪽(03)을 보여 드립니다. 손이 화면 아래에 머무는 모바일에서는 푸터가 아예 조작바가 되고(04), 계정으로 나가는 길은 큼직한 타일(05)과 계단식 링크 트리(06)로 갈립니다. 마지막 셋은 푸터의 바탕 자체를 다룹니다 — 오시는 길을 지도 카드로 얹고(07), 본문과 푸터 사이의 경계를 물결로 잇고(08), 밝은 본문을 어두운 잉크 바닥으로 눌러 닫습니다(09). 아홉 개가 잉크 #1b1a20 · 크림 #fbf6ee · 아쿠아 #6ee7d7 라는 같은 색 값을 공유하므로 둘셋을 섞어 써도 색이 어긋나지 않습니다. 모바일 푸터 디자인을 따로 잡아야 하는 이유도 여기 있는데, 화면이 좁아지면 푸터가 목록이 아니라 손이 닿는 조작바가 되기 때문입니다. 이 목록이 한 페이지의 닫는 장이라면 여는 장은 전환을 끌어올리는 섹션과 히어로 섹션 패턴이 맡고 있습니다.
01다단 사이트맵 푸터
무엇이 보이나
넓은 화면에서는 링크 묶음 세 단이 나란히 서고, 폭이 좁아지면 각 단이 열리고 닫히는 아코디언이 됩니다. 여닫히는 것은 높이가 아니라 그리드 트랙입니다 — height: auto 는 중간값이 계산되지 않지만 0fr 과 1fr 사이는 계산되기 때문입니다.

<main class="stage">
<footer class="fsm is-demo" role="contentinfo">
<p class="fsm__brand">스튜디오</p>
<div class="fsm__cols">
<section class="fsm__col">
<h2 class="fsm__head">
<button class="fsm__btn" type="button" aria-expanded="true" aria-controls="fsm-p1">
<span class="fsm__btn-t">제품</span>
<svg class="fsm__caret" viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
</button>
</h2>
<div class="fsm__panel" id="fsm-p1">
<nav class="fsm__list" aria-label="제품 링크">
<a href="#">기능 소개</a>
<a href="#">요금 안내</a>
<a href="#">사용 사례</a>
</nav>
</div>
</section>
<section class="fsm__col">
<h2 class="fsm__head">
<button class="fsm__btn" type="button" aria-expanded="true" aria-controls="fsm-p2">
<span class="fsm__btn-t">회사</span>
<svg class="fsm__caret" viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
</button>
</h2>
<div class="fsm__panel" id="fsm-p2">
<nav class="fsm__list" aria-label="회사 링크">
<a href="#">회사 소개</a>
<a href="#">채용 정보</a>
<a href="#">보도 자료</a>
</nav>
</div>
</section>
<section class="fsm__col">
<h2 class="fsm__head">
<button class="fsm__btn" type="button" aria-expanded="true" aria-controls="fsm-p3">
<span class="fsm__btn-t">지원</span>
<svg class="fsm__caret" viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
</button>
</h2>
<div class="fsm__panel" id="fsm-p3">
<nav class="fsm__list" aria-label="지원 링크">
<a href="#">도움말</a>
<a href="#">문의 창구</a>
<a href="#">상태 알림</a>
</nav>
</div>
</section>
</div>
<p class="fsm__legal">© 2026 스튜디오</p>
</footer>
</main>@use "../../../../site/static/demo-tokens" as *;
// 272 푸터 팔레트 — 세 항목이 같은 값을 쓴다. 괄호 안은 실측 대비비
$ft-ink: #1b1a20; // 어두운 푸터 바닥
$ft-ink2: #2c2a33; // 바닥 위 구분선
$ft-paper: $stage-paper; // 흰 글자 on ink 17.28:1
$ft-cream: #fbf6ee; // 무대
$ft-mist: #b9b3c4; // 보조 글자 on ink 8.48:1
$ft-amber: $stage-yellow; // 브랜드 on ink 11.97:1
$ft-aqua: #b3dad5; // 캐럿·링크 강조 on ink 11.58:1
$duration: $dur-loop; // 세 단이 한 바퀴 열렸다 닫히는 주기
$easing: $ease-spring; // 목록이 펼쳐질 때의 감속
$color: $ft-cream; // 무대(크림)
@include stage($ft-cream);
.fsm {
box-sizing: border-box;
width: clamp(320px, 92vw, 432px);
padding: $sp-4;
border-radius: $r-card;
background: $ft-ink;
color: $ft-paper;
}
.fsm__brand {
margin: 0 0 $sp-3;
color: $ft-amber;
font-weight: 800; font-size: 15px; line-height: 1.2;
letter-spacing: .02em;
}
// 넓은 화면: 링크 묶음 세 단이 나란히. 단이 접혀도 바닥 높이가 흔들리지 않게 min-height 로 자리를 잡아 둔다
.fsm__cols {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: start;
gap: $sp-3;
min-height: 92px;
}
.fsm__head { margin: 0; }
.fsm__btn {
display: flex;
align-items: center;
justify-content: flex-start; // 캐럿은 열 이름 바로 옆 — 단 폭이 넓어도 멀어지지 않는다
gap: $sp-2;
width: 100%;
padding: 0;
border: 0;
border-radius: $r-xs;
background: none;
color: $ft-paper;
font-family: inherit;
font-weight: 700; font-size: 13px; line-height: 1.4;
text-align: left;
cursor: pointer;
}
.fsm__btn:focus-visible {
outline: 2px solid $ft-aqua;
outline-offset: 2px;
}
.fsm__caret {
flex: 0 0 auto;
color: $ft-aqua;
transition: transform $dur-quick $easing;
}
// 접힌 단의 캐럿은 오른쪽을 가리킨다 (정적 상태 규칙 — .is-demo 를 떼도 말이 된다)
.fsm__btn[aria-expanded="false"] .fsm__caret { transform: rotate(-90deg); }
// 여닫기의 본체: 0fr ↔ 1fr. 높이 auto 로는 보간이 안 되지만 grid 트랙은 보간된다
.fsm__panel {
display: grid;
grid-template-rows: 1fr;
overflow: hidden;
transition: grid-template-rows $dur-base $easing;
}
.fsm__panel > * { min-height: 0; }
.fsm__col:has(.fsm__btn[aria-expanded="false"]) .fsm__panel { grid-template-rows: 0fr; }
.fsm__list {
display: flex;
flex-direction: column;
gap: $sp-1;
padding-top: $sp-2;
}
.fsm__list a {
color: $ft-mist;
font-weight: 500; font-size: 12px; line-height: 1.4;
text-decoration: none;
}
.fsm__list a:hover { color: $ft-aqua; }
.fsm__list a:focus-visible {
outline: 2px solid $ft-aqua;
outline-offset: 2px;
border-radius: $r-xs;
}
.fsm__legal {
margin: $sp-3 0 0;
padding-top: $sp-3;
border-top: 1px solid $ft-ink2;
color: $ft-mist;
font-weight: 500; font-size: 11px; line-height: 1.4;
}
// 자동 재생 — 세 단이 1/3 주기씩 어긋나 차례로 열렸다 닫힌다.
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로 쓴다
.fsm.is-demo .fsm__panel,
.fsm.is-demo .fsm__caret {
animation-duration: $duration;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fsm.is-demo .fsm__panel {
animation-name: fsmSweep;
animation-timing-function: $easing;
transition: none;
}
.fsm.is-demo .fsm__caret {
animation-name: fsmCaret;
animation-timing-function: $easing;
transition: none;
}
.fsm.is-demo .fsm__col:nth-child(2) .fsm__panel,
.fsm.is-demo .fsm__col:nth-child(2) .fsm__caret { animation-delay: -.6667s; }
.fsm.is-demo .fsm__col:nth-child(3) .fsm__panel,
.fsm.is-demo .fsm__col:nth-child(3) .fsm__caret { animation-delay: -1.3333s; }
// 복귀는 90% 안에 끝낸다 — 24프레임의 마지막 장은 95.8% 지점이라 그 뒤는 샘플이 없다
@keyframes fsmSweep {
0% { grid-template-rows: 0fr; }
8%, 74% { grid-template-rows: 1fr; }
86%, 100% { grid-template-rows: 0fr; }
}
@keyframes fsmCaret {
0% { transform: rotate(-90deg); }
8%, 74% { transform: rotate(0deg); }
86%, 100% { transform: rotate(-90deg); }
}
// 좁은 화면: 각 단이 아코디언이 된다. 세로 여유가 174px 뿐이라 값을 올리지 않고 내린다
@media (max-width: 340px), (max-height: 220px) {
.fsm { width: 100%; padding: $sp-2; }
.fsm__brand { margin-bottom: $sp-1; font-size: 10px; }
.fsm__cols { grid-template-columns: 1fr; gap: $sp-1; min-height: 0; }
// 한 단이 한 줄을 통째로 쓰는 아코디언이 되므로 캐럿을 줄 오른쪽 끝으로 보낸다
.fsm__btn { justify-content: space-between; font-size: 11px; }
.fsm__list {
flex-direction: row;
flex-wrap: wrap;
gap: 0 $sp-2;
padding-top: $sp-1;
}
.fsm__list a { font-size: 10px; line-height: 1.3; }
.fsm__list a:nth-child(3) { display: none; }
.fsm__legal { margin-top: $sp-1; padding-top: $sp-1; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// .fsm.is-demo .fsm__panel (0,3,0) 을 이기려면 !important 가 필요하다
.fsm__panel, .fsm__caret { animation: none !important; transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #fbf6ee;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fsm {
box-sizing: border-box;
width: clamp(320px, 92vw, 432px);
padding: 16px;
border-radius: 16px;
background: #1b1a20;
color: #ffffff;
}
.fsm__brand {
margin: 0 0 12px;
color: #ffd23f;
font-weight: 800;
font-size: 15px;
line-height: 1.2;
letter-spacing: 0.02em;
}
.fsm__cols {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: start;
gap: 12px;
min-height: 92px;
}
.fsm__head {
margin: 0;
}
.fsm__btn {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
width: 100%;
padding: 0;
border: 0;
border-radius: 4px;
background: none;
color: #ffffff;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.4;
text-align: left;
cursor: pointer;
}
.fsm__btn:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 2px;
}
.fsm__caret {
flex: 0 0 auto;
color: #b3dad5;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fsm__btn[aria-expanded=false] .fsm__caret {
transform: rotate(-90deg);
}
.fsm__panel {
display: grid;
grid-template-rows: 1fr;
overflow: hidden;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fsm__panel > * {
min-height: 0;
}
.fsm__col:has(.fsm__btn[aria-expanded=false]) .fsm__panel {
grid-template-rows: 0fr;
}
.fsm__list {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 8px;
}
.fsm__list a {
color: #b9b3c4;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-decoration: none;
}
.fsm__list a:hover {
color: #b3dad5;
}
.fsm__list a:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 2px;
border-radius: 4px;
}
.fsm__legal {
margin: 12px 0 0;
padding-top: 12px;
border-top: 1px solid #2c2a33;
color: #b9b3c4;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.fsm.is-demo .fsm__panel,
.fsm.is-demo .fsm__caret {
animation-duration: 2s;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fsm.is-demo .fsm__panel {
animation-name: fsmSweep;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
transition: none;
}
.fsm.is-demo .fsm__caret {
animation-name: fsmCaret;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
transition: none;
}
.fsm.is-demo .fsm__col:nth-child(2) .fsm__panel,
.fsm.is-demo .fsm__col:nth-child(2) .fsm__caret {
animation-delay: -0.6667s;
}
.fsm.is-demo .fsm__col:nth-child(3) .fsm__panel,
.fsm.is-demo .fsm__col:nth-child(3) .fsm__caret {
animation-delay: -1.3333s;
}
@keyframes fsmSweep {
0% {
grid-template-rows: 0fr;
}
8%, 74% {
grid-template-rows: 1fr;
}
86%, 100% {
grid-template-rows: 0fr;
}
}
@keyframes fsmCaret {
0% {
transform: rotate(-90deg);
}
8%, 74% {
transform: rotate(0deg);
}
86%, 100% {
transform: rotate(-90deg);
}
}
@media (max-width: 340px), (max-height: 220px) {
.fsm {
width: 100%;
padding: 8px;
}
.fsm__brand {
margin-bottom: 4px;
font-size: 10px;
}
.fsm__cols {
grid-template-columns: 1fr;
gap: 4px;
min-height: 0;
}
.fsm__btn {
justify-content: space-between;
font-size: 11px;
}
.fsm__list {
flex-direction: row;
flex-wrap: wrap;
gap: 0 8px;
padding-top: 4px;
}
.fsm__list a {
font-size: 10px;
line-height: 1.3;
}
.fsm__list a:nth-child(3) {
display: none;
}
.fsm__legal {
margin-top: 4px;
padding-top: 4px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.fsm__panel, .fsm__caret {
animation: none !important;
transition: none !important;
}
}(function () {
var root = document.querySelector('.fsm');
var btns = [].slice.call(root.querySelectorAll('.fsm__btn'));
// 사람이 손을 대면 자동 루프를 끄고 aria-expanded 가 상태의 유일한 출처가 된다
function stopLoop() { root.classList.remove('is-demo'); }
btns.forEach(function (b, i) {
b.addEventListener('click', function () {
stopLoop();
b.setAttribute('aria-expanded', b.getAttribute('aria-expanded') === 'true' ? 'false' : 'true');
});
// 위아래 화살표로 열 머리 사이를 옮긴다. Enter·Space 는 button 이 기본으로 처리한다
b.addEventListener('keydown', function (e) {
var step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
if (!step) { return; }
e.preventDefault();
stopLoop();
btns[(i + step + btns.length) % btns.length].focus({ preventScroll: true });
});
});
root.addEventListener('pointerenter', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./FooterSitemapMega.module.scss";
/* ambient 에 KeyboardEvent 가 없어 파일 안에 필요한 만큼만 선언한다 */
type KeyEvt = { key: string; preventDefault(): void };
export type SitemapColumn = {
/** 열 머리 이름 */
title: string;
/** 링크 묶음의 이름 — nav 의 aria-label 이 된다 */
navLabel: string;
links: string[];
};
export type FooterSitemapMegaProps = {
brand?: string;
legal?: string;
columns?: SitemapColumn[];
/** 세 단이 한 바퀴 열렸다 닫히는 주기(ms) */
duration?: number;
easing?: string;
/** 무대 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const DEFAULT_COLUMNS: SitemapColumn[] = [
{ title: "제품", navLabel: "제품 링크", links: ["기능 소개", "요금 안내", "사용 사례"] },
{ title: "회사", navLabel: "회사 링크", links: ["회사 소개", "채용 정보", "보도 자료"] },
{ title: "지원", navLabel: "지원 링크", links: ["도움말", "문의 창구", "상태 알림"] },
];
const Caret = (): ReactNode => (
<svg className={styles.caret} viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false">
<g fill="currentColor">
<path d="m208 96l-80 80l-80-80Z" opacity=".2" />
<path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z" />
</g>
</svg>
);
export default function FooterSitemapMega({
brand = "스튜디오",
legal = "© 2026 스튜디오",
columns = DEFAULT_COLUMNS,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fbf6ee",
}: FooterSitemapMegaProps): ReactNode {
/* aria-expanded 가 열림/닫힘의 유일한 출처다 */
const [open, setOpen] = useState<boolean[]>(columns.map(() => true));
const [loop, setLoop] = useState<boolean>(true);
const btns = useRef<Array<HTMLButtonElement | null>>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const toggle = (i: number): void => {
setLoop(false);
setOpen((prev: boolean[]) => prev.map((v: boolean, k: number) => (k === i ? !v : v)));
};
/* 위아래 화살표로 열 머리 사이를 옮긴다. Enter·Space 는 button 이 기본으로 처리한다 */
const onKey = (e: KeyEvt, i: number): void => {
const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
if (step === 0) { return; }
e.preventDefault();
setLoop(false);
const list = btns.current;
if (!list) { return; }
const next = list[(i + step + list.length) % list.length];
if (next) { next.focus({ preventScroll: true }); }
};
return (
<footer
className={loop ? `${styles.fsm} ${styles.isDemo}` : styles.fsm}
style={vars}
role="contentinfo"
onPointerEnter={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<p className={styles.brand}>{brand}</p>
<div className={styles.cols}>
{columns.map((c: SitemapColumn, i: number) => (
<section className={styles.col} key={c.title}>
<h2 className={styles.head}>
<button
className={styles.btn}
type="button"
aria-expanded={open[i] ? "true" : "false"}
aria-controls={`fsm-p${i + 1}`}
ref={(el: HTMLButtonElement | null) => { if (btns.current) { btns.current[i] = el; } }}
onClick={() => toggle(i)}
onKeyDown={(e: KeyEvt) => onKey(e, i)}
>
<span className={styles.btnT}>{c.title}</span>
<Caret />
</button>
</h2>
<div className={styles.panel} id={`fsm-p${i + 1}`}>
<nav className={styles.list} aria-label={c.navLabel}>
{c.links.map((l: string) => (
<a href="#" key={l}>{l}</a>
))}
</nav>
</div>
</section>
))}
</div>
<p className={styles.legal}>{legal}</p>
</footer>
);
}01 · 다단 사이트맵 푸터 — 측정 누적면적 2.053% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
링크가 스무 개를 넘는 서비스 사이트 하단에 씁니다.
.fsm__panel {
display: grid;
grid-template-rows: 1fr; /* 열린 상태 */
overflow: hidden;
transition: grid-template-rows 300ms cubic-bezier(.2, .8, .2, 1);
}
.fsm__panel > * { min-height: 0; }
/* 접힌 단 — 버튼의 aria-expanded 가 상태의 유일한 출처다 */
.fsm__col:has(.fsm__btn[aria-expanded="false"]) .fsm__panel {
grid-template-rows: 0fr;
}
.fsm__btn[aria-expanded="false"] .fsm__caret { transform: rotate(-90deg); }
02뉴스레터 통합 푸터
홈페이지 푸터에서 뉴스레터 칸은 화면에서 어떻게 열리나
이메일을 넣고 보내면 버튼이 눌리고, 로딩 표시가 돌고, 완료 도장이 튀어오르는 세 장면이 같은 자리에서 이어집니다. 아이콘 석 장과 안내 문구 넉 장을 각각 같은 그리드 칸에 포개 두었기 때문에 상태가 바뀌어도 버튼 폭과 줄 높이가 흔들리지 않습니다.

<main class="stage">
<footer class="fn is-demo is-idle" role="contentinfo">
<p class="fn__title">주간 소식 받기</p>
<p class="fn__desc">새 글과 코드 조각을 한 주에 한 번만 보내 드립니다.</p>
<form class="fn__form" novalidate>
<label class="fn__label" for="fn-mail">이메일 주소</label>
<div class="fn__row">
<input class="fn__input" id="fn-mail" type="email" name="email" autocomplete="email" placeholder="[email protected]">
<button class="fn__send" type="submit">
<span class="fn__send-t">구독</span>
<span class="fn__icons" aria-hidden="true">
<span class="fn__ic fn__ic--plane"><svg viewBox="0 0 256 256" width="16" height="16" focusable="false"><g fill="currentColor"><path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2"/><path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z"/></g></svg></span>
<span class="fn__ic fn__ic--load"><span class="fn__spin"></span></span>
<span class="fn__ic fn__ic--done"><svg viewBox="0 0 256 256" width="18" height="18" focusable="false"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
</span>
</button>
</div>
<span class="fn__bar" aria-hidden="true"></span>
</form>
<p class="fn__msg" role="status" aria-live="polite">
<span class="fn__m fn__m--idle">언제든 한 번에 구독을 끊을 수 있습니다.</span>
<span class="fn__m fn__m--send">보내는 중입니다.</span>
<span class="fn__m fn__m--done">확인 메일을 보냈습니다.</span>
<span class="fn__m fn__m--err"></span>
</p>
<p class="fn__legal">© 2026 스튜디오</p>
</footer>
</main>@use "../../../../site/static/demo-tokens" as *;
// 272 푸터 팔레트 — 세 항목이 같은 값을 쓴다. 괄호 안은 실측 대비비
$ft-ink: #1b1a20; // 어두운 푸터 바닥
$ft-ink2: #2c2a33; // 바닥 위 입력칸·구분선
$ft-paper: $stage-paper; // 흰 글자 on ink 17.28:1 · 흰 글자 on teal 5.79:1
$ft-mist: #b9b3c4; // 보조 글자 on ink 8.48:1
$ft-amber: $stage-yellow; // 무대
$ft-flame: $stage-orange; // 오류 글자 on ink 5.21:1
$ft-teal: #479089; // 보내기 버튼 면
$ft-aqua: #b3dad5; // 완료 버튼 면 (ink 글자 11.58:1) · 진행 막대
$duration: $dur-loop; // 입력 대기 → 보냄 → 완료 → 다시 대기 한 바퀴
$easing: $ease-pop; // 완료 도장이 튀어 오를 때
$color: $ft-amber; // 무대(앰버)
@include stage($ft-amber);
.fn {
box-sizing: border-box;
width: clamp(320px, 92vw, 432px);
padding: $sp-4;
border-radius: $r-card;
background: $ft-ink;
color: $ft-paper;
}
.fn__title {
margin: 0;
font-weight: 800; font-size: 16px; line-height: 1.2;
}
.fn__desc {
margin: $sp-1 0 $sp-3;
color: $ft-mist;
font-weight: 500; font-size: 12px; line-height: 1.4;
}
.fn__label {
display: block;
margin-bottom: $sp-1;
color: $ft-mist;
font-weight: 600; font-size: 11px; line-height: 1.2;
}
.fn__row {
display: flex;
align-items: stretch;
gap: $sp-2;
}
.fn__input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
padding: $sp-2 $sp-3;
border: 1px solid $ft-mist;
border-radius: $r-control;
background: $ft-ink2;
color: $ft-paper;
font-family: inherit;
font-weight: 500; font-size: 13px; line-height: 1.4;
}
.fn__input::placeholder { color: $ft-mist; }
.fn__input:focus-visible {
outline: 2px solid $ft-aqua;
outline-offset: 2px;
}
// 기본 면은 teal — 흰 글자로 5.79:1. 완료 상태에서만 aqua 로 바뀌고 글자가 ink 로 뒤집힌다
.fn__send {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border: 2px solid $ft-aqua;
border-radius: $r-control;
background: $ft-teal;
color: $ft-paper;
font-family: inherit;
font-weight: 700; font-size: 13px; line-height: 1.4;
cursor: pointer;
}
.fn__send:focus-visible {
outline: 2px solid $ft-aqua;
outline-offset: 2px;
}
.fn.is-done .fn__send { background: $ft-aqua; color: $ft-ink; }
.fn.is-sending .fn__send { transform: translateY(3px); }
// 아이콘 세 장을 같은 칸에 포갠다 — 상태가 바뀌어도 버튼 폭이 흔들리지 않는다
.fn__icons {
display: grid;
place-items: center;
width: 18px;
height: 18px;
}
.fn__ic {
grid-area: 1 / 1;
display: grid;
place-items: center;
opacity: 0;
}
.fn.is-idle .fn__ic--plane,
.fn.is-error .fn__ic--plane { opacity: 1; }
.fn.is-sending .fn__ic--load { opacity: 1; }
.fn.is-done .fn__ic--done { opacity: 1; }
.fn__spin {
display: block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, .35);
border-top-color: currentColor;
border-radius: 50%;
animation-name: fnSpin;
animation-duration: 500ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
// 보내는 동안 차오르는 막대. 탭 인디케이터가 아니라 진행 게이지다
.fn__bar {
display: block;
height: 2px;
margin-top: $sp-2;
border-radius: $r-pill;
background: $ft-aqua;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 진행 게이지 왼쪽 채움
}
.fn.is-sending .fn__bar { transform: scaleX(.6); }
.fn.is-done .fn__bar { transform: scaleX(1); }
// 결과 문장 넷을 같은 칸에 포갠다 — 글자가 바뀌어도 줄이 흔들리지 않는다
.fn__msg {
display: grid;
margin: $sp-2 0 0;
min-height: 17px;
}
.fn__m {
grid-area: 1 / 1;
opacity: 0;
font-weight: 500; font-size: 12px; line-height: 1.4;
}
.fn__m--idle, .fn__m--send { color: $ft-mist; }
.fn__m--done { color: $ft-aqua; }
.fn__m--err { color: $ft-flame; }
.fn.is-idle .fn__m--idle,
.fn.is-sending .fn__m--send,
.fn.is-done .fn__m--done,
.fn.is-error .fn__m--err { opacity: 1; }
.fn__legal {
margin: $sp-3 0 0;
padding-top: $sp-3;
border-top: 1px solid $ft-ink2;
color: $ft-mist;
font-weight: 500; font-size: 11px; line-height: 1.4;
}
// ── 자동 재생 ─────────────────────────────────────────────
// 상태 교체는 steps(1, end) 컷 — 반투명 유령 프레임이 생기지 않는다.
// 컷만 있으면 움직인 프레임이 두세 장으로 떨어지므로 옆에 transform 움직임
// (도장 translateY 팝 · 버튼 누름 · 막대 scaleX)을 같이 둔다.
// 퇴장은 재현하지 않는다 — 완료 표시를 애니메이션으로 빼지 않고 컷으로 조용히 되돌린다.
.fn.is-demo .fn__ic, .fn.is-demo .fn__m {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fn.is-demo .fn__ic--plane { animation-name: fnPlane; }
.fn.is-demo .fn__ic--load { animation-name: fnLoad; }
.fn.is-demo .fn__ic--done { animation-name: fnDone; }
.fn.is-demo .fn__m--idle { animation-name: fnMsgIdle; }
.fn.is-demo .fn__m--send { animation-name: fnMsgSend; }
.fn.is-demo .fn__m--done { animation-name: fnMsgDone; }
.fn.is-demo .fn__ic--done svg {
animation-name: fnPop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fn.is-demo .fn__send {
animation-name: fnFace, fnPress;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $ease-spring;
animation-iteration-count: infinite, infinite;
}
.fn.is-demo .fn__bar {
animation-name: fnBar;
animation-duration: $duration;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes fnSpin { to { transform: rotate(360deg); } }
@keyframes fnPlane { 0% { opacity: 1; } 40% { opacity: 0; } 82% { opacity: 1; } }
@keyframes fnLoad { 0% { opacity: 0; } 40% { opacity: 1; } 58% { opacity: 0; } }
@keyframes fnDone { 0% { opacity: 0; } 58% { opacity: 1; } 82% { opacity: 0; } }
@keyframes fnMsgIdle { 0% { opacity: 1; } 40% { opacity: 0; } 89% { opacity: 1; } }
@keyframes fnMsgSend { 0% { opacity: 0; } 40% { opacity: 1; } 58% { opacity: 0; } }
@keyframes fnMsgDone { 0% { opacity: 0; } 58% { opacity: 1; } 89% { opacity: 0; } }
@keyframes fnFace {
0% { background: #{$ft-teal}; color: #{$ft-paper}; }
58% { background: #{$ft-aqua}; color: #{$ft-ink}; }
86% { background: #{$ft-teal}; color: #{$ft-paper}; }
}
@keyframes fnPress {
0%, 38% { transform: translateY(0); }
41% { transform: translateY(3px); }
46%, 100% { transform: translateY(0); }
}
@keyframes fnPop {
0%, 58% { transform: translateY(6px) scale(.7); }
66%, 82% { transform: translateY(0) scale(1); }
83%, 100% { transform: translateY(6px) scale(.7); }
}
@keyframes fnBar {
0%, 40% { transform: scaleX(0); }
58%, 88.9% { transform: scaleX(1); }
89%, 100% { transform: scaleX(0); }
}
// 좁은 화면 — 세로 여유가 174px 뿐이라 값을 올리지 않고 내린다
@media (max-width: 340px), (max-height: 220px) {
.fn { width: 100%; padding: $sp-2; }
.fn__title { font-size: 12px; }
.fn__desc { margin: $sp-1 0 $sp-2; font-size: 10px; line-height: 1.3; }
.fn__label { font-size: 10px; }
.fn__row { gap: $sp-1; }
.fn__input { padding: $sp-1 $sp-2; font-size: 11px; }
.fn__send { padding: $sp-1 $sp-2; font-size: 11px; }
.fn__bar { margin-top: $sp-1; }
.fn__msg { margin-top: $sp-1; min-height: 14px; }
.fn__m { font-size: 10px; line-height: 1.3; }
.fn__legal { margin-top: $sp-1; padding-top: $sp-1; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// .fn.is-demo .fn__ic (0,3,0) 를 이기려면 !important 가 필요하다
.fn__ic, .fn__m, .fn__send, .fn__bar, .fn__spin, .fn__ic--done svg { animation: none !important; }
}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);
}
.fn {
box-sizing: border-box;
width: clamp(320px, 92vw, 432px);
padding: 16px;
border-radius: 16px;
background: #1b1a20;
color: #ffffff;
}
.fn__title {
margin: 0;
font-weight: 800;
font-size: 16px;
line-height: 1.2;
}
.fn__desc {
margin: 4px 0 12px;
color: #b9b3c4;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.fn__label {
display: block;
margin-bottom: 4px;
color: #b9b3c4;
font-weight: 600;
font-size: 11px;
line-height: 1.2;
}
.fn__row {
display: flex;
align-items: stretch;
gap: 8px;
}
.fn__input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
padding: 8px 12px;
border: 1px solid #b9b3c4;
border-radius: 12px;
background: #2c2a33;
color: #ffffff;
font-family: inherit;
font-weight: 500;
font-size: 13px;
line-height: 1.4;
}
.fn__input::placeholder {
color: #b9b3c4;
}
.fn__input:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 2px;
}
.fn__send {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 2px solid #b3dad5;
border-radius: 12px;
background: #479089;
color: #ffffff;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.4;
cursor: pointer;
}
.fn__send:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 2px;
}
.fn.is-done .fn__send {
background: #b3dad5;
color: #1b1a20;
}
.fn.is-sending .fn__send {
transform: translateY(3px);
}
.fn__icons {
display: grid;
place-items: center;
width: 18px;
height: 18px;
}
.fn__ic {
grid-area: 1/1;
display: grid;
place-items: center;
opacity: 0;
}
.fn.is-idle .fn__ic--plane,
.fn.is-error .fn__ic--plane {
opacity: 1;
}
.fn.is-sending .fn__ic--load {
opacity: 1;
}
.fn.is-done .fn__ic--done {
opacity: 1;
}
.fn__spin {
display: block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: currentColor;
border-radius: 50%;
animation-name: fnSpin;
animation-duration: 500ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.fn__bar {
display: block;
height: 2px;
margin-top: 8px;
border-radius: 999px;
background: #b3dad5;
transform: scaleX(0);
transform-origin: left center;
}
.fn.is-sending .fn__bar {
transform: scaleX(0.6);
}
.fn.is-done .fn__bar {
transform: scaleX(1);
}
.fn__msg {
display: grid;
margin: 8px 0 0;
min-height: 17px;
}
.fn__m {
grid-area: 1/1;
opacity: 0;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.fn__m--idle, .fn__m--send {
color: #b9b3c4;
}
.fn__m--done {
color: #b3dad5;
}
.fn__m--err {
color: #ff4d1f;
}
.fn.is-idle .fn__m--idle,
.fn.is-sending .fn__m--send,
.fn.is-done .fn__m--done,
.fn.is-error .fn__m--err {
opacity: 1;
}
.fn__legal {
margin: 12px 0 0;
padding-top: 12px;
border-top: 1px solid #2c2a33;
color: #b9b3c4;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.fn.is-demo .fn__ic, .fn.is-demo .fn__m {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fn.is-demo .fn__ic--plane {
animation-name: fnPlane;
}
.fn.is-demo .fn__ic--load {
animation-name: fnLoad;
}
.fn.is-demo .fn__ic--done {
animation-name: fnDone;
}
.fn.is-demo .fn__m--idle {
animation-name: fnMsgIdle;
}
.fn.is-demo .fn__m--send {
animation-name: fnMsgSend;
}
.fn.is-demo .fn__m--done {
animation-name: fnMsgDone;
}
.fn.is-demo .fn__ic--done svg {
animation-name: fnPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.fn.is-demo .fn__send {
animation-name: fnFace, fnPress;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
.fn.is-demo .fn__bar {
animation-name: fnBar;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes fnSpin {
to {
transform: rotate(360deg);
}
}
@keyframes fnPlane {
0% {
opacity: 1;
}
40% {
opacity: 0;
}
82% {
opacity: 1;
}
}
@keyframes fnLoad {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
58% {
opacity: 0;
}
}
@keyframes fnDone {
0% {
opacity: 0;
}
58% {
opacity: 1;
}
82% {
opacity: 0;
}
}
@keyframes fnMsgIdle {
0% {
opacity: 1;
}
40% {
opacity: 0;
}
89% {
opacity: 1;
}
}
@keyframes fnMsgSend {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
58% {
opacity: 0;
}
}
@keyframes fnMsgDone {
0% {
opacity: 0;
}
58% {
opacity: 1;
}
89% {
opacity: 0;
}
}
@keyframes fnFace {
0% {
background: #479089;
color: #ffffff;
}
58% {
background: #b3dad5;
color: #1b1a20;
}
86% {
background: #479089;
color: #ffffff;
}
}
@keyframes fnPress {
0%, 38% {
transform: translateY(0);
}
41% {
transform: translateY(3px);
}
46%, 100% {
transform: translateY(0);
}
}
@keyframes fnPop {
0%, 58% {
transform: translateY(6px) scale(0.7);
}
66%, 82% {
transform: translateY(0) scale(1);
}
83%, 100% {
transform: translateY(6px) scale(0.7);
}
}
@keyframes fnBar {
0%, 40% {
transform: scaleX(0);
}
58%, 88.9% {
transform: scaleX(1);
}
89%, 100% {
transform: scaleX(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.fn {
width: 100%;
padding: 8px;
}
.fn__title {
font-size: 12px;
}
.fn__desc {
margin: 4px 0 8px;
font-size: 10px;
line-height: 1.3;
}
.fn__label {
font-size: 10px;
}
.fn__row {
gap: 4px;
}
.fn__input {
padding: 4px 8px;
font-size: 11px;
}
.fn__send {
padding: 4px 8px;
font-size: 11px;
}
.fn__bar {
margin-top: 4px;
}
.fn__msg {
margin-top: 4px;
min-height: 14px;
}
.fn__m {
font-size: 10px;
line-height: 1.3;
}
.fn__legal {
margin-top: 4px;
padding-top: 4px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.fn__ic, .fn__m, .fn__send, .fn__bar, .fn__spin, .fn__ic--done svg {
animation: none !important;
}
}(function () {
var root = document.querySelector('.fn');
var form = root.querySelector('.fn__form');
var input = root.querySelector('.fn__input');
var err = root.querySelector('.fn__m--err');
var timer = 0;
// 상태는 뿌리의 클래스 하나로만 관리한다 — 정적 규칙이 먼저, 자동 루프는 그 위에 덧씌운다
function setState(s) {
root.classList.remove('is-idle', 'is-sending', 'is-done', 'is-error');
root.classList.add(s);
}
function stopLoop() { root.classList.remove('is-demo'); }
form.addEventListener('submit', function (e) {
e.preventDefault();
stopLoop();
clearTimeout(timer);
var v = input.value.trim();
// 오류도 같은 aria-live 영역으로 알린다 — 화면을 못 보는 사람에게 결과가 한 곳에서만 들린다
if (!v) { err.textContent = '이메일 주소를 입력해 주세요.'; setState('is-error'); return; }
if (v.indexOf('@') < 0) { err.textContent = '주소에 @ 가 빠졌습니다.'; setState('is-error'); return; }
setState('is-sending');
timer = setTimeout(function () { setState('is-done'); }, 900);
});
input.addEventListener('input', function () {
stopLoop();
if (root.classList.contains('is-error')) { setState('is-idle'); }
});
root.addEventListener('pointerenter', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties, ChangeEvent, ReactNode } from "react";
import styles from "./FooterNewsletter.module.scss";
/* ambient 에 FormEvent 가 없어 파일 안에 필요한 만큼만 선언한다 */
type SubmitEvt = { preventDefault(): void };
export type NewsletterState = "idle" | "sending" | "done" | "error";
export type FooterNewsletterProps = {
title?: string;
desc?: string;
label?: string;
placeholder?: string;
sendText?: string;
idleText?: string;
sendingText?: string;
doneText?: string;
emptyError?: string;
atError?: string;
legal?: string;
/** 입력 대기 → 보냄 → 완료 → 다시 대기 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 무대 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const Plane = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" focusable="false">
<g fill="currentColor">
<path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2" />
<path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z" />
</g>
</svg>
);
const CheckCircle = (): ReactNode => (
<svg viewBox="0 0 256 256" width="18" height="18" focusable="false">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
</svg>
);
export default function FooterNewsletter({
title = "주간 소식 받기",
desc = "새 글과 코드 조각을 한 주에 한 번만 보내 드립니다.",
label = "이메일 주소",
placeholder = "[email protected]",
sendText = "구독",
idleText = "언제든 한 번에 구독을 끊을 수 있습니다.",
sendingText = "보내는 중입니다.",
doneText = "확인 메일을 보냈습니다.",
emptyError = "이메일 주소를 입력해 주세요.",
atError = "주소에 @ 가 빠졌습니다.",
legal = "© 2026 스튜디오",
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ffd23f",
}: FooterNewsletterProps): ReactNode {
const [state, setState] = useState<NewsletterState>("idle");
const [value, setValue] = useState<string>("");
const [error, setError] = useState<string>("");
const [loop, setLoop] = useState<boolean>(true);
const timer = useRef<number | null>(null);
useEffect(() => () => {
if (timer.current !== null) { window.clearTimeout(timer.current); }
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const submit = (e: SubmitEvt): void => {
e.preventDefault();
setLoop(false);
if (timer.current !== null) { window.clearTimeout(timer.current); }
const v = value.trim();
/* 오류도 같은 aria-live 영역으로 알린다 — 결과가 한 곳에서만 들린다 */
if (v === "") { setError(emptyError); setState("error"); return; }
if (v.indexOf("@") < 0) { setError(atError); setState("error"); return; }
setState("sending");
timer.current = window.setTimeout(() => setState("done"), 900);
};
const onInput = (e: ChangeEvent<HTMLInputElement>): void => {
setLoop(false);
setValue(e.target.value);
if (state === "error") { setState("idle"); }
};
const stateClass =
state === "sending" ? styles.isSending : state === "done" ? styles.isDone : state === "error" ? styles.isError : styles.isIdle;
return (
<footer
className={`${styles.fn} ${stateClass}${loop ? ` ${styles.isDemo}` : ""}`}
style={vars}
role="contentinfo"
onPointerEnter={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<p className={styles.title}>{title}</p>
<p className={styles.desc}>{desc}</p>
<form className={styles.form} noValidate onSubmit={submit}>
<label className={styles.label} htmlFor="fn-mail">{label}</label>
<div className={styles.row}>
<input
className={styles.input}
id="fn-mail"
type="email"
name="email"
autoComplete="email"
placeholder={placeholder}
value={value}
onChange={onInput}
/>
<button className={styles.send} type="submit">
<span className={styles.sendT}>{sendText}</span>
<span className={styles.icons} aria-hidden="true">
<span className={`${styles.ic} ${styles.icPlane}`}><Plane /></span>
<span className={`${styles.ic} ${styles.icLoad}`}><span className={styles.spin} /></span>
<span className={`${styles.ic} ${styles.icDone}`}><CheckCircle /></span>
</span>
</button>
</div>
<span className={styles.bar} aria-hidden="true" />
</form>
<p className={styles.msg} role="status" aria-live="polite">
<span className={`${styles.m} ${styles.mIdle}`}>{idleText}</span>
<span className={`${styles.m} ${styles.mSend}`}>{sendingText}</span>
<span className={`${styles.m} ${styles.mDone}`}>{doneText}</span>
<span className={`${styles.m} ${styles.mErr}`}>{error}</span>
</p>
<p className={styles.legal}>{legal}</p>
</footer>
);
}02 · 뉴스레터 통합 푸터 — 측정 누적면적 3.778% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
구독을 받는 푸터에 씁니다.
/* 아이콘 석 장을 같은 칸에 포갠다 — 상태가 바뀌어도 버튼 폭이 안 흔들린다 */
.fn__icons { display: grid; place-items: center; width: 18px; height: 18px; }
.fn__ic { grid-area: 1 / 1; opacity: 0; }
.fn.is-idle .fn__ic--plane { opacity: 1; }
.fn.is-sending .fn__ic--load { opacity: 1; }
.fn.is-done .fn__ic--done { opacity: 1; }
/* 보내는 동안 차오르는 진행 게이지 */
.fn__bar {
height: 2px;
border-radius: 999px;
background: #6ee7d7;
transform: scaleX(0);
transform-origin: left center;
}
.fn.is-sending .fn__bar { transform: scaleX(.6); }
.fn.is-done .fn__bar { transform: scaleX(1); }
03미니멀 원라인 푸터
어떤 움직임인가
로고와 저작권, 동그란 소셜 버튼 세 개가 한 줄로 끝납니다. 버튼 위를 지나면 점이 6px 떠오르면서 계정마다 정해 둔 색으로 차는데, 그 색을 --on 이라는 변수 하나로 받아 두어 버튼마다 규칙을 새로 쓰지 않습니다.

<main class="stage">
<footer class="fo is-demo" role="contentinfo">
<a class="fo__mark" href="#">
<span class="fo__mark-t">스튜디오</span>
<span class="fo__line" aria-hidden="true"></span>
</a>
<p class="fo__copy">© 2026 스튜디오</p>
<nav class="fo__social" aria-label="소셜 링크">
<a class="fo__dot fo__dot--a" href="#" aria-label="사진 계정">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M176 32H80a48 48 0 0 0-48 48v96a48 48 0 0 0 48 48h96a48 48 0 0 0 48-48V80a48 48 0 0 0-48-48m-48 136a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M176 24H80a56.06 56.06 0 0 0-56 56v96a56.06 56.06 0 0 0 56 56h96a56.06 56.06 0 0 0 56-56V80a56.06 56.06 0 0 0-56-56m40 152a40 40 0 0 1-40 40H80a40 40 0 0 1-40-40V80a40 40 0 0 1 40-40h96a40 40 0 0 1 40 40Zm-88-96a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m64-84a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg>
</a>
<a class="fo__dot fo__dot--b" href="#" aria-label="짧은 소식 계정">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M208 216h-48L48 40h48Z" opacity=".2"/><path d="m214.75 211.71l-62.6-98.38l61.77-67.95a8 8 0 0 0-11.84-10.76l-58.84 64.72l-40.49-63.63A8 8 0 0 0 96 32H48a8 8 0 0 0-6.75 12.3l62.6 98.37l-61.77 68a8 8 0 1 0 11.84 10.76l58.84-64.72l40.49 63.63A8 8 0 0 0 160 224h48a8 8 0 0 0 6.75-12.29M164.39 208L62.57 48h29l101.86 160Z"/></g></svg>
</a>
<a class="fo__dot fo__dot--c" href="#" aria-label="코드 저장소">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M208 104v8a48 48 0 0 1-48 48h-24a32 32 0 0 1 32 32v40h-64v-40a32 32 0 0 1 32-32h-24a48 48 0 0 1-48-48v-8a49.3 49.3 0 0 1 8.51-27.3A51.92 51.92 0 0 1 76 32a52 52 0 0 1 43.83 24h32.34A52 52 0 0 1 196 32a51.92 51.92 0 0 1 3.49 44.7A49.3 49.3 0 0 1 208 104" opacity=".2"/><path d="M208.3 75.68A59.74 59.74 0 0 0 202.93 28a8 8 0 0 0-6.93-4a59.75 59.75 0 0 0-48 24h-24a59.75 59.75 0 0 0-48-24a8 8 0 0 0-6.93 4a59.78 59.78 0 0 0-5.38 47.68A58.14 58.14 0 0 0 56 104v8a56.06 56.06 0 0 0 48.44 55.47A39.8 39.8 0 0 0 96 192v8H72a24 24 0 0 1-24-24a40 40 0 0 0-40-40a8 8 0 0 0 0 16a24 24 0 0 1 24 24a40 40 0 0 0 40 40h24v16a8 8 0 0 0 16 0v-40a24 24 0 0 1 48 0v40a8 8 0 0 0 16 0v-40a39.8 39.8 0 0 0-8.44-24.53A56.06 56.06 0 0 0 216 112v-8a58 58 0 0 0-7.7-28.32M200 112a40 40 0 0 1-40 40h-48a40 40 0 0 1-40-40v-8a41.74 41.74 0 0 1 6.9-22.48a8 8 0 0 0 1.1-7.69a43.8 43.8 0 0 1 .79-33.58a43.88 43.88 0 0 1 32.32 20.06a8 8 0 0 0 6.71 3.69h32.35a8 8 0 0 0 6.74-3.69a43.87 43.87 0 0 1 32.32-20.06a43.8 43.8 0 0 1 .77 33.58a8.09 8.09 0 0 0 1 7.65a41.76 41.76 0 0 1 7 22.52Z"/></g></svg>
</a>
</nav>
</footer>
</main>@use "../../../../site/static/demo-tokens" as *;
// 272 푸터 팔레트 — 세 항목이 같은 값을 쓴다. 괄호 안은 실측 대비비
$ft-ink: #1b1a20; // 워드마크 on cream 16.06:1
$ft-paper: $stage-paper; // 점 바탕 · ink/teal 면 위 아이콘
$ft-cream: #fbf6ee; // 무대
$ft-stone: #575162; // 저작권 on cream 7.08:1 · 점 아이콘 on paper 7.61:1
$ft-flame: $stage-orange; // 첫 점 면 (ink 아이콘 5.21:1)
$ft-teal: #479089; // 셋째 점 면 (흰 아이콘 5.79:1)
$duration: $dur-loop; // 점 세 개가 차례로 떠오르는 한 바퀴
$easing: $ease-pop; // 점이 떠오를 때의 튕김
$color: $ft-cream; // 무대(크림)
@include stage($ft-cream);
// 한 줄 그대로 닫힌다 — 좁은 화면에서도 세로로 쌓지 않는다 (쌓으면 오히려 세로로 넘친다)
.fo {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-3;
width: clamp(288px, 92vw, 432px);
padding-top: $sp-4;
border-top: 2px solid $ft-ink;
}
.fo__mark {
position: relative;
flex: 0 0 auto;
color: $ft-ink;
font-weight: 800; font-size: 20px; line-height: 1.1;
letter-spacing: -.01em;
text-decoration: none;
}
.fo__mark:focus-visible {
outline: 2px solid $ft-teal;
outline-offset: 4px;
border-radius: $r-xs;
}
// 이름 밑줄 — 왼쪽에서 오른쪽으로 차오른다
.fo__line {
position: absolute;
left: 0;
right: 0;
bottom: -4px;
height: 3px;
border-radius: $r-pill;
background: $ft-teal;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 밑줄 왼쪽 채움
transition: transform $dur-base $easing;
}
.fo__mark:hover .fo__line,
.fo__mark:focus-visible .fo__line { transform: scaleX(1); }
.fo__copy {
flex: 0 1 auto;
margin: 0;
color: $ft-stone;
font-weight: 500; font-size: 12px; line-height: 1.2;
}
.fo__social {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: $sp-2;
}
// 정적 상태 — 흰 점에 돌 색 아이콘. 호버·포커스에서 떠오르며 저마다의 색으로 찬다
.fo__dot {
--on: #{$ft-flame};
--on-ink: #{$ft-ink};
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: $ft-paper;
color: $ft-stone;
transition: transform $dur-quick $easing, background-color $dur-quick $ease-out, color $dur-quick $ease-out;
}
.fo__dot--b { --on: #{$ft-ink}; --on-ink: #{$ft-paper}; }
.fo__dot--c { --on: #{$ft-teal}; --on-ink: #{$ft-paper}; }
.fo__dot:hover,
.fo__dot:focus-visible {
transform: translateY(-6px);
background: var(--on);
color: var(--on-ink);
}
.fo__dot:focus-visible {
outline: 2px solid $ft-ink;
outline-offset: 2px;
}
// ── 자동 재생 ─────────────────────────────────────────────
// 점 셋이 1/3 주기씩 어긋나 차례로 떠오르고, 그 위에서 이름 밑줄이 따로 차오른다.
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로 쓴다
.fo.is-demo .fo__dot {
animation-name: foLift;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
transition: none;
}
.fo.is-demo .fo__dot--b { animation-delay: -.6667s; }
.fo.is-demo .fo__dot--c { animation-delay: -1.3333s; }
.fo.is-demo .fo__line {
animation-name: foLine;
animation-duration: $duration;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
transition: none;
}
@keyframes foLift {
0% {
transform: translateY(0);
background: #{$ft-paper};
color: #{$ft-stone};
}
8%, 26% {
transform: translateY(-6px);
background: var(--on);
color: var(--on-ink);
}
33.33%, 100% {
transform: translateY(0);
background: #{$ft-paper};
color: #{$ft-stone};
}
}
// 복귀는 90% 안에 끝낸다 — 24프레임의 마지막 장은 95.8% 지점이다
@keyframes foLine {
0% { transform: scaleX(0); }
45%, 78% { transform: scaleX(1); }
88%, 100% { transform: scaleX(0); }
}
// 좁은 화면 — 글자와 간격만 줄이고 한 줄을 유지한다
@media (max-width: 340px), (max-height: 220px) {
.fo { gap: $sp-2; width: 100%; padding-top: $sp-2; }
.fo__mark { font-size: 14px; }
.fo__line { bottom: -3px; height: 2px; }
.fo__copy { font-size: 10px; }
.fo__social { gap: $sp-1; }
.fo__dot { width: 22px; height: 22px; }
.fo__dot svg { width: 13px; height: 13px; }
}
@media (prefers-reduced-motion: reduce) {
// .fo.is-demo .fo__dot (0,3,0) 를 이기려면 !important 가 필요하다
.fo__dot, .fo__line { animation: none !important; transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #fbf6ee;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fo {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: clamp(288px, 92vw, 432px);
padding-top: 16px;
border-top: 2px solid #1b1a20;
}
.fo__mark {
position: relative;
flex: 0 0 auto;
color: #1b1a20;
font-weight: 800;
font-size: 20px;
line-height: 1.1;
letter-spacing: -0.01em;
text-decoration: none;
}
.fo__mark:focus-visible {
outline: 2px solid #479089;
outline-offset: 4px;
border-radius: 4px;
}
.fo__line {
position: absolute;
left: 0;
right: 0;
bottom: -4px;
height: 3px;
border-radius: 999px;
background: #479089;
transform: scaleX(0);
transform-origin: left center;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.fo__mark:hover .fo__line,
.fo__mark:focus-visible .fo__line {
transform: scaleX(1);
}
.fo__copy {
flex: 0 1 auto;
margin: 0;
color: #575162;
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
.fo__social {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
}
.fo__dot {
--on: #ff4d1f;
--on-ink: #1b1a20;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: #ffffff;
color: #575162;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), background-color 220ms ease-out, color 220ms ease-out;
}
.fo__dot--b {
--on: #1b1a20;
--on-ink: #ffffff;
}
.fo__dot--c {
--on: #479089;
--on-ink: #ffffff;
}
.fo__dot:hover,
.fo__dot:focus-visible {
transform: translateY(-6px);
background: var(--on);
color: var(--on-ink);
}
.fo__dot:focus-visible {
outline: 2px solid #1b1a20;
outline-offset: 2px;
}
.fo.is-demo .fo__dot {
animation-name: foLift;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
transition: none;
}
.fo.is-demo .fo__dot--b {
animation-delay: -0.6667s;
}
.fo.is-demo .fo__dot--c {
animation-delay: -1.3333s;
}
.fo.is-demo .fo__line {
animation-name: foLine;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
transition: none;
}
@keyframes foLift {
0% {
transform: translateY(0);
background: #ffffff;
color: #575162;
}
8%, 26% {
transform: translateY(-6px);
background: var(--on);
color: var(--on-ink);
}
33.33%, 100% {
transform: translateY(0);
background: #ffffff;
color: #575162;
}
}
@keyframes foLine {
0% {
transform: scaleX(0);
}
45%, 78% {
transform: scaleX(1);
}
88%, 100% {
transform: scaleX(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.fo {
gap: 8px;
width: 100%;
padding-top: 8px;
}
.fo__mark {
font-size: 14px;
}
.fo__line {
bottom: -3px;
height: 2px;
}
.fo__copy {
font-size: 10px;
}
.fo__social {
gap: 4px;
}
.fo__dot {
width: 22px;
height: 22px;
}
.fo__dot svg {
width: 13px;
height: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.fo__dot, .fo__line {
animation: none !important;
transition: none !important;
}
}(function () {
var root = document.querySelector('.fo');
var dots = [].slice.call(root.querySelectorAll('.fo__dot'));
function stopLoop() { root.classList.remove('is-demo'); }
// 소셜 점 셋은 좌우 화살표로 옮겨 다닌다 (Home·End 는 양 끝)
dots.forEach(function (dot, i) {
dot.addEventListener('keydown', function (e) {
var to = -1;
if (e.key === 'ArrowRight') { to = (i + 1) % dots.length; }
if (e.key === 'ArrowLeft') { to = (i - 1 + dots.length) % dots.length; }
if (e.key === 'Home') { to = 0; }
if (e.key === 'End') { to = dots.length - 1; }
if (to < 0) { return; }
e.preventDefault();
stopLoop();
dots[to].focus({ preventScroll: true });
});
});
root.addEventListener('pointerenter', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./FooterOneLine.module.scss";
/* ambient 에 KeyboardEvent 가 없어 파일 안에 필요한 만큼만 선언한다 */
type KeyEvt = { key: string; preventDefault(): void };
export type SocialDot = {
/** 스크린 리더가 읽을 이름 */
label: string;
icon: ReactNode;
};
export type FooterOneLineProps = {
mark?: string;
copy?: string;
navLabel?: string;
dots?: SocialDot[];
/** 점 세 개가 차례로 떠오르는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 무대 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const PhotoIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" focusable="false">
<g fill="currentColor">
<path d="M176 32H80a48 48 0 0 0-48 48v96a48 48 0 0 0 48 48h96a48 48 0 0 0 48-48V80a48 48 0 0 0-48-48m-48 136a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2" />
<path d="M176 24H80a56.06 56.06 0 0 0-56 56v96a56.06 56.06 0 0 0 56 56h96a56.06 56.06 0 0 0 56-56V80a56.06 56.06 0 0 0-56-56m40 152a40 40 0 0 1-40 40H80a40 40 0 0 1-40-40V80a40 40 0 0 1 40-40h96a40 40 0 0 1 40 40Zm-88-96a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m64-84a12 12 0 1 1-12-12a12 12 0 0 1 12 12" />
</g>
</svg>
);
const PostIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" focusable="false">
<g fill="currentColor">
<path d="M208 216h-48L48 40h48Z" opacity=".2" />
<path d="m214.75 211.71l-62.6-98.38l61.77-67.95a8 8 0 0 0-11.84-10.76l-58.84 64.72l-40.49-63.63A8 8 0 0 0 96 32H48a8 8 0 0 0-6.75 12.3l62.6 98.37l-61.77 68a8 8 0 1 0 11.84 10.76l58.84-64.72l40.49 63.63A8 8 0 0 0 160 224h48a8 8 0 0 0 6.75-12.29M164.39 208L62.57 48h29l101.86 160Z" />
</g>
</svg>
);
const CodeIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" focusable="false">
<g fill="currentColor">
<path d="M208 104v8a48 48 0 0 1-48 48h-24a32 32 0 0 1 32 32v40h-64v-40a32 32 0 0 1 32-32h-24a48 48 0 0 1-48-48v-8a49.3 49.3 0 0 1 8.51-27.3A51.92 51.92 0 0 1 76 32a52 52 0 0 1 43.83 24h32.34A52 52 0 0 1 196 32a51.92 51.92 0 0 1 3.49 44.7A49.3 49.3 0 0 1 208 104" opacity=".2" />
<path d="M208.3 75.68A59.74 59.74 0 0 0 202.93 28a8 8 0 0 0-6.93-4a59.75 59.75 0 0 0-48 24h-24a59.75 59.75 0 0 0-48-24a8 8 0 0 0-6.93 4a59.78 59.78 0 0 0-5.38 47.68A58.14 58.14 0 0 0 56 104v8a56.06 56.06 0 0 0 48.44 55.47A39.8 39.8 0 0 0 96 192v8H72a24 24 0 0 1-24-24a40 40 0 0 0-40-40a8 8 0 0 0 0 16a24 24 0 0 1 24 24a40 40 0 0 0 40 40h24v16a8 8 0 0 0 16 0v-40a24 24 0 0 1 48 0v40a8 8 0 0 0 16 0v-40a39.8 39.8 0 0 0-8.44-24.53A56.06 56.06 0 0 0 216 112v-8a58 58 0 0 0-7.7-28.32M200 112a40 40 0 0 1-40 40h-48a40 40 0 0 1-40-40v-8a41.74 41.74 0 0 1 6.9-22.48a8 8 0 0 0 1.1-7.69a43.8 43.8 0 0 1 .79-33.58a43.88 43.88 0 0 1 32.32 20.06a8 8 0 0 0 6.71 3.69h32.35a8 8 0 0 0 6.74-3.69a43.87 43.87 0 0 1 32.32-20.06a43.8 43.8 0 0 1 .77 33.58a8.09 8.09 0 0 0 1 7.65a41.76 41.76 0 0 1 7 22.52Z" />
</g>
</svg>
);
const DEFAULT_DOTS: SocialDot[] = [
{ label: "사진 계정", icon: <PhotoIcon /> },
{ label: "짧은 소식 계정", icon: <PostIcon /> },
{ label: "코드 저장소", icon: <CodeIcon /> },
];
export default function FooterOneLine({
mark = "스튜디오",
copy = "© 2026 스튜디오",
navLabel = "소셜 링크",
dots = DEFAULT_DOTS,
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fbf6ee",
}: FooterOneLineProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const refs = useRef<Array<HTMLAnchorElement | null>>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const tone = [styles.dotA, styles.dotB, styles.dotC];
/* 소셜 점 셋은 좌우 화살표로 옮겨 다닌다 (Home·End 는 양 끝) */
const onKey = (e: KeyEvt, i: number): void => {
const list = refs.current;
if (!list) { return; }
let to = -1;
if (e.key === "ArrowRight") { to = (i + 1) % list.length; }
if (e.key === "ArrowLeft") { to = (i - 1 + list.length) % list.length; }
if (e.key === "Home") { to = 0; }
if (e.key === "End") { to = list.length - 1; }
if (to < 0) { return; }
e.preventDefault();
setLoop(false);
const next = list[to];
if (next) { next.focus({ preventScroll: true }); }
};
return (
<footer
className={loop ? `${styles.fo} ${styles.isDemo}` : styles.fo}
style={vars}
role="contentinfo"
onPointerEnter={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<a className={styles.mark} href="#">
<span className={styles.markT}>{mark}</span>
<span className={styles.line} aria-hidden="true" />
</a>
<p className={styles.copy}>{copy}</p>
<nav className={styles.social} aria-label={navLabel}>
{dots.map((d: SocialDot, i: number) => (
<a
className={`${styles.dot} ${tone[i % tone.length]}`}
href="#"
key={d.label}
aria-label={d.label}
ref={(el: HTMLAnchorElement | null) => { if (refs.current) { refs.current[i] = el; } }}
onKeyDown={(e: KeyEvt) => onKey(e, i)}
>
{d.icon}
</a>
))}
</nav>
</footer>
);
}03 · 미니멀 원라인 푸터 — 측정 누적면적 1.865% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
보여 줄 것이 적은 포트폴리오 하단에 씁니다.
.fo__dot {
--on: #ff4d1f; /* 계정마다 다른 채움 색 */
--on-ink: #1b1a20;
display: grid;
place-items: center;
width: 28px; height: 28px;
border-radius: 50%;
background: #ffffff;
color: #575162;
transition: transform 220ms cubic-bezier(.2, 1.4, .4, 1),
background-color 220ms ease-out, color 220ms ease-out;
}
.fo__dot--b { --on: #1b1a20; --on-ink: #ffffff; }
.fo__dot--c { --on: #0b7269; --on-ink: #ffffff; }
.fo__dot:hover,
.fo__dot:focus-visible {
transform: translateY(-6px);
background: var(--on);
color: var(--on-ink);
}
04스티키 하단 앱바
무엇이 보이나
화면 바닥에 붙은 막대의 네 칸 사이를 밝은 알약이 미끄러지고, 알약이 닿는 칸의 아이콘이 한 번 튑니다. 알약 폭을 픽셀로 짐작하지 않고 칸과 똑같이 25% 로 두었기 때문에 translateX(100%) 한 번이 정확히 한 칸이 됩니다.

<div class="stage">
<div class="dk">
<div class="dk__view">
<div class="dk__doc">
<div class="dk__body">
<p class="dk__title">오늘의 기록</p>
<span class="dk__row dk__row--full" aria-hidden="true"></span>
<span class="dk__row dk__row--wide" aria-hidden="true"></span>
<span class="dk__row dk__row--mid" aria-hidden="true"></span>
<span class="dk__row dk__row--accent" aria-hidden="true"></span>
<span class="dk__row dk__row--full" aria-hidden="true"></span>
<span class="dk__row dk__row--wide" aria-hidden="true"></span>
<span class="dk__row dk__row--short" aria-hidden="true"></span>
<span class="dk__row dk__row--mid" aria-hidden="true"></span>
<span class="dk__row dk__row--wide" aria-hidden="true"></span>
</div>
<!-- 하단 앱바 — 스크롤되는 본문 안에서 sticky 로 바닥에 붙는다.
sticky 의 유효 범위는 부모(.dk__doc)의 콘텐츠 박스다 -->
<footer class="dk__bar">
<nav class="dk__nav" aria-label="주요 화면 이동">
<a class="dk__tab is-on" href="#" aria-current="page">
<span class="dk__ic">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
</span>
<span class="dk__label">홈</span>
</a>
<a class="dk__tab" href="#">
<span class="dk__ic">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
</span>
<span class="dk__label">찾기</span>
</a>
<a class="dk__tab" href="#">
<span class="dk__ic">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 176v48l-64-40l-64 40v-48l64-40Z" opacity=".2"/><path d="M184 32H72a16 16 0 0 0-16 16v176a8 8 0 0 0 12.24 6.78L128 193.43l59.77 37.35A8 8 0 0 0 200 224V48a16 16 0 0 0-16-16m0 16v113.57l-51.77-32.35a8 8 0 0 0-8.48 0L72 161.56V48Zm-51.77 129.22a8 8 0 0 0-8.48 0L72 209.57v-29.14l56-35l56 35v29.14Z"/></g></svg>
</span>
<span class="dk__label">저장</span>
</a>
<a class="dk__tab" href="#">
<span class="dk__ic">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 64v128H40V64Z" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8M40 72h176a8 8 0 0 0 0-16H40a8 8 0 0 0 0 16m176 112H40a8 8 0 0 0 0 16h176a8 8 0 0 0 0-16"/></g></svg>
</span>
<span class="dk__label">더보기</span>
</a>
<!-- 활성 칸 알약. 폭이 칸 하나(25%)라 translateX(100%) 한 번이 정확히 한 칸이다 -->
<span class="dk__pill" aria-hidden="true"></span>
</nav>
</footer>
</div>
</div>
</div>
</div>@use "../../../../site/static/demo-tokens" as *;
// 편 272 푸터 팔레트 — 04·05·06 이 같은 값을 쓴다.
// 대비 실측(WCAG 2.1): 흰 글자 on ink 17.28:1 · mist on ink 8.48:1 · aqua on ink 11.58:1
// ink on cream 16.06:1 · stone on cream 7.08:1 · teal on paper 5.79:1
$ft-ink: #1b1a20; // 다크 마감 바닥 / 무대
$ft-ink2: #2c2a33; // 잉크 위 카드·구분
$ft-paper: #ffffff;
$ft-cream: #fbf6ee; // 밝은 바탕
$ft-stone: #575162; // 밝은 면 위 보조 글자
$ft-mist: #b9b3c4; // 잉크 위 보조 글자
$ft-amber: $stage-yellow;
$ft-flame: $stage-orange;
$ft-teal: #479089; // 밝은 면 위 강조
$ft-aqua: #b3dad5; // 잉크 위 강조
$duration: $dur-loop; // 알약이 네 칸을 한 바퀴 도는 주기
$easing: $ease-pop; // 칸에 들어설 때만 살짝 튕긴다
$color: $ft-aqua; // 활성 칸 알약 색
@include stage($ft-ink);
// 앱 화면 — 안쪽이 실제로 스크롤된다. 앱바는 그 안에서 sticky 로 바닥에 붙는다
.dk {
position: relative; // .is-demo 동안 앱바의 절대배치 기준
box-sizing: border-box;
width: clamp(248px, 78vw, 320px);
height: 216px;
overflow: hidden;
border-radius: $r-card;
background: $ft-cream;
box-shadow: $shadow-raised;
}
.dk__view {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
}
// 화면보다 긴 문서 — 이 높이 차이가 sticky 가 걸릴 여유다.
// 여유를 padding-bottom 으로 주면 sticky 범위(부모 콘텐츠 박스) 밖이라 안 먹는다
.dk__doc {
display: flex;
flex-direction: column;
min-height: 280px;
}
.dk__body {
flex: 1 0 auto;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-4;
}
.dk__title {
margin: 0;
color: $ft-ink;
font-weight: 700; font-size: 15px; line-height: 1.3;
font-family: inherit;
}
// 본문 자리표시 줄 — 장식이라 aria-hidden
.dk__row {
display: block;
height: 8px;
border-radius: $r-pill;
background: $ft-mist;
}
.dk__row--full { width: 100%; }
.dk__row--wide { width: 86%; }
.dk__row--mid { width: 68%; }
.dk__row--short { width: 44%; }
.dk__row--accent { width: 52%; background: $ft-teal; }
.dk__bar {
position: sticky;
bottom: 0;
margin-top: auto;
padding: $sp-1;
background: $ft-ink;
}
// 스크롤 0 에서는 sticky 가 안 걸리는 브라우저가 있어(260-09 실측)
// 자동 재생 동안에는 화면(.dk) 기준 절대배치로 세워 둔다
.is-demo .dk__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
}
// 칸 폭은 그리드가 정한다 — 알약이 짐작한 px 이 아니라 100% 단위로 움직인다 (BD19)
.dk__nav {
position: relative;
display: grid;
grid-template-columns: repeat(4, 1fr);
}
.dk__pill {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 25%; // 칸 하나 = 100% → translateX(100%) 이 정확히 한 칸
height: 100%;
border-radius: $r-control;
background: $color;
transform: translateX(calc(var(--n, 0) * 100%));
transition: transform $dur-base $ease-spring;
}
.dk__tab {
position: relative;
z-index: 1;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
padding: $sp-1;
border-radius: $r-control;
color: $ft-mist; // 잉크 바 위 보조 글자 8.48:1
text-decoration: none;
}
.dk__tab.is-on { color: $ft-ink; } // 아쿠아 알약 위 잉크 글자 11.58:1
.dk__tab:focus-visible {
outline: 2px solid $ft-paper; // 알약 위에서도 보이도록 흰 테. 바 여백 4px 안에 들어간다
outline-offset: 0;
}
.dk__ic { display: block; }
.dk__ic svg { display: block; }
.dk__label {
font-weight: 600; font-size: 11px; line-height: 1;
font-family: inherit;
}
// ── 자동 재생 ───────────────────────────────────────────────
// 알약은 네 자리를 돌고, 복귀(3번 칸 → 0번 칸)는 82~100~10% 에 걸쳐 끝난다.
// 24프레임의 마지막 장은 95.8% 지점이라 복귀를 그 뒤에 두면 안 잡힌다
.is-demo .dk__pill {
animation-name: dockSlide;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 글자색 교체는 steps(1, end) 컷 — 두 색이 겹친 유령 프레임이 안 생긴다.
// 칸마다 창이 다르므로 하나의 키프레임 + delay 가 아니라 키프레임 넷으로 쓴다
.is-demo .dk__tab {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .dk__tab:nth-child(1) { animation-name: onA; }
.is-demo .dk__tab:nth-child(2) { animation-name: onB; }
.is-demo .dk__tab:nth-child(3) { animation-name: onC; }
.is-demo .dk__tab:nth-child(4) { animation-name: onD; }
.is-demo .dk__ic {
animation-name: icPop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
// 튀는 시점은 알약이 그 칸에 "닿는" 순간. $ease-pop 은 구간 중간에 이미 도착하므로
// 키프레임 끝(22·46·70%)이 아니라 실측 도착 시각(4·27·51·75%)에 맞춘다
.is-demo .dk__tab:nth-child(1) .dk__ic { animation-delay: -1.92s; }
.is-demo .dk__tab:nth-child(2) .dk__ic { animation-delay: -1.46s; }
.is-demo .dk__tab:nth-child(3) .dk__ic { animation-delay: -1.02s; }
.is-demo .dk__tab:nth-child(4) .dk__ic { animation-delay: -.5s; }
@keyframes dockSlide {
0% { transform: translateX(300%); }
10%, 22% { transform: translateX(0); }
34%, 46% { transform: translateX(100%); }
58%, 70% { transform: translateX(200%); }
82%, 100% { transform: translateX(300%); }
}
// 잉크 글자는 알약이 그 칸에 서 있는 동안만. 옮겨 가는 동안에는 두 칸 다 mist 다 —
// 잉크 글자가 잉크 바 위에 남으면 그 칸이 통째로 사라져 보인다 (272-04 실측)
// 창의 경계는 24프레임 눈금(4.1667% 간격)에 맞춰 실측으로 잡았다 —
// 알약이 그 칸의 라벨을 완전히 덮는 프레임에서만 잉크가 되게
@keyframes onA { 0%, 3.9% { color: $ft-mist; } 4%, 21.9% { color: $ft-ink; } 22%, 100% { color: $ft-mist; } }
@keyframes onB { 0%, 26.9% { color: $ft-mist; } 27%, 45.9% { color: $ft-ink; } 46%, 100% { color: $ft-mist; } }
@keyframes onC { 0%, 48.9% { color: $ft-mist; } 49%, 71.4% { color: $ft-ink; } 71.5%, 100% { color: $ft-mist; } }
@keyframes onD { 0%, 3.9% { color: $ft-ink; } 4%, 74.9% { color: $ft-mist; } 75%, 100% { color: $ft-ink; } }
@keyframes icPop {
0% { transform: translateY(0) scale(1); }
5% { transform: translateY(-5px) scale(1.16); }
12%, 100% { transform: translateY(0) scale(1); }
}
@media (max-width: 340px), (max-height: 220px) {
.dk { height: 156px; }
.dk__doc { min-height: 220px; }
.dk__body { gap: $sp-1; padding: $sp-3; }
.dk__title { font-size: 13px; }
.dk__row { height: 6px; }
.dk__ic svg { width: 18px; height: 18px; }
.dk__label { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// .is-demo 쪽 선택자가 (0,3,0) 이라 !important 없이는 진다 (260 실측)
.is-demo .dk__pill, .is-demo .dk__tab, .is-demo .dk__ic { animation: none !important; }
.dk__pill { transition: none !important; }
// 움직임을 끊어도 활성 칸 하나는 그대로 보인다
.is-demo .dk__tab.is-on { color: $ft-ink; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1b1a20;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dk {
position: relative;
box-sizing: border-box;
width: clamp(248px, 78vw, 320px);
height: 216px;
overflow: hidden;
border-radius: 16px;
background: #fbf6ee;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.dk__view {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
}
.dk__doc {
display: flex;
flex-direction: column;
min-height: 280px;
}
.dk__body {
flex: 1 0 auto;
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
}
.dk__title {
margin: 0;
color: #1b1a20;
font-weight: 700;
font-size: 15px;
line-height: 1.3;
font-family: inherit;
}
.dk__row {
display: block;
height: 8px;
border-radius: 999px;
background: #b9b3c4;
}
.dk__row--full {
width: 100%;
}
.dk__row--wide {
width: 86%;
}
.dk__row--mid {
width: 68%;
}
.dk__row--short {
width: 44%;
}
.dk__row--accent {
width: 52%;
background: #479089;
}
.dk__bar {
position: sticky;
bottom: 0;
margin-top: auto;
padding: 4px;
background: #1b1a20;
}
.is-demo .dk__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
}
.dk__nav {
position: relative;
display: grid;
grid-template-columns: repeat(4, 1fr);
}
.dk__pill {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 25%;
height: 100%;
border-radius: 12px;
background: #b3dad5;
transform: translateX(calc(var(--n, 0) * 100%));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dk__tab {
position: relative;
z-index: 1;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 4px;
border-radius: 12px;
color: #b9b3c4;
text-decoration: none;
}
.dk__tab.is-on {
color: #1b1a20;
}
.dk__tab:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 0;
}
.dk__ic {
display: block;
}
.dk__ic svg {
display: block;
}
.dk__label {
font-weight: 600;
font-size: 11px;
line-height: 1;
font-family: inherit;
}
.is-demo .dk__pill {
animation-name: dockSlide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.is-demo .dk__tab {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .dk__tab:nth-child(1) {
animation-name: onA;
}
.is-demo .dk__tab:nth-child(2) {
animation-name: onB;
}
.is-demo .dk__tab:nth-child(3) {
animation-name: onC;
}
.is-demo .dk__tab:nth-child(4) {
animation-name: onD;
}
.is-demo .dk__ic {
animation-name: icPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .dk__tab:nth-child(1) .dk__ic {
animation-delay: -1.92s;
}
.is-demo .dk__tab:nth-child(2) .dk__ic {
animation-delay: -1.46s;
}
.is-demo .dk__tab:nth-child(3) .dk__ic {
animation-delay: -1.02s;
}
.is-demo .dk__tab:nth-child(4) .dk__ic {
animation-delay: -0.5s;
}
@keyframes dockSlide {
0% {
transform: translateX(300%);
}
10%, 22% {
transform: translateX(0);
}
34%, 46% {
transform: translateX(100%);
}
58%, 70% {
transform: translateX(200%);
}
82%, 100% {
transform: translateX(300%);
}
}
@keyframes onA {
0%, 3.9% {
color: #b9b3c4;
}
4%, 21.9% {
color: #1b1a20;
}
22%, 100% {
color: #b9b3c4;
}
}
@keyframes onB {
0%, 26.9% {
color: #b9b3c4;
}
27%, 45.9% {
color: #1b1a20;
}
46%, 100% {
color: #b9b3c4;
}
}
@keyframes onC {
0%, 48.9% {
color: #b9b3c4;
}
49%, 71.4% {
color: #1b1a20;
}
71.5%, 100% {
color: #b9b3c4;
}
}
@keyframes onD {
0%, 3.9% {
color: #1b1a20;
}
4%, 74.9% {
color: #b9b3c4;
}
75%, 100% {
color: #1b1a20;
}
}
@keyframes icPop {
0% {
transform: translateY(0) scale(1);
}
5% {
transform: translateY(-5px) scale(1.16);
}
12%, 100% {
transform: translateY(0) scale(1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.dk {
height: 156px;
}
.dk__doc {
min-height: 220px;
}
.dk__body {
gap: 4px;
padding: 12px;
}
.dk__title {
font-size: 13px;
}
.dk__row {
height: 6px;
}
.dk__ic svg {
width: 18px;
height: 18px;
}
.dk__label {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .dk__pill, .is-demo .dk__tab, .is-demo .dk__ic {
animation: none !important;
}
.dk__pill {
transition: none !important;
}
.is-demo .dk__tab.is-on {
color: #1b1a20;
}
}(function () {
var body = document.body;
var nav = document.querySelector('.dk__nav');
var tabs = Array.prototype.slice.call(nav.querySelectorAll('.dk__tab'));
var n = 0;
// 알약 위치는 칸 번호 하나로만 정한다 — 짐작한 px 이 없으니 칸 폭이 바뀌어도 안 어긋난다
function move(i, focus) {
n = (i + tabs.length) % tabs.length;
tabs.forEach(function (t, k) {
var on = k === n;
t.classList.toggle('is-on', on);
if (on) t.setAttribute('aria-current', 'page');
else t.removeAttribute('aria-current');
t.tabIndex = on ? 0 : -1; // 로빙 탭인덱스 — Tab 은 활성 칸으로만 들어온다
});
nav.style.setProperty('--n', n);
if (focus) tabs[n].focus({ preventScroll: true });
}
function hand() { body.classList.remove('is-demo'); } // 사람이 손을 대면 자동 재생을 끈다
nav.addEventListener('keydown', function (e) {
var d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!d) return;
e.preventDefault();
hand();
move(n + d, true);
});
tabs.forEach(function (t, k) {
t.addEventListener('click', function (e) { e.preventDefault(); hand(); move(k, true); });
});
nav.addEventListener('pointerdown', hand);
nav.addEventListener('focusin', hand);
move(0, false);
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./FooterMobileDock.module.scss";
/* ambient 에 React 의 KeyboardEvent 가 없어 필요한 것만 로컬로 선언한다 */
type KeyEvt = { key: string; preventDefault(): void };
type ClickEvt = { preventDefault(): void };
export type FooterMobileDockProps = {
/** 칸 라벨 4개 */
labels?: string[];
/** 알약이 네 칸을 한 바퀴 도는 주기(ms) */
duration?: number;
easing?: string;
/** 활성 칸 알약 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--n"?: number;
};
/* Phosphor Duotone (MIT) — 집 · 돋보기 · 북마크 · 목록 */
const ICONS: ReactNode[] = [
<svg key="house" width="22" height="22" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path opacity=".2" d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" /><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z" /></g></svg>,
<svg key="glass" width="22" height="22" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path opacity=".2" d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" /><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" /></g></svg>,
<svg key="mark" width="22" height="22" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path opacity=".2" d="M192 176v48l-64-40l-64 40v-48l64-40Z" /><path d="M184 32H72a16 16 0 0 0-16 16v176a8 8 0 0 0 12.24 6.78L128 193.43l59.77 37.35A8 8 0 0 0 200 224V48a16 16 0 0 0-16-16m0 16v113.57l-51.77-32.35a8 8 0 0 0-8.48 0L72 161.56V48Zm-51.77 129.22a8 8 0 0 0-8.48 0L72 209.57v-29.14l56-35l56 35v29.14Z" /></g></svg>,
<svg key="list" width="22" height="22" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path opacity=".2" d="M216 64v128H40V64Z" /><path d="M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8M40 72h176a8 8 0 0 0 0-16H40a8 8 0 0 0 0 16m176 112H40a8 8 0 0 0 0 16h176a8 8 0 0 0 0-16" /></g></svg>,
];
const ROWS = ["full", "wide", "mid", "accent", "full", "wide", "short", "mid", "wide"];
export default function FooterMobileDock({
labels = ["홈", "찾기", "저장", "더보기"],
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#6ee7d7",
}: FooterMobileDockProps): ReactNode {
const [n, setN] = useState(0);
const [demo, setDemo] = useState(true); // 사람이 손을 대면 자동 재생을 끈다
const navRef = useRef<HTMLElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--n": n };
/* 알약 위치는 칸 번호 하나로만 정한다 — 짐작한 px 이 없으니 칸 폭이 바뀌어도 안 어긋난다 */
const move = (i: number, focus: boolean) => {
const next = (i + labels.length) % labels.length;
setDemo(false);
setN(next);
const nav = navRef.current;
if (focus && nav) {
const el = nav.querySelectorAll("a")[next] as HTMLElement | undefined;
if (el) el.focus({ preventScroll: true });
}
};
const onKey = (e: KeyEvt) => {
const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!d) return;
e.preventDefault();
move(n + d, true);
};
return (
<div className={`${styles.dock} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.view}>
<div className={styles.doc}>
<div className={styles.body}>
<p className={styles.title}>오늘의 기록</p>
{ROWS.map((w, i) => (
<span key={`${w}-${i}`} className={`${styles.row} ${styles[w]}`} aria-hidden="true" />
))}
</div>
{/* 스크롤되는 본문 안에서 sticky 로 바닥에 붙는다 */}
<footer className={styles.bar}>
<nav ref={navRef} className={styles.nav} aria-label="주요 화면 이동" onKeyDown={onKey}>
{labels.map((t, k) => (
<a
key={t}
className={`${styles.tab} ${k === n ? styles.isOn : ""}`}
href="#"
tabIndex={k === n ? 0 : -1}
aria-current={k === n ? "page" : undefined}
onClick={(e: ClickEvt) => { e.preventDefault(); move(k, true); }}
>
<span className={styles.ic}>{ICONS[k % ICONS.length]}</span>
<span className={styles.label}>{t}</span>
</a>
))}
{/* 폭이 칸 하나(25%)라 translateX(100%) 한 번이 정확히 한 칸이다 */}
<span className={styles.pill} aria-hidden="true" />
</nav>
</footer>
</div>
</div>
</div>
);
}04 · 스티키 하단 앱바 — 측정 누적면적 9.898% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
손가락이 화면 아래쪽에 머무는 모바일 웹앱에 씁니다.
.dk__nav {
position: relative;
display: grid;
grid-template-columns: repeat(4, 1fr);
}
/* 칸 하나 = 알약 폭 = 25%. translateX(100%) 가 정확히 한 칸이다 */
.dk__pill {
position: absolute; top: 0; left: 0; z-index: 0;
width: 25%;
height: 100%;
border-radius: 12px;
background: #6ee7d7;
transform: translateX(calc(var(--n, 0) * 100%));
transition: transform 300ms cubic-bezier(.2, .8, .2, 1);
}
.dk__tab { position: relative; z-index: 1; color: #b9b3c4; }
.dk__tab.is-on { color: #1b1a20; } /* 아쿠아 알약 위 잉크 글자 11.58:1 */
05소셜 타일 푸터
화면에서 일어나는 일
작은 아이콘 대신 손가락으로 누르기 쉬운 큰 타일 넷이 서고, 0.5초 간격으로 하나씩 6px 들렸다 앉습니다. 들린 타일은 그림자가 깊어지고 아이콘이 계정 고유색으로 바뀌며 모서리에 화살표가 scale(0) 에서 자라납니다.

<div class="stage">
<footer class="sc">
<p class="sc__head">팔로우</p>
<nav class="sc__grid" aria-label="소셜 계정 링크">
<a class="sc__tile" href="#">
<span class="sc__ic">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M176 32H80a48 48 0 0 0-48 48v96a48 48 0 0 0 48 48h96a48 48 0 0 0 48-48V80a48 48 0 0 0-48-48m-48 136a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M176 24H80a56.06 56.06 0 0 0-56 56v96a56.06 56.06 0 0 0 56 56h96a56.06 56.06 0 0 0 56-56V80a56.06 56.06 0 0 0-56-56m40 152a40 40 0 0 1-40 40H80a40 40 0 0 1-40-40V80a40 40 0 0 1 40-40h96a40 40 0 0 1 40 40Zm-88-96a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m64-84a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg>
</span>
<span class="sc__name">Instagram</span>
<span class="sc__go" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256"><g fill="currentColor"><path d="M192 64v104L88 64Z" opacity=".2"/><path d="M192 56H88a8 8 0 0 0-5.66 13.66L128.69 116l-70.35 70.34a8 8 0 0 0 11.32 11.32L140 127.31l46.34 46.35A8 8 0 0 0 200 168V64a8 8 0 0 0-8-8m-8 92.69l-38.34-38.34L107.31 72H184Z"/></g></svg>
</span>
</a>
<a class="sc__tile" href="#">
<span class="sc__ic">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 216h-48L48 40h48Z" opacity=".2"/><path d="m214.75 211.71l-62.6-98.38l61.77-67.95a8 8 0 0 0-11.84-10.76l-58.84 64.72l-40.49-63.63A8 8 0 0 0 96 32H48a8 8 0 0 0-6.75 12.3l62.6 98.37l-61.77 68a8 8 0 1 0 11.84 10.76l58.84-64.72l40.49 63.63A8 8 0 0 0 160 224h48a8 8 0 0 0 6.75-12.29M164.39 208L62.57 48h29l101.86 160Z"/></g></svg>
</span>
<span class="sc__name">X</span>
<span class="sc__go" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256"><g fill="currentColor"><path d="M192 64v104L88 64Z" opacity=".2"/><path d="M192 56H88a8 8 0 0 0-5.66 13.66L128.69 116l-70.35 70.34a8 8 0 0 0 11.32 11.32L140 127.31l46.34 46.35A8 8 0 0 0 200 168V64a8 8 0 0 0-8-8m-8 92.69l-38.34-38.34L107.31 72H184Z"/></g></svg>
</span>
</a>
<a class="sc__tile" href="#">
<span class="sc__ic">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M226.59 71.53a16 16 0 0 0-9.63-11C183.48 47.65 128 48 128 48s-55.48-.35-89 12.58a16 16 0 0 0-9.63 11C27.07 80.54 24 98.09 24 128s3.07 47.46 5.41 56.47A16 16 0 0 0 39 195.42C72.52 208.35 128 208 128 208s55.48.35 89-12.58a16 16 0 0 0 9.63-10.95c2.34-9 5.41-26.56 5.41-56.47s-3.11-47.46-5.45-56.47M112 160V96l48 32Z" opacity=".2"/><path d="m164.44 121.34l-48-32A8 8 0 0 0 104 96v64a8 8 0 0 0 12.44 6.66l48-32a8 8 0 0 0 0-13.32M120 145.05V111l25.58 17Zm114.33-75.53a24 24 0 0 0-14.49-16.4C185.56 39.88 131 40 128 40s-57.56-.12-91.84 13.12a24 24 0 0 0-14.49 16.4C19.08 79.5 16 97.74 16 128s3.08 48.5 5.67 58.48a24 24 0 0 0 14.49 16.41C69 215.56 120.4 216 127.34 216h1.32c6.94 0 58.37-.44 91.18-13.11a24 24 0 0 0 14.49-16.41c2.59-10 5.67-28.22 5.67-58.48s-3.08-48.5-5.67-58.48m-15.49 113a8 8 0 0 1-4.77 5.49c-31.65 12.22-85.48 12-86.12 12s-54.37.18-86-12a8 8 0 0 1-4.77-5.49C34.8 173.39 32 156.57 32 128s2.8-45.39 5.16-54.47A8 8 0 0 1 41.93 68c31.65-12.18 85.47-12 86.12-12s54.37-.18 86 12a8 8 0 0 1 4.77 5.49C221.2 82.61 224 99.43 224 128s-2.8 45.39-5.16 54.47Z"/></g></svg>
</span>
<span class="sc__name">YouTube</span>
<span class="sc__go" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256"><g fill="currentColor"><path d="M192 64v104L88 64Z" opacity=".2"/><path d="M192 56H88a8 8 0 0 0-5.66 13.66L128.69 116l-70.35 70.34a8 8 0 0 0 11.32 11.32L140 127.31l46.34 46.35A8 8 0 0 0 200 168V64a8 8 0 0 0-8-8m-8 92.69l-38.34-38.34L107.31 72H184Z"/></g></svg>
</span>
</a>
<a class="sc__tile" href="#">
<span class="sc__ic">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 104v8a48 48 0 0 1-48 48h-24a32 32 0 0 1 32 32v40h-64v-40a32 32 0 0 1 32-32h-24a48 48 0 0 1-48-48v-8a49.3 49.3 0 0 1 8.51-27.3A51.92 51.92 0 0 1 76 32a52 52 0 0 1 43.83 24h32.34A52 52 0 0 1 196 32a51.92 51.92 0 0 1 3.49 44.7A49.3 49.3 0 0 1 208 104" opacity=".2"/><path d="M208.3 75.68A59.74 59.74 0 0 0 202.93 28a8 8 0 0 0-6.93-4a59.75 59.75 0 0 0-48 24h-24a59.75 59.75 0 0 0-48-24a8 8 0 0 0-6.93 4a59.78 59.78 0 0 0-5.38 47.68A58.14 58.14 0 0 0 56 104v8a56.06 56.06 0 0 0 48.44 55.47A39.8 39.8 0 0 0 96 192v8H72a24 24 0 0 1-24-24a40 40 0 0 0-40-40a8 8 0 0 0 0 16a24 24 0 0 1 24 24a40 40 0 0 0 40 40h24v16a8 8 0 0 0 16 0v-40a24 24 0 0 1 48 0v40a8 8 0 0 0 16 0v-40a39.8 39.8 0 0 0-8.44-24.53A56.06 56.06 0 0 0 216 112v-8a58 58 0 0 0-7.7-28.32M200 112a40 40 0 0 1-40 40h-48a40 40 0 0 1-40-40v-8a41.74 41.74 0 0 1 6.9-22.48a8 8 0 0 0 1.1-7.69a43.8 43.8 0 0 1 .79-33.58a43.88 43.88 0 0 1 32.32 20.06a8 8 0 0 0 6.71 3.69h32.35a8 8 0 0 0 6.74-3.69a43.87 43.87 0 0 1 32.32-20.06a43.8 43.8 0 0 1 .77 33.58a8.09 8.09 0 0 0 1 7.65a41.76 41.76 0 0 1 7 22.52Z"/></g></svg>
</span>
<span class="sc__name">GitHub</span>
<span class="sc__go" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256"><g fill="currentColor"><path d="M192 64v104L88 64Z" opacity=".2"/><path d="M192 56H88a8 8 0 0 0-5.66 13.66L128.69 116l-70.35 70.34a8 8 0 0 0 11.32 11.32L140 127.31l46.34 46.35A8 8 0 0 0 200 168V64a8 8 0 0 0-8-8m-8 92.69l-38.34-38.34L107.31 72H184Z"/></g></svg>
</span>
</a>
</nav>
<p class="sc__note">계정마다 다른 소식이 올라옵니다</p>
</footer>
</div>@use "../../../../site/static/demo-tokens" as *;
// 편 272 푸터 팔레트 — 04·05·06 이 같은 값을 쓴다.
// 대비 실측(WCAG 2.1): ink on flame 5.21:1 · ink on paper 17.28:1 · stone on paper 7.61:1 · teal on paper 5.79:1
// ⛔ 흰 글자 on flame 은 3.32:1 이라 이 무대에서는 흰 글자를 주황 위에 얹지 않는다
$ft-ink: #1b1a20;
$ft-ink2: #2c2a33;
$ft-paper: #ffffff;
$ft-cream: #fbf6ee;
$ft-stone: #575162;
$ft-mist: #b9b3c4;
$ft-amber: $stage-yellow;
$ft-flame: $stage-orange;
$ft-teal: #479089;
$ft-aqua: #b3dad5;
// 계정 색 — 아이콘(비텍스트)에만 쓴다. 흰 타일 위 대비 실측:
// 인스타 #c3869b 4.34:1 · X #0f0f0f 19.3:1 · 유튜브 #bd7a7a 4.00:1 · 깃허브 #181717 17.0:1 (기준 3:1)
$br-instagram: #c3869b;
$br-x: #0f0f0f;
$br-youtube: #bd7a7a;
$br-github: #181717;
$duration: $dur-loop; // 타일 넷이 차례로 한 번씩 들리는 주기
$easing: $ease-pop; // 들릴 때만 살짝 튕긴다
$color: $ft-paper; // 타일 면 색
@include stage($ft-flame);
.sc {
box-sizing: border-box;
width: clamp(268px, 88vw, 408px);
display: flex;
flex-direction: column;
gap: $sp-2;
}
.sc__head {
margin: 0;
color: $ft-ink; // 주황 위 잉크 5.21:1
font-weight: 800; font-size: 14px; line-height: 1.2;
letter-spacing: .04em;
font-family: inherit;
}
.sc__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $sp-3;
}
.sc__tile {
--brand: #{$br-instagram};
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: $sp-2;
min-height: 88px;
padding: $sp-4;
border-radius: $r-card;
background: $color;
color: $ft-ink;
text-decoration: none;
box-shadow: $shadow-press;
transform: translateY(0);
transition: transform $dur-quick $easing, box-shadow $dur-quick $ease-out;
}
.sc__tile:nth-child(1) { --brand: #{$br-instagram}; }
.sc__tile:nth-child(2) { --brand: #{$br-x}; }
.sc__tile:nth-child(3) { --brand: #{$br-youtube}; }
.sc__tile:nth-child(4) { --brand: #{$br-github}; }
// 정적 경로를 먼저 쓴다 — .is-demo 를 떼도 호버·포커스로 같은 동작이 그대로 남는다
.sc__tile:hover,
.sc__tile:focus-visible {
transform: translateY(-6px);
box-shadow: $shadow-raised;
}
.sc__tile:hover .sc__ic,
.sc__tile:focus-visible .sc__ic { color: var(--brand); }
.sc__tile:hover .sc__go,
.sc__tile:focus-visible .sc__go { transform: scale(1); }
.sc__tile:focus-visible {
outline: 3px solid $ft-ink;
outline-offset: 2px;
}
.sc__ic {
display: block;
color: $ft-stone; // 흰 타일 위 7.61:1
transition: color $dur-quick $ease-out;
}
.sc__ic svg { display: block; }
.sc__name {
font-weight: 700; font-size: 14px; line-height: 1;
font-family: inherit;
}
// 모서리의 "열림" 표시 — scale(0) 이라 쉴 때는 픽셀이 아예 없다.
// opacity 로 숨기면 중간 알파 프레임이 poster 에 유령으로 찍힌다
.sc__go {
position: absolute;
top: 8px;
right: 8px;
display: block;
color: $ft-teal; // 흰 타일 위 5.79:1
transform: scale(0);
transform-origin: top right; // 규칙8: 모서리 표시 꼭짓점서 펼침
transition: transform $dur-quick $easing;
}
.sc__go svg { display: block; }
.sc__note {
margin: 0;
color: $ft-ink;
font-weight: 500; font-size: 12px; line-height: 1.3;
font-family: inherit;
}
// ── 자동 재생 ───────────────────────────────────────────────
// 네 타일이 500ms 씩 차례로 들렸다 앉는다. 시차는 음수 delay 로 준다 —
// animation 단축을 쓰면 delay 가 0 으로 리셋된다
.is-demo .sc__tile {
animation-name: tileLift;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .sc__tile .sc__ic {
animation-name: tileIcon;
animation-duration: $duration;
animation-timing-function: steps(1, end); // 색 교체는 한 프레임 컷
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .sc__tile .sc__go {
animation-name: tileGo;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .sc__tile:nth-child(2),
.is-demo .sc__tile:nth-child(2) .sc__ic,
.is-demo .sc__tile:nth-child(2) .sc__go { animation-delay: -1.5s; }
.is-demo .sc__tile:nth-child(3),
.is-demo .sc__tile:nth-child(3) .sc__ic,
.is-demo .sc__tile:nth-child(3) .sc__go { animation-delay: -1s; }
.is-demo .sc__tile:nth-child(4),
.is-demo .sc__tile:nth-child(4) .sc__ic,
.is-demo .sc__tile:nth-child(4) .sc__go { animation-delay: -.5s; }
// 들림은 빠르게(0~5%), 앉음은 느리게(16~25%) 둔다.
// poster 는 "앞 장과 가장 크게 달라진 장" 이라 앉는 쪽이 더 빠르면 아무것도 안 들린 장이 대표가 된다
@keyframes tileLift {
0% { transform: translateY(0); box-shadow: $shadow-press; }
5%, 16% { transform: translateY(-6px); box-shadow: $shadow-raised; }
25%, 100% { transform: translateY(0); box-shadow: $shadow-press; }
}
@keyframes tileIcon {
0%, 19.9% { color: var(--brand); }
20%, 100% { color: $ft-stone; }
}
@keyframes tileGo {
0% { transform: scale(0); }
5%, 16% { transform: scale(1); }
25%, 100% { transform: scale(0); }
}
@media (max-width: 340px), (max-height: 220px) {
.sc { width: 268px; gap: $sp-1; }
.sc__head { font-size: 12px; }
.sc__grid { gap: $sp-2; }
.sc__tile { min-height: 56px; gap: $sp-1; padding: $sp-2; }
.sc__ic svg { width: 20px; height: 20px; }
.sc__name { font-size: 11px; }
.sc__go { top: 4px; right: 4px; }
.sc__go svg { width: 12px; height: 12px; }
.sc__note { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// .is-demo 쪽 선택자가 (0,3,0) 이라 !important 없이는 진다 (260 실측)
.is-demo .sc__tile,
.is-demo .sc__tile .sc__ic,
.is-demo .sc__tile .sc__go { animation: none !important; }
.sc__tile, .sc__ic, .sc__go { transition: none !important; }
// 움직임을 끊어도 어느 타일이 열리는 링크인지는 남는다
.sc__tile:hover .sc__go,
.sc__tile:focus-visible .sc__go { transform: scale(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);
}
.sc {
box-sizing: border-box;
width: clamp(268px, 88vw, 408px);
display: flex;
flex-direction: column;
gap: 8px;
}
.sc__head {
margin: 0;
color: #1b1a20;
font-weight: 800;
font-size: 14px;
line-height: 1.2;
letter-spacing: 0.04em;
font-family: inherit;
}
.sc__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.sc__tile {
--brand: #c3869b;
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 8px;
min-height: 88px;
padding: 16px;
border-radius: 16px;
background: #ffffff;
color: #1b1a20;
text-decoration: none;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateY(0);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), box-shadow 220ms ease-out;
}
.sc__tile:nth-child(1) {
--brand: #c3869b;
}
.sc__tile:nth-child(2) {
--brand: #0f0f0f;
}
.sc__tile:nth-child(3) {
--brand: #bd7a7a;
}
.sc__tile:nth-child(4) {
--brand: #181717;
}
.sc__tile:hover,
.sc__tile:focus-visible {
transform: translateY(-6px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sc__tile:hover .sc__ic,
.sc__tile:focus-visible .sc__ic {
color: var(--brand);
}
.sc__tile:hover .sc__go,
.sc__tile:focus-visible .sc__go {
transform: scale(1);
}
.sc__tile:focus-visible {
outline: 3px solid #1b1a20;
outline-offset: 2px;
}
.sc__ic {
display: block;
color: #575162;
transition: color 220ms ease-out;
}
.sc__ic svg {
display: block;
}
.sc__name {
font-weight: 700;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.sc__go {
position: absolute;
top: 8px;
right: 8px;
display: block;
color: #479089;
transform: scale(0);
transform-origin: top right;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.sc__go svg {
display: block;
}
.sc__note {
margin: 0;
color: #1b1a20;
font-weight: 500;
font-size: 12px;
line-height: 1.3;
font-family: inherit;
}
.is-demo .sc__tile {
animation-name: tileLift;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .sc__tile .sc__ic {
animation-name: tileIcon;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .sc__tile .sc__go {
animation-name: tileGo;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .sc__tile:nth-child(2),
.is-demo .sc__tile:nth-child(2) .sc__ic,
.is-demo .sc__tile:nth-child(2) .sc__go {
animation-delay: -1.5s;
}
.is-demo .sc__tile:nth-child(3),
.is-demo .sc__tile:nth-child(3) .sc__ic,
.is-demo .sc__tile:nth-child(3) .sc__go {
animation-delay: -1s;
}
.is-demo .sc__tile:nth-child(4),
.is-demo .sc__tile:nth-child(4) .sc__ic,
.is-demo .sc__tile:nth-child(4) .sc__go {
animation-delay: -0.5s;
}
@keyframes tileLift {
0% {
transform: translateY(0);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
5%, 16% {
transform: translateY(-6px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
25%, 100% {
transform: translateY(0);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
@keyframes tileIcon {
0%, 19.9% {
color: var(--brand);
}
20%, 100% {
color: #575162;
}
}
@keyframes tileGo {
0% {
transform: scale(0);
}
5%, 16% {
transform: scale(1);
}
25%, 100% {
transform: scale(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.sc {
width: 268px;
gap: 4px;
}
.sc__head {
font-size: 12px;
}
.sc__grid {
gap: 8px;
}
.sc__tile {
min-height: 56px;
gap: 4px;
padding: 8px;
}
.sc__ic svg {
width: 20px;
height: 20px;
}
.sc__name {
font-size: 11px;
}
.sc__go {
top: 4px;
right: 4px;
}
.sc__go svg {
width: 12px;
height: 12px;
}
.sc__note {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .sc__tile,
.is-demo .sc__tile .sc__ic,
.is-demo .sc__tile .sc__go {
animation: none !important;
}
.sc__tile, .sc__ic, .sc__go {
transition: none !important;
}
.sc__tile:hover .sc__go,
.sc__tile:focus-visible .sc__go {
transform: scale(1);
}
}(function () {
var body = document.body;
var grid = document.querySelector('.sc__grid');
var tiles = Array.prototype.slice.call(grid.querySelectorAll('.sc__tile'));
function hand() { body.classList.remove('is-demo'); } // 손이 닿으면 자동 재생을 끈다
// 2×2 격자라 좌우는 ±1, 상하는 ±2 칸 — 화살표 네 방향이 전부 산다
grid.addEventListener('keydown', function (e) {
var step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: 2, ArrowUp: -2 }[e.key];
if (!step) return;
e.preventDefault();
hand();
var i = tiles.indexOf(document.activeElement);
var next = (i < 0 ? 0 : i + step + tiles.length) % tiles.length;
tiles[next].focus({ preventScroll: true });
});
grid.addEventListener('pointerdown', hand);
grid.addEventListener('pointerover', hand);
grid.addEventListener('focusin', hand);
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./FooterSocialTiles.module.scss";
/* ambient 에 React 의 KeyboardEvent 가 없어 필요한 것만 로컬로 선언한다 */
type KeyEvt = { key: string; preventDefault(): void };
export type FooterSocialTilesProps = {
/** 타일 넷이 차례로 한 번씩 들리는 주기(ms) */
duration?: number;
easing?: string;
/** 타일 면 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type TileVars = CSSProperties & { "--brand"?: string };
/* Phosphor Duotone (MIT). 계정 색은 아이콘(비텍스트)에만 쓴다 —
흰 타일 위 대비 실측: #e1306c 4.34:1 · #0f0f0f 19.3:1 · #ff0000 4.00:1 · #181717 17.0:1 (기준 3:1) */
const TILES: { name: string; brand: string; icon: ReactNode }[] = [
{
name: "Instagram", brand: "#e1306c",
icon: <svg width="28" height="28" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path opacity=".2" d="M176 32H80a48 48 0 0 0-48 48v96a48 48 0 0 0 48 48h96a48 48 0 0 0 48-48V80a48 48 0 0 0-48-48m-48 136a40 40 0 1 1 40-40a40 40 0 0 1-40 40" /><path d="M176 24H80a56.06 56.06 0 0 0-56 56v96a56.06 56.06 0 0 0 56 56h96a56.06 56.06 0 0 0 56-56V80a56.06 56.06 0 0 0-56-56m40 152a40 40 0 0 1-40 40H80a40 40 0 0 1-40-40V80a40 40 0 0 1 40-40h96a40 40 0 0 1 40 40Zm-88-96a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m64-84a12 12 0 1 1-12-12a12 12 0 0 1 12 12" /></g></svg>,
},
{
name: "X", brand: "#0f0f0f",
icon: <svg width="28" height="28" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path opacity=".2" d="M208 216h-48L48 40h48Z" /><path d="m214.75 211.71l-62.6-98.38l61.77-67.95a8 8 0 0 0-11.84-10.76l-58.84 64.72l-40.49-63.63A8 8 0 0 0 96 32H48a8 8 0 0 0-6.75 12.3l62.6 98.37l-61.77 68a8 8 0 1 0 11.84 10.76l58.84-64.72l40.49 63.63A8 8 0 0 0 160 224h48a8 8 0 0 0 6.75-12.29M164.39 208L62.57 48h29l101.86 160Z" /></g></svg>,
},
{
name: "YouTube", brand: "#ff0000",
icon: <svg width="28" height="28" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path opacity=".2" d="M226.59 71.53a16 16 0 0 0-9.63-11C183.48 47.65 128 48 128 48s-55.48-.35-89 12.58a16 16 0 0 0-9.63 11C27.07 80.54 24 98.09 24 128s3.07 47.46 5.41 56.47A16 16 0 0 0 39 195.42C72.52 208.35 128 208 128 208s55.48.35 89-12.58a16 16 0 0 0 9.63-10.95c2.34-9 5.41-26.56 5.41-56.47s-3.11-47.46-5.45-56.47M112 160V96l48 32Z" /><path d="m164.44 121.34l-48-32A8 8 0 0 0 104 96v64a8 8 0 0 0 12.44 6.66l48-32a8 8 0 0 0 0-13.32M120 145.05V111l25.58 17Zm114.33-75.53a24 24 0 0 0-14.49-16.4C185.56 39.88 131 40 128 40s-57.56-.12-91.84 13.12a24 24 0 0 0-14.49 16.4C19.08 79.5 16 97.74 16 128s3.08 48.5 5.67 58.48a24 24 0 0 0 14.49 16.41C69 215.56 120.4 216 127.34 216h1.32c6.94 0 58.37-.44 91.18-13.11a24 24 0 0 0 14.49-16.41c2.59-10 5.67-28.22 5.67-58.48s-3.08-48.5-5.67-58.48m-15.49 113a8 8 0 0 1-4.77 5.49c-31.65 12.22-85.48 12-86.12 12s-54.37.18-86-12a8 8 0 0 1-4.77-5.49C34.8 173.39 32 156.57 32 128s2.8-45.39 5.16-54.47A8 8 0 0 1 41.93 68c31.65-12.18 85.47-12 86.12-12s54.37-.18 86 12a8 8 0 0 1 4.77 5.49C221.2 82.61 224 99.43 224 128s-2.8 45.39-5.16 54.47Z" /></g></svg>,
},
{
name: "GitHub", brand: "#181717",
icon: <svg width="28" height="28" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path opacity=".2" d="M208 104v8a48 48 0 0 1-48 48h-24a32 32 0 0 1 32 32v40h-64v-40a32 32 0 0 1 32-32h-24a48 48 0 0 1-48-48v-8a49.3 49.3 0 0 1 8.51-27.3A51.92 51.92 0 0 1 76 32a52 52 0 0 1 43.83 24h32.34A52 52 0 0 1 196 32a51.92 51.92 0 0 1 3.49 44.7A49.3 49.3 0 0 1 208 104" /><path d="M208.3 75.68A59.74 59.74 0 0 0 202.93 28a8 8 0 0 0-6.93-4a59.75 59.75 0 0 0-48 24h-24a59.75 59.75 0 0 0-48-24a8 8 0 0 0-6.93 4a59.78 59.78 0 0 0-5.38 47.68A58.14 58.14 0 0 0 56 104v8a56.06 56.06 0 0 0 48.44 55.47A39.8 39.8 0 0 0 96 192v8H72a24 24 0 0 1-24-24a40 40 0 0 0-40-40a8 8 0 0 0 0 16a24 24 0 0 1 24 24a40 40 0 0 0 40 40h24v16a8 8 0 0 0 16 0v-40a24 24 0 0 1 48 0v40a8 8 0 0 0 16 0v-40a39.8 39.8 0 0 0-8.44-24.53A56.06 56.06 0 0 0 216 112v-8a58 58 0 0 0-7.7-28.32M200 112a40 40 0 0 1-40 40h-48a40 40 0 0 1-40-40v-8a41.74 41.74 0 0 1 6.9-22.48a8 8 0 0 0 1.1-7.69a43.8 43.8 0 0 1 .79-33.58a43.88 43.88 0 0 1 32.32 20.06a8 8 0 0 0 6.71 3.69h32.35a8 8 0 0 0 6.74-3.69a43.87 43.87 0 0 1 32.32-20.06a43.8 43.8 0 0 1 .77 33.58a8.09 8.09 0 0 0 1 7.65a41.76 41.76 0 0 1 7 22.52Z" /></g></svg>,
},
];
const GO: ReactNode = (
<svg width="14" height="14" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path opacity=".2" d="M192 64v104L88 64Z" /><path d="M192 56H88a8 8 0 0 0-5.66 13.66L128.69 116l-70.35 70.34a8 8 0 0 0 11.32 11.32L140 127.31l46.34 46.35A8 8 0 0 0 200 168V64a8 8 0 0 0-8-8m-8 92.69l-38.34-38.34L107.31 72H184Z" /></g></svg>
);
export default function FooterSocialTiles({
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ffffff",
}: FooterSocialTilesProps): ReactNode {
const [demo, setDemo] = useState(true); // 손이 닿으면 자동 재생을 끈다
const gridRef = useRef<HTMLElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
/* 2×2 격자라 좌우는 ±1, 상하는 ±2 칸 — 화살표 네 방향이 전부 산다 */
const onKey = (e: KeyEvt) => {
const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1
: e.key === "ArrowDown" ? 2 : e.key === "ArrowUp" ? -2 : 0;
if (!step) return;
e.preventDefault();
setDemo(false);
const grid = gridRef.current;
if (!grid) return;
const list = grid.querySelectorAll("a");
const now = Array.prototype.indexOf.call(list, document.activeElement);
const next = ((now < 0 ? 0 : now + step) + list.length) % list.length;
const el = list[next] as HTMLElement | undefined;
if (el) el.focus({ preventScroll: true });
};
return (
<footer className={`${styles.sc} ${demo ? styles.isDemo : ""}`} style={vars}>
<p className={styles.head}>팔로우</p>
<nav
ref={gridRef}
className={styles.grid}
aria-label="소셜 계정 링크"
onKeyDown={onKey}
onPointerDown={() => setDemo(false)}
onPointerOver={() => setDemo(false)}
>
{TILES.map((t) => {
const tv: TileVars = { "--brand": t.brand };
return (
<a key={t.name} className={styles.tile} href="#" style={tv}>
<span className={styles.ic}>{t.icon}</span>
<span className={styles.name}>{t.name}</span>
<span className={styles.go} aria-hidden="true">{GO}</span>
</a>
);
})}
</nav>
<p className={styles.note}>계정마다 다른 소식이 올라옵니다</p>
</footer>
);
}05 · 소셜 타일 푸터 — 측정 누적면적 13.894% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
팔로우를 유도하는 크리에이터 사이트에 씁니다.
/* 들림은 빠르게(0~5%), 앉음은 느리게(16~25%) 둔다 */
@keyframes tileLift {
0% { transform: translateY(0); box-shadow: 0 6px 12px rgba(23, 20, 26, .32); }
5%, 16% { transform: translateY(-6px); box-shadow: 0 14px 24px rgba(23, 20, 26, .32); }
25%, 100% { transform: translateY(0); box-shadow: 0 6px 12px rgba(23, 20, 26, .32); }
}
/* 모서리의 열림 표시 — scale(0) 이라 쉴 때는 픽셀이 아예 없다 */
@keyframes tileGo {
0% { transform: scale(0); }
5%, 16% { transform: scale(1); }
25%, 100% { transform: scale(0); }
}
/* 시차는 음수 delay 로. animation 단축을 쓰면 delay 가 0 으로 리셋된다 */
.is-demo .sc__tile:nth-child(2) { animation-delay: -1.5s; }
.is-demo .sc__tile:nth-child(3) { animation-delay: -1s; }
.is-demo .sc__tile:nth-child(4) { animation-delay: -.5s; }
06법적·링크 트리 푸터
어떤 움직임인가
중요한 링크일수록 글자가 커지는 계단을 만들고, 약관과 정책은 그 아래 작은 줄로 따로 모았습니다. 마우스를 얹으면 링크가 오른쪽으로 6px 밀리면서 밑줄이 왼쪽에서 오른쪽으로 차오르는데, 폭이 아니라 scaleX 라서 글자 위치를 다시 계산하지 않습니다.

<div class="stage">
<footer class="lt">
<nav class="lt__tree" aria-label="주요 링크">
<a class="lt__link lt__link--lead" href="#" aria-current="page">작업 모음</a>
<a class="lt__link lt__link--lead" href="#">글 목록</a>
<a class="lt__link lt__link--sub" href="#">소개</a>
<a class="lt__link lt__link--sub" href="#">문의하기</a>
</nav>
<nav class="lt__legal" aria-label="법적 고지">
<a class="lt__fine" href="#">이용약관</a>
<a class="lt__fine" href="#">개인정보 처리방침</a>
<a class="lt__fine" href="#">쿠키 설정</a>
</nav>
</footer>
</div>@use "../../../../site/static/demo-tokens" as *;
// 편 272 푸터 팔레트 — 04·05·06 이 같은 값을 쓴다.
// 대비 실측(WCAG 2.1): ink on amber 11.97:1
// ⛔ 흰 글자 on amber 는 1.44:1 이라 이 무대의 글자는 전부 잉크다
$ft-ink: #1b1a20;
$ft-ink2: #2c2a33;
$ft-paper: #ffffff;
$ft-cream: #fbf6ee;
$ft-stone: #575162;
$ft-mist: #b9b3c4;
$ft-amber: $stage-yellow;
$ft-flame: $stage-orange;
$ft-teal: #479089;
$ft-aqua: #b3dad5;
$duration: $dur-loop; // 링크 넷에 차례로 밑줄이 차는 주기
$easing: $ease-out; // 밑줄은 튕기지 않고 곧게 차오른다
$color: $ft-ink; // 밑줄·글자 색
@include stage($ft-amber);
.lt {
box-sizing: border-box;
width: clamp(248px, 84vw, 392px);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $sp-4;
}
.lt__tree {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $sp-2;
}
// 중요도 계단 — font-size 3단. 큰 것이 주요 링크, 작은 것이 법적 문서
.lt__link {
position: relative;
display: inline-block;
color: $color;
text-decoration: none;
transform: translateX(0);
transition: transform $dur-quick $easing;
font-family: inherit;
}
.lt__link--lead { font-weight: 800; font-size: clamp(20px, 5.4vw, 26px); line-height: 1.15; }
.lt__link--sub { font-weight: 600; font-size: clamp(14px, 3.6vw, 17px); line-height: 1.2; }
// 밑줄은 왼쪽 원점에서 scaleX 로 차오른다 — 폭이 아니라 transform 이라 레이아웃을 다시 계산하지 않는다
.lt__link::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 3px;
border-radius: $r-pill;
background: $color;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 밑줄 왼쪽 채움
transition: transform $dur-quick $easing;
}
// 정적 경로를 먼저 쓴다 — .is-demo 를 떼도 호버·포커스에서 같은 동작이 남는다
.lt__link:hover,
.lt__link:focus-visible { transform: translateX(6px); }
.lt__link:hover::after,
.lt__link:focus-visible::after { transform: scaleX(1); }
// 현재 위치 — aria-current 에 굵기와 점 표시를 둘 다 준다(색·굵기 하나만으로는 약하다)
.lt__link[aria-current="page"] { font-weight: 900; }
.lt__link[aria-current="page"]::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
margin-right: $sp-2;
border-radius: 50%;
background: $color;
vertical-align: middle;
}
.lt__legal {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-1 $sp-4;
}
.lt__fine {
position: relative;
color: $color;
text-decoration: none;
font-weight: 500; font-size: clamp(10px, 2.7vw, 12px); line-height: 1.4;
font-family: inherit;
border-bottom: 1px solid $color;
}
.lt__link:focus-visible,
.lt__fine:focus-visible {
outline: 3px solid $color;
outline-offset: 4px;
}
// ── 자동 재생 ───────────────────────────────────────────────
// 계단 링크 넷에 500ms 씩 차례로 밑줄이 찬다. 시차는 음수 delay 로 —
// animation 단축을 쓰면 delay 가 0 으로 리셋된다
.is-demo .lt__link::after {
animation-name: fillLine;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .lt__link {
animation-name: stepIn;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .lt__link:nth-child(2),
.is-demo .lt__link:nth-child(2)::after { animation-delay: -1.5s; }
.is-demo .lt__link:nth-child(3),
.is-demo .lt__link:nth-child(3)::after { animation-delay: -1s; }
.is-demo .lt__link:nth-child(4),
.is-demo .lt__link:nth-child(4)::after { animation-delay: -.5s; }
// 차오름은 빠르게(0~5%), 물러남은 느리게(16~25%) 둔다.
// poster 는 "앞 장과 가장 크게 달라진 장" 이라 물러남이 더 빠르면 밑줄이 없는 장이 대표가 된다 (272-06 실측)
@keyframes fillLine {
0% { transform: scaleX(0); }
5%, 16% { transform: scaleX(1); }
25%, 100% { transform: scaleX(0); }
}
@keyframes stepIn {
0% { transform: translateX(0); }
5%, 16% { transform: translateX(6px); }
25%, 100% { transform: translateX(0); }
}
@media (max-width: 340px), (max-height: 220px) {
.lt { width: 248px; gap: $sp-3; }
.lt__link--lead { font-size: 19px; }
.lt__link--sub { font-size: 13px; }
.lt__legal { gap: 0 $sp-3; }
.lt__fine { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// .is-demo 쪽 선택자가 (0,3,0) 이라 !important 없이는 진다 (260 실측)
.is-demo .lt__link,
.is-demo .lt__link::after { animation: none !important; }
.lt__link, .lt__link::after { transition: none !important; }
// 움직임을 끊어도 현재 위치의 밑줄은 그대로 차 있다
.lt__link[aria-current="page"]::after { transform: scaleX(1); }
}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);
}
.lt {
box-sizing: border-box;
width: clamp(248px, 84vw, 392px);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.lt__tree {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.lt__link {
position: relative;
display: inline-block;
color: #1b1a20;
text-decoration: none;
transform: translateX(0);
transition: transform 220ms ease-out;
font-family: inherit;
}
.lt__link--lead {
font-weight: 800;
font-size: clamp(20px, 5.4vw, 26px);
line-height: 1.15;
}
.lt__link--sub {
font-weight: 600;
font-size: clamp(14px, 3.6vw, 17px);
line-height: 1.2;
}
.lt__link::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 3px;
border-radius: 999px;
background: #1b1a20;
transform: scaleX(0);
transform-origin: left center;
transition: transform 220ms ease-out;
}
.lt__link:hover,
.lt__link:focus-visible {
transform: translateX(6px);
}
.lt__link:hover::after,
.lt__link:focus-visible::after {
transform: scaleX(1);
}
.lt__link[aria-current=page] {
font-weight: 900;
}
.lt__link[aria-current=page]::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
margin-right: 8px;
border-radius: 50%;
background: #1b1a20;
vertical-align: middle;
}
.lt__legal {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 16px;
}
.lt__fine {
position: relative;
color: #1b1a20;
text-decoration: none;
font-weight: 500;
font-size: clamp(10px, 2.7vw, 12px);
line-height: 1.4;
font-family: inherit;
border-bottom: 1px solid #1b1a20;
}
.lt__link:focus-visible,
.lt__fine:focus-visible {
outline: 3px solid #1b1a20;
outline-offset: 4px;
}
.is-demo .lt__link::after {
animation-name: fillLine;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .lt__link {
animation-name: stepIn;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .lt__link:nth-child(2),
.is-demo .lt__link:nth-child(2)::after {
animation-delay: -1.5s;
}
.is-demo .lt__link:nth-child(3),
.is-demo .lt__link:nth-child(3)::after {
animation-delay: -1s;
}
.is-demo .lt__link:nth-child(4),
.is-demo .lt__link:nth-child(4)::after {
animation-delay: -0.5s;
}
@keyframes fillLine {
0% {
transform: scaleX(0);
}
5%, 16% {
transform: scaleX(1);
}
25%, 100% {
transform: scaleX(0);
}
}
@keyframes stepIn {
0% {
transform: translateX(0);
}
5%, 16% {
transform: translateX(6px);
}
25%, 100% {
transform: translateX(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.lt {
width: 248px;
gap: 12px;
}
.lt__link--lead {
font-size: 19px;
}
.lt__link--sub {
font-size: 13px;
}
.lt__legal {
gap: 0 12px;
}
.lt__fine {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .lt__link,
.is-demo .lt__link::after {
animation: none !important;
}
.lt__link, .lt__link::after {
transition: none !important;
}
.lt__link[aria-current=page]::after {
transform: scaleX(1);
}
}(function () {
var body = document.body;
var root = document.querySelector('.lt');
var links = Array.prototype.slice.call(root.querySelectorAll('a'));
function hand() { body.classList.remove('is-demo'); } // 손이 닿으면 자동 재생을 끈다
// 계단은 세로 한 줄이라 ↑↓ 가 다음·이전 링크, Home·End 가 처음·끝
root.addEventListener('keydown', function (e) {
var i = links.indexOf(document.activeElement);
var to = -1;
if (e.key === 'ArrowDown') to = (i + 1 + links.length) % links.length;
else if (e.key === 'ArrowUp') to = (i - 1 + links.length) % links.length;
else if (e.key === 'Home') to = 0;
else if (e.key === 'End') to = links.length - 1;
if (to < 0) return;
e.preventDefault();
hand();
links[to].focus({ preventScroll: true });
});
root.addEventListener('pointerdown', hand);
root.addEventListener('pointerover', hand);
root.addEventListener('focusin', hand);
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./FooterLinktreeSteps.module.scss";
/* ambient 에 React 의 KeyboardEvent 가 없어 필요한 것만 로컬로 선언한다 */
type KeyEvt = { key: string; preventDefault(): void };
export type FooterLinktreeStepsProps = {
/** 링크 넷에 차례로 밑줄이 차는 주기(ms) */
duration?: number;
easing?: string;
/** 밑줄·글자 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/* 중요도 계단 — lead 가 주요 링크, sub 가 보조 링크, fine 이 법적 문서 */
const TREE: { text: string; lead: boolean; current?: boolean }[] = [
{ text: "작업 모음", lead: true, current: true },
{ text: "글 목록", lead: true },
{ text: "소개", lead: false },
{ text: "문의하기", lead: false },
];
const LEGAL = ["이용약관", "개인정보 처리방침", "쿠키 설정"];
export default function FooterLinktreeSteps({
duration = 2000,
easing = "ease-out",
color = "#1b1a20",
}: FooterLinktreeStepsProps): ReactNode {
const [demo, setDemo] = useState(true); // 손이 닿으면 자동 재생을 끈다
const rootRef = useRef<HTMLElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
/* 계단은 세로 한 줄이라 ↑↓ 가 다음·이전 링크, Home·End 가 처음·끝 */
const onKey = (e: KeyEvt) => {
const root = rootRef.current;
if (!root) return;
const list = root.querySelectorAll("a");
const now = Array.prototype.indexOf.call(list, document.activeElement);
let to = -1;
if (e.key === "ArrowDown") to = (now + 1 + list.length) % list.length;
else if (e.key === "ArrowUp") to = (now - 1 + list.length) % list.length;
else if (e.key === "Home") to = 0;
else if (e.key === "End") to = list.length - 1;
if (to < 0) return;
e.preventDefault();
setDemo(false);
const el = list[to] as HTMLElement | undefined;
if (el) el.focus({ preventScroll: true });
};
return (
<footer
ref={rootRef}
className={`${styles.lt} ${demo ? styles.isDemo : ""}`}
style={vars}
onKeyDown={onKey}
onPointerDown={() => setDemo(false)}
onPointerOver={() => setDemo(false)}
>
<nav className={styles.tree} aria-label="주요 링크">
{TREE.map((l) => (
<a
key={l.text}
className={`${styles.link} ${l.lead ? styles.lead : styles.sub}`}
href="#"
aria-current={l.current ? "page" : undefined}
>
{l.text}
</a>
))}
</nav>
<nav className={styles.legal} aria-label="법적 고지">
{LEGAL.map((t) => (
<a key={t} className={styles.fine} href="#">{t}</a>
))}
</nav>
</footer>
);
}06 · 법적·링크 트리 푸터 — 측정 누적면적 3.451% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
링크 하나로 모으는 프로필형 하단에 씁니다.
.lt__link--lead { font-weight: 800; font-size: clamp(20px, 5.4vw, 26px); line-height: 1.15; }
.lt__link--sub { font-weight: 600; font-size: clamp(14px, 3.6vw, 17px); line-height: 1.2; }
/* 밑줄은 폭이 아니라 transform 이라 레이아웃을 다시 계산하지 않는다 */
.lt__link::after {
content: "";
position: absolute; left: 0; bottom: 0;
width: 100%; height: 3px;
border-radius: 999px;
background: #1b1a20;
transform: scaleX(0);
transform-origin: left center;
transition: transform 220ms ease-out;
}
.lt__link:hover::after,
.lt__link:focus-visible::after { transform: scaleX(1); }
.lt__link[aria-current="page"] { font-weight: 900; }
07연락처·지도 카드 푸터
무엇이 보이나
왼쪽 지도에 핀이 떨어져 한 번 튀고 동그란 물결이 퍼지는 동안, 오른쪽의 주소·전화·메일 카드가 220ms 간격으로 차례로 드러납니다. 지도의 도로 무늬는 이미지 파일이 아니라 repeating-linear-gradient 세 겹이라 외부 요청이 0건입니다.

<div class="stage">
<!-- footer 가 body 계통(article·section·main 안이 아님)에 있으므로 암묵적 role 은 contentinfo 다 -->
<footer class="fc">
<!-- 지도는 장식이지만 누르면 핀이 다시 떨어지므로 button. Enter·Space 는 button 이 스스로 처리한다 -->
<button class="fc__map" type="button" aria-label="핀 다시 떨어뜨리기">
<span class="fc__ripple" aria-hidden="true"></span>
<span class="fc__pin" aria-hidden="true">
<svg viewBox="0 0 256 256" width="100%" height="100%" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
</span>
</button>
<div class="fc__side">
<h2 class="fc__title">오시는 길</h2>
<address class="fc__cards">
<a class="fc__card" href="#">
<span class="fc__ic" aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
</span>
<span class="fc__txt"><span class="fc__k">주소</span><span class="fc__v">도심 큰길 12, 4층</span></span>
</a>
<a class="fc__card" href="tel:00000000000">
<span class="fc__ic" aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M223.94 174.08A48.33 48.33 0 0 1 176 216A136 136 0 0 1 40 80a48.33 48.33 0 0 1 41.92-47.94a8 8 0 0 1 8.3 4.8l21.13 47.2a8 8 0 0 1-.66 7.53L89.32 117a7.93 7.93 0 0 0-.54 7.81c8.27 16.93 25.77 34.22 42.75 42.41a7.92 7.92 0 0 0 7.83-.59l25-21.3a8 8 0 0 1 7.59-.69l47.16 21.13a8 8 0 0 1 4.83 8.31" opacity=".2"/><path d="m222.37 158.46l-47.11-21.11l-.13-.06a16 16 0 0 0-15.17 1.4a8 8 0 0 0-.75.56L134.87 160c-15.42-7.49-31.34-23.29-38.83-38.51l20.78-24.71c.2-.25.39-.5.57-.77a16 16 0 0 0 1.32-15.06v-.12L97.54 33.64a16 16 0 0 0-16.62-9.52A56.26 56.26 0 0 0 32 80c0 79.4 64.6 144 144 144a56.26 56.26 0 0 0 55.88-48.92a16 16 0 0 0-9.51-16.62M176 208A128.14 128.14 0 0 1 48 80a40.2 40.2 0 0 1 34.87-40a.6.6 0 0 0 0 .12l21 47l-20.67 24.74a6 6 0 0 0-.57.77a16 16 0 0 0-1 15.7c9.06 18.53 27.73 37.06 46.46 46.11a16 16 0 0 0 15.75-1.14a8 8 0 0 0 .74-.56L168.89 152l47 21.05h.11A40.21 40.21 0 0 1 176 208"/></g></svg>
</span>
<span class="fc__txt"><span class="fc__k">전화</span><span class="fc__v">000-0000-0000</span></span>
</a>
<a class="fc__card" href="mailto:[email protected]">
<span class="fc__ic" aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="m224 56l-96 88l-96-88Z" opacity=".2"/><path d="M224 48H32a8 8 0 0 0-8 8v136a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a8 8 0 0 0-8-8m-20.57 16L128 133.15L52.57 64ZM216 192H40V74.19l82.59 75.71a8 8 0 0 0 10.82 0L216 74.19z"/></g></svg>
</span>
<span class="fc__txt"><span class="fc__k">메일</span><span class="fc__v">[email protected]</span></span>
</a>
</address>
</div>
</footer>
</div>@use "../../../../site/static/demo-tokens" as *;
// 272 푸터 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실제 대비비(WCAG 2.1 1.4.3)
$ft-ink: #1b1a20; // 다크 마감 바닥
$ft-ink2: #2c2a33; // 잉크 위 카드·구분
$ft-paper: $stage-paper;
$ft-cream: #fbf6ee; // 밝은 바탕
$ft-stone: #575162; // 밝은 면 위 보조 글자 (크림 위 7.08:1)
$ft-mist: #b9b3c4; // 잉크 위 보조 글자 (8.48:1)
$ft-amber: $stage-yellow;
$ft-flame: $stage-orange; // 잉크 글자를 얹어야 하는 색 (5.21:1). 흰 글자는 3.32:1 이라 못 쓴다
$ft-teal: #479089; // 흰 면 위 5.79:1
$ft-aqua: #b3dad5;
$duration: $dur-loop; // 핀이 떨어져 튀고 카드 셋이 차례로 서는 한 주기
$easing: $ease-pop; // 핀 착지의 튐
$color: $ft-flame; // 핀·물결 색
@include stage($ft-flame);
.fc {
--drop: -26px; // 핀이 떨어지기 시작하는 높이 (지도 안쪽이라 넘침이 안 생긴다)
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-3;
width: clamp(288px, 94vw, 432px);
padding: $sp-3;
border-radius: $r-card;
background: $ft-paper;
box-shadow: $shadow-raised;
}
// 지도 — 도로는 그림이 아니라 repeating-linear-gradient 세 겹이다.
// 타일을 background-size 로 먼저 못 박고(72·36px) 그 안에 띠 폭을 넣는다.
// 정지점을 띠 폭(2px)만으로 주면 타일이 아니라 요소 전체가 반복 단위가 돼 화면이 도배된다 (269 실측)
.fc__map {
position: relative;
flex: 0 0 132px;
height: 132px;
padding: 0;
border: 0;
border-radius: $r-control;
overflow: hidden;
cursor: pointer;
background-color: $ft-cream;
background-image:
repeating-linear-gradient(0deg, rgba($ft-stone, .20) 0 6px, transparent 6px 72px),
repeating-linear-gradient(90deg, rgba($ft-stone, .16) 0 2px, transparent 2px 36px),
repeating-linear-gradient(0deg, rgba($ft-stone, .16) 0 2px, transparent 2px 36px);
background-size: 72px 72px, 36px 36px, 36px 36px;
}
.fc__map:focus-visible { outline: 3px solid $ft-teal; outline-offset: 2px; }
// 핀 — 기본 상태에서도 자기 자리에 서 있다. 그 위에 .is-demo 연출을 덧씌운다
.fc__pin {
position: absolute;
left: calc(50% - 15px);
top: 30px;
display: block;
width: 30px;
height: 30px;
color: $color;
}
.fc__pin svg { display: block; width: 100%; height: 100%; }
// 핀 끝에서 퍼지는 동그란 물결. 정지 상태에서는 자리 표시용 얇은 고리
.fc__ripple {
position: absolute;
left: calc(50% - 18px);
top: 42px;
display: block;
width: 36px;
height: 36px;
border: 2px solid $color;
border-radius: 50%;
opacity: .35;
}
.fc__side { flex: 1 1 auto; min-width: 0; }
.fc__title {
margin: 0 0 $sp-2;
color: $ft-ink;
font-weight: 800; font-size: clamp(13px, 3vw, 15px); line-height: 1.2;
font-family: inherit;
}
.fc__cards { display: flex; flex-direction: column; gap: $sp-2; font-style: normal; }
.fc__card {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2;
border-radius: $r-sm;
background: $ft-cream;
text-decoration: none;
}
.fc__card:focus-visible { outline: 3px solid $ft-teal; outline-offset: 2px; }
.fc__ic { display: flex; flex: 0 0 auto; color: $ft-teal; } // 크림 위 5.79:1
.fc__txt { display: flex; flex-direction: column; min-width: 0; }
.fc__k {
color: $ft-stone; // 크림 위 7.08:1
font-weight: 600; font-size: clamp(9px, 2.2vw, 10px); line-height: 1.2;
}
.fc__v {
color: $ft-ink; // 크림 위 16.06:1
font-weight: 700; font-size: clamp(11px, 2.6vw, 12px); line-height: 1.25;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
// 자동 재생 — 핀이 떨어져 튀고, 물결이 퍼지고, 카드 셋이 시차로 선다
.fc.is-demo .fc__pin,
.fc__map.is-drop .fc__pin {
animation-name: pinDrop;
animation-timing-function: $easing;
animation-fill-mode: backwards;
}
.fc.is-demo .fc__ripple,
.fc__map.is-drop .fc__ripple {
animation-name: pinRipple;
animation-timing-function: $ease-out;
animation-fill-mode: backwards;
}
.fc.is-demo .fc__pin, .fc.is-demo .fc__ripple {
animation-duration: $duration;
animation-iteration-count: infinite;
}
// 누름 한 번에 한 번만 — 지도 버튼을 눌렀을 때의 재생
.fc__map.is-drop .fc__pin, .fc__map.is-drop .fc__ripple {
animation-duration: 900ms;
animation-iteration-count: 1;
}
// 카드 시차 — animation: 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로 쓰고,
// 지연 구간에 기본(완성) 상태가 번쩍이지 않게 fill-mode: backwards 를 반드시 같이 준다
.fc.is-demo .fc__card {
animation-name: cardIn;
animation-duration: $duration;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fc.is-demo .fc__card:nth-child(2) { animation-delay: 220ms; }
.fc.is-demo .fc__card:nth-child(3) { animation-delay: 440ms; }
@keyframes pinDrop {
0% { transform: translateY(var(--drop)); opacity: 0; }
6% { transform: translateY(var(--drop)); opacity: 1; }
30% { transform: translateY(0); }
40% { transform: translateY(-9px); }
52%, 100% { transform: translateY(0); }
}
@keyframes pinRipple {
0%, 28% { transform: scale(.3); opacity: 0; }
34% { transform: scale(.7); opacity: .9; }
76%, 100% { transform: scale(2.4); opacity: 0; }
}
// 카드는 투명도가 아니라 clip-path 로 왼쪽부터 드러나고 왼쪽부터 걷힌다.
// ① opacity 로 띄우면 이징 곡선 위의 중간 프레임이 반투명 유령이 된다 (실측: 250ms 프레임에서 두 번째 카드 opacity .83)
// ② 한 번에 나타나고 한 번에 사라지면 그 한 장이 "가장 크게 변한 장" 이라 poster 로 뽑힌다 —
// 실제로 첫 렌더의 poster 는 메일 카드 자리가 빈 그림이었다. 조각조각 드러나면 프레임당 변화가 고르게 나뉜다
@keyframes cardIn {
0%, 6% { transform: translateY(10px); clip-path: inset(0 100% 0 0); }
26% { transform: translateY(0); clip-path: inset(0 0 0 0); }
86% { transform: translateY(0); clip-path: inset(0 0 0 0); }
99%, 100% { transform: translateY(0); clip-path: inset(0 0 0 100%); }
}
@media (max-width: 340px), (max-height: 220px) {
.fc { --drop: -14px; width: 276px; gap: $sp-2; padding: $sp-2; }
.fc__map { flex: 0 0 84px; height: 84px; }
.fc__pin { left: calc(50% - 10px); top: 18px; width: 20px; height: 20px; }
.fc__ripple { left: calc(50% - 12px); top: 26px; width: 24px; height: 24px; }
.fc__title { margin: 0 0 $sp-1; font-size: 12px; }
.fc__cards { gap: $sp-1; }
.fc__card { gap: $sp-1; padding: $sp-1 $sp-2; }
.fc__ic svg { width: 14px; height: 14px; }
.fc__k { font-size: 9px; }
.fc__v { font-size: 11px; }
}
// .fc.is-demo .fc__pin 은 (0,3,0) 이라 @media 안의 낮은 특이도로는 못 이긴다 — !important 로 끈다
@media (prefers-reduced-motion: reduce) {
.fc__pin, .fc__ripple, .fc__card {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
.fc__ripple { opacity: .35 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fc {
--drop: -26px;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 12px;
width: clamp(288px, 94vw, 432px);
padding: 12px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.fc__map {
position: relative;
flex: 0 0 132px;
height: 132px;
padding: 0;
border: 0;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
background-color: #fbf6ee;
background-image: repeating-linear-gradient(0deg, rgba(87, 81, 98, 0.2) 0 6px, transparent 6px 72px), repeating-linear-gradient(90deg, rgba(87, 81, 98, 0.16) 0 2px, transparent 2px 36px), repeating-linear-gradient(0deg, rgba(87, 81, 98, 0.16) 0 2px, transparent 2px 36px);
background-size: 72px 72px, 36px 36px, 36px 36px;
}
.fc__map:focus-visible {
outline: 3px solid #479089;
outline-offset: 2px;
}
.fc__pin {
position: absolute;
left: calc(50% - 15px);
top: 30px;
display: block;
width: 30px;
height: 30px;
color: #ff4d1f;
}
.fc__pin svg {
display: block;
width: 100%;
height: 100%;
}
.fc__ripple {
position: absolute;
left: calc(50% - 18px);
top: 42px;
display: block;
width: 36px;
height: 36px;
border: 2px solid #ff4d1f;
border-radius: 50%;
opacity: 0.35;
}
.fc__side {
flex: 1 1 auto;
min-width: 0;
}
.fc__title {
margin: 0 0 8px;
color: #1b1a20;
font-weight: 800;
font-size: clamp(13px, 3vw, 15px);
line-height: 1.2;
font-family: inherit;
}
.fc__cards {
display: flex;
flex-direction: column;
gap: 8px;
font-style: normal;
}
.fc__card {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 8px;
background: #fbf6ee;
text-decoration: none;
}
.fc__card:focus-visible {
outline: 3px solid #479089;
outline-offset: 2px;
}
.fc__ic {
display: flex;
flex: 0 0 auto;
color: #479089;
}
.fc__txt {
display: flex;
flex-direction: column;
min-width: 0;
}
.fc__k {
color: #575162;
font-weight: 600;
font-size: clamp(9px, 2.2vw, 10px);
line-height: 1.2;
}
.fc__v {
color: #1b1a20;
font-weight: 700;
font-size: clamp(11px, 2.6vw, 12px);
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fc.is-demo .fc__pin,
.fc__map.is-drop .fc__pin {
animation-name: pinDrop;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-fill-mode: backwards;
}
.fc.is-demo .fc__ripple,
.fc__map.is-drop .fc__ripple {
animation-name: pinRipple;
animation-timing-function: ease-out;
animation-fill-mode: backwards;
}
.fc.is-demo .fc__pin, .fc.is-demo .fc__ripple {
animation-duration: 2s;
animation-iteration-count: infinite;
}
.fc__map.is-drop .fc__pin, .fc__map.is-drop .fc__ripple {
animation-duration: 900ms;
animation-iteration-count: 1;
}
.fc.is-demo .fc__card {
animation-name: cardIn;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fc.is-demo .fc__card:nth-child(2) {
animation-delay: 220ms;
}
.fc.is-demo .fc__card:nth-child(3) {
animation-delay: 440ms;
}
@keyframes pinDrop {
0% {
transform: translateY(var(--drop));
opacity: 0;
}
6% {
transform: translateY(var(--drop));
opacity: 1;
}
30% {
transform: translateY(0);
}
40% {
transform: translateY(-9px);
}
52%, 100% {
transform: translateY(0);
}
}
@keyframes pinRipple {
0%, 28% {
transform: scale(0.3);
opacity: 0;
}
34% {
transform: scale(0.7);
opacity: 0.9;
}
76%, 100% {
transform: scale(2.4);
opacity: 0;
}
}
@keyframes cardIn {
0%, 6% {
transform: translateY(10px);
clip-path: inset(0 100% 0 0);
}
26% {
transform: translateY(0);
clip-path: inset(0 0 0 0);
}
86% {
transform: translateY(0);
clip-path: inset(0 0 0 0);
}
99%, 100% {
transform: translateY(0);
clip-path: inset(0 0 0 100%);
}
}
@media (max-width: 340px), (max-height: 220px) {
.fc {
--drop: -14px;
width: 276px;
gap: 8px;
padding: 8px;
}
.fc__map {
flex: 0 0 84px;
height: 84px;
}
.fc__pin {
left: calc(50% - 10px);
top: 18px;
width: 20px;
height: 20px;
}
.fc__ripple {
left: calc(50% - 12px);
top: 26px;
width: 24px;
height: 24px;
}
.fc__title {
margin: 0 0 4px;
font-size: 12px;
}
.fc__cards {
gap: 4px;
}
.fc__card {
gap: 4px;
padding: 4px 8px;
}
.fc__ic svg {
width: 14px;
height: 14px;
}
.fc__k {
font-size: 9px;
}
.fc__v {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.fc__pin, .fc__ripple, .fc__card {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
.fc__ripple {
opacity: 0.35 !important;
}
}(function () {
var fc = document.querySelector('.fc');
var map = document.querySelector('.fc__map');
var pin = map.querySelector('.fc__pin');
// 격자 미리보기용 자동 재생. 사람이 손을 대면 떼고 진짜 조작으로 넘긴다
fc.classList.add('is-demo');
function stopDemo() { fc.classList.remove('is-demo'); }
['pointerdown', 'keydown'].forEach(function (t) {
document.addEventListener(t, stopDemo, { once: true });
});
// 지도를 누르면 핀이 한 번 더 떨어진다 (마우스·터치·Enter·Space 모두 click 으로 온다)
map.addEventListener('click', function () {
map.classList.remove('is-drop');
void map.offsetWidth; // 리플로를 강제해야 같은 애니메이션이 다시 시작한다
map.classList.add('is-drop');
});
pin.addEventListener('animationend', function () {
map.classList.remove('is-drop'); // 끝나면 표시를 떼서 다음 누름도 처음부터 돈다
});
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./ContactCardFooter.module.scss";
export type ContactCardFooterProps = {
/** 한 주기(ms) — 핀이 떨어져 튀고 카드 셋이 서는 시간 */
duration?: number;
/** 핀 착지의 이징 */
easing?: string;
/** 핀·물결 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/* Phosphor Duotone (MIT) — 런타임에 불러오지 않고 그대로 박아 둔다 */
function PinIcon({ size }: { size: number | string }): ReactNode {
return (
<svg viewBox="0 0 256 256" width={size} height={size} aria-hidden="true">
<g fill="currentColor">
<path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2" />
<path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118" />
</g>
</svg>
);
}
function PhoneIcon(): ReactNode {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M223.94 174.08A48.33 48.33 0 0 1 176 216A136 136 0 0 1 40 80a48.33 48.33 0 0 1 41.92-47.94a8 8 0 0 1 8.3 4.8l21.13 47.2a8 8 0 0 1-.66 7.53L89.32 117a7.93 7.93 0 0 0-.54 7.81c8.27 16.93 25.77 34.22 42.75 42.41a7.92 7.92 0 0 0 7.83-.59l25-21.3a8 8 0 0 1 7.59-.69l47.16 21.13a8 8 0 0 1 4.83 8.31" opacity=".2" />
<path d="m222.37 158.46l-47.11-21.11l-.13-.06a16 16 0 0 0-15.17 1.4a8 8 0 0 0-.75.56L134.87 160c-15.42-7.49-31.34-23.29-38.83-38.51l20.78-24.71c.2-.25.39-.5.57-.77a16 16 0 0 0 1.32-15.06v-.12L97.54 33.64a16 16 0 0 0-16.62-9.52A56.26 56.26 0 0 0 32 80c0 79.4 64.6 144 144 144a56.26 56.26 0 0 0 55.88-48.92a16 16 0 0 0-9.51-16.62M176 208A128.14 128.14 0 0 1 48 80a40.2 40.2 0 0 1 34.87-40a.6.6 0 0 0 0 .12l21 47l-20.67 24.74a6 6 0 0 0-.57.77a16 16 0 0 0-1 15.7c9.06 18.53 27.73 37.06 46.46 46.11a16 16 0 0 0 15.75-1.14a8 8 0 0 0 .74-.56L168.89 152l47 21.05h.11A40.21 40.21 0 0 1 176 208" />
</g>
</svg>
);
}
function MailIcon(): ReactNode {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="m224 56l-96 88l-96-88Z" opacity=".2" />
<path d="M224 48H32a8 8 0 0 0-8 8v136a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a8 8 0 0 0-8-8m-20.57 16L128 133.15L52.57 64ZM216 192H40V74.19l82.59 75.71a8 8 0 0 0 10.82 0L216 74.19z" />
</g>
</svg>
);
}
export default function ContactCardFooter({
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ff4d1f",
}: ContactCardFooterProps): ReactNode {
const [demo, setDemo] = useState(true);
const [drop, setDrop] = useState(false);
const pin = useRef<HTMLSpanElement>(null);
// 격자 미리보기용 자동 재생. 사람이 손을 대면 떼고 진짜 조작으로 넘긴다
useEffect(() => {
const off = (): void => setDemo(false);
document.addEventListener("pointerdown", off, { once: true });
document.addEventListener("keydown", off, { once: true });
return () => {
document.removeEventListener("pointerdown", off);
document.removeEventListener("keydown", off);
};
}, []);
// 지도를 누르면 핀이 한 번 더 떨어진다 (마우스·터치·Enter·Space 모두 click 으로 온다).
// 한 프레임 껐다 켜야 같은 애니메이션이 처음부터 다시 돈다
const dropAgain = useCallback((): void => {
setDrop(false);
window.requestAnimationFrame(() => setDrop(true));
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const mapClass = `${styles.map} ${drop ? styles.isDrop : ""}`;
return (
<footer className={`${styles.fc} ${demo ? styles.isDemo : ""}`} style={vars}>
<button className={mapClass} type="button" aria-label="핀 다시 떨어뜨리기" onClick={dropAgain}>
<span className={styles.ripple} aria-hidden="true" />
<span className={styles.pin} ref={pin} aria-hidden="true" onAnimationEnd={() => setDrop(false)}>
<PinIcon size="100%" />
</span>
</button>
<div className={styles.side}>
<h2 className={styles.title}>오시는 길</h2>
<address className={styles.cards}>
<a className={styles.card} href="#">
<span className={styles.ic} aria-hidden="true"><PinIcon size={18} /></span>
<span className={styles.txt}>
<span className={styles.k}>주소</span>
<span className={styles.v}>도심 큰길 12, 4층</span>
</span>
</a>
<a className={styles.card} href="tel:00000000000">
<span className={styles.ic} aria-hidden="true"><PhoneIcon /></span>
<span className={styles.txt}>
<span className={styles.k}>전화</span>
<span className={styles.v}>000-0000-0000</span>
</span>
</a>
<a className={styles.card} href="mailto:[email protected]">
<span className={styles.ic} aria-hidden="true"><MailIcon /></span>
<span className={styles.txt}>
<span className={styles.k}>메일</span>
<span className={styles.v}>[email protected]</span>
</span>
</a>
</address>
</div>
</footer>
);
}07 · 연락처·지도 카드 푸터 — 측정 누적면적 29.003% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
매장이나 사무실 위치를 알리는 푸터에 씁니다.
/* 도로는 그림이 아니라 그러데이션 세 겹. 타일 크기를 먼저 못 박는다 */
.fc__map {
background-color: #fbf6ee;
background-image:
repeating-linear-gradient(0deg, rgba(87, 81, 98, .20) 0 6px, transparent 6px 72px),
repeating-linear-gradient(90deg, rgba(87, 81, 98, .16) 0 2px, transparent 2px 36px),
repeating-linear-gradient(0deg, rgba(87, 81, 98, .16) 0 2px, transparent 2px 36px);
background-size: 72px 72px, 36px 36px, 36px 36px;
}
/* 카드는 투명도가 아니라 clip-path 로 왼쪽부터 드러나고 왼쪽부터 걷힌다 */
@keyframes cardIn {
0%, 6% { transform: translateY(10px); clip-path: inset(0 100% 0 0); }
26% { transform: translateY(0); clip-path: inset(0 0 0 0); }
86% { transform: translateY(0); clip-path: inset(0 0 0 0); }
99%, 100% { transform: translateY(0); clip-path: inset(0 0 0 100%); }
}
08웨이브 경계 푸터
화면에서 일어나는 일
밝은 본문과 어두운 푸터가 만나는 28px 띠 안에서 물결 두 겹이 서로 다른 속도로 흐릅니다. 물결 모양은 SVG 를 data URI 로 깐 배경이라 파일을 따로 올리지 않아도 되고, 데모 안의 스위치를 누르면 animation-play-state 로 흐름만 멈춥니다.

<div class="stage">
<!-- footer 가 body 계통에 있으므로 암묵적 role 은 contentinfo 다 -->
<footer class="wv">
<div class="wv__body">
<p class="wv__lead">본문은 여기서 끝납니다</p>
</div>
<!-- 경계 — 물결 두 겹. 모양은 SVG path 를 data URI 로 깐 배경이고, 타일 폭은 120px 이다 -->
<div class="wv__edge" aria-hidden="true">
<span class="wv__wave wv__wave--back"></span>
<span class="wv__wave wv__wave--front"></span>
</div>
<div class="wv__foot">
<div class="wv__row">
<nav class="wv__nav" aria-label="푸터 링크">
<a class="wv__link" href="#">소개</a>
<a class="wv__link" href="#">글 목록</a>
<a class="wv__link" href="#">문의</a>
</nav>
<!-- 움직임을 끄는 스위치. 라벨 두 개를 다 심어 두고 aria-pressed 로 갈라 보인다 -->
<button class="wv__toggle" type="button" aria-pressed="false">
<span class="wv__t wv__t--stop">물결 멈춤</span>
<span class="wv__t wv__t--play">물결 재생</span>
</button>
</div>
<p class="wv__copy">© 2026 예시 사이트</p>
</div>
</footer>
</div>@use "../../../../site/static/demo-tokens" as *;
// 272 푸터 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실제 대비비(WCAG 2.1 1.4.3)
$ft-ink: #1b1a20; // 어두운 푸터 바닥
$ft-ink2: #2c2a33;
$ft-paper: $stage-paper; // 잉크 위 17.28:1
$ft-cream: #fbf6ee; // 밝은 본문
$ft-stone: #575162; // 크림 위 7.08:1
$ft-mist: #b9b3c4; // 잉크 위 8.48:1 — 뒷물결의 옅은 색이기도 하다
$ft-amber: $stage-yellow;
$ft-flame: $stage-orange;
$ft-teal: #479089;
$ft-aqua: #b3dad5; // 잉크 위 11.58:1
$duration: $dur-loop; // 앞물결이 한 벌(480px = 타일 4장)을 지나는 시간
$easing: linear; // 흐르는 물결은 등속이어야 이음매가 안 보인다
$color: $ft-ink; // 앞물결 = 푸터 바닥과 같은 색이라 경계가 이어진다
// 물결 모양은 SVG path 를 data URI 로 깐 배경이다 (외부 요청 0).
// 타일은 120×28, 양 끝의 접선 기울기가 같아 옆으로 붙여도 이음매가 없다.
// 색은 URL 안에 들어가므로 # 을 %23 으로 인코딩한 문자열로 둔다
$w-front: "%231b1a20"; // = $ft-ink
$w-back: "%23b9b3c4"; // = $ft-mist
@include stage($ft-ink);
.wv {
box-sizing: border-box;
width: clamp(288px, 94vw, 432px);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
// 위 — 밝은 본문
.wv__body {
padding: $sp-4;
background: $ft-cream;
text-align: center;
}
.wv__lead {
margin: 0;
color: $ft-stone;
font-weight: 600; font-size: clamp(11px, 2.6vw, 13px); line-height: 1.3;
font-family: inherit;
}
// 경계 — 두 겹이 서로 다른 속도로 흐른다. 상자는 크림이고 물결이 잉크를 끌어올린다
.wv__edge {
position: relative;
height: 28px;
overflow: hidden;
background: $ft-cream;
}
.wv__wave {
position: absolute;
top: 0;
left: 0;
display: block;
width: 960px; // 한 벌 480px(타일 4장) × 2 — 정확히 두 벌이라 반 바퀴가 한 벌이다
height: 28px;
background-repeat: repeat-x;
background-size: 120px 28px; // 타일을 먼저 못 박는다
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-duration: $duration;
}
// 뒷겹 — 옅고 느리다. 한 주기에 타일 2장(240px)만 간다
.wv__wave--back {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='28' viewBox='0 0 120 28' preserveAspectRatio='none'%3E%3Cpath d='M0 12C20 2 40 2 60 12S100 22 120 12V28H0Z' fill='#{$w-back}'/%3E%3C/svg%3E");
animation-name: waveBack;
}
// 앞겹 — 진하고 빠르다. 한 주기에 타일 4장(480px). 둘 다 타일 폭의 정수배라 텔레포트가 없다
.wv__wave--front {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='28' viewBox='0 0 120 28' preserveAspectRatio='none'%3E%3Cpath d='M0 14C15 0 45 0 60 14S105 28 120 14V28H0Z' fill='#{$w-front}'/%3E%3C/svg%3E");
animation-name: waveFront;
}
// 아래 — 어두운 푸터
.wv__foot {
padding: $sp-3 $sp-4 $sp-4;
background: $color;
}
.wv__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
flex-wrap: wrap;
}
.wv__nav { display: flex; gap: $sp-3; flex-wrap: wrap; }
.wv__link {
color: $ft-paper; // 잉크 위 17.28:1
text-decoration: none;
font-weight: 600; font-size: clamp(11px, 2.6vw, 12px); line-height: 1.4;
}
.wv__link:hover { color: $ft-aqua; } // 잉크 위 11.58:1
.wv__link:focus-visible { outline: 2px solid $ft-aqua; outline-offset: 3px; border-radius: $r-xs; }
.wv__toggle {
flex: 0 0 auto;
padding: $sp-1 $sp-2;
border: 1px solid $ft-mist;
border-radius: $r-pill;
background: $ft-ink2;
color: $ft-mist; // 잉크2 위 8.1:1
cursor: pointer;
font-weight: 600; font-size: 11px; line-height: 1.3;
font-family: inherit;
}
.wv__toggle:focus-visible { outline: 2px solid $ft-aqua; outline-offset: 2px; }
.wv__toggle[aria-pressed="false"] .wv__t--play { display: none; }
.wv__toggle[aria-pressed="true"] .wv__t--stop { display: none; }
.wv__copy {
margin: $sp-2 0 0;
color: $ft-mist; // 잉크 위 8.48:1
font-weight: 500; font-size: 10px; line-height: 1.4;
font-family: inherit;
}
// 이동량은 타일 폭(120px)의 정수배 — 한 바퀴 뒤 무늬가 픽셀까지 같아 이음매가 없다
@keyframes waveBack { from { transform: translateX(0); } to { transform: translateX(-240px); } }
@keyframes waveFront { from { transform: translateX(0); } to { transform: translateX(-480px); } }
// 스위치로 멈춘 상태 — 움직임만 멈추고 모양은 그대로다
.wv.is-paused .wv__wave { animation-play-state: paused; }
@media (max-width: 340px), (max-height: 220px) {
.wv { width: 276px; }
.wv__body { padding: $sp-2; }
.wv__edge { height: 20px; }
.wv__wave { height: 20px; background-size: 120px 20px; }
.wv__foot { padding: $sp-2; }
.wv__nav { gap: $sp-2; }
.wv__link { font-size: 11px; }
.wv__toggle { font-size: 10px; }
.wv__copy { margin: $sp-1 0 0; font-size: 9px; }
}
// .wv.is-paused .wv__wave 처럼 특이도가 올라간 규칙이 있으므로 !important 로 끈다
@media (prefers-reduced-motion: reduce) {
.wv__wave { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1b1a20;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wv {
box-sizing: border-box;
width: clamp(288px, 94vw, 432px);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.wv__body {
padding: 16px;
background: #fbf6ee;
text-align: center;
}
.wv__lead {
margin: 0;
color: #575162;
font-weight: 600;
font-size: clamp(11px, 2.6vw, 13px);
line-height: 1.3;
font-family: inherit;
}
.wv__edge {
position: relative;
height: 28px;
overflow: hidden;
background: #fbf6ee;
}
.wv__wave {
position: absolute;
top: 0;
left: 0;
display: block;
width: 960px;
height: 28px;
background-repeat: repeat-x;
background-size: 120px 28px;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-duration: 2s;
}
.wv__wave--back {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='28' viewBox='0 0 120 28' preserveAspectRatio='none'%3E%3Cpath d='M0 12C20 2 40 2 60 12S100 22 120 12V28H0Z' fill='%23b9b3c4'/%3E%3C/svg%3E");
animation-name: waveBack;
}
.wv__wave--front {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='28' viewBox='0 0 120 28' preserveAspectRatio='none'%3E%3Cpath d='M0 14C15 0 45 0 60 14S105 28 120 14V28H0Z' fill='%231b1a20'/%3E%3C/svg%3E");
animation-name: waveFront;
}
.wv__foot {
padding: 12px 16px 16px;
background: #1b1a20;
}
.wv__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.wv__nav {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.wv__link {
color: #ffffff;
text-decoration: none;
font-weight: 600;
font-size: clamp(11px, 2.6vw, 12px);
line-height: 1.4;
}
.wv__link:hover {
color: #b3dad5;
}
.wv__link:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 3px;
border-radius: 4px;
}
.wv__toggle {
flex: 0 0 auto;
padding: 4px 8px;
border: 1px solid #b9b3c4;
border-radius: 999px;
background: #2c2a33;
color: #b9b3c4;
cursor: pointer;
font-weight: 600;
font-size: 11px;
line-height: 1.3;
font-family: inherit;
}
.wv__toggle:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 2px;
}
.wv__toggle[aria-pressed=false] .wv__t--play {
display: none;
}
.wv__toggle[aria-pressed=true] .wv__t--stop {
display: none;
}
.wv__copy {
margin: 8px 0 0;
color: #b9b3c4;
font-weight: 500;
font-size: 10px;
line-height: 1.4;
font-family: inherit;
}
@keyframes waveBack {
from {
transform: translateX(0);
}
to {
transform: translateX(-240px);
}
}
@keyframes waveFront {
from {
transform: translateX(0);
}
to {
transform: translateX(-480px);
}
}
.wv.is-paused .wv__wave {
animation-play-state: paused;
}
@media (max-width: 340px), (max-height: 220px) {
.wv {
width: 276px;
}
.wv__body {
padding: 8px;
}
.wv__edge {
height: 20px;
}
.wv__wave {
height: 20px;
background-size: 120px 20px;
}
.wv__foot {
padding: 8px;
}
.wv__nav {
gap: 8px;
}
.wv__link {
font-size: 11px;
}
.wv__toggle {
font-size: 10px;
}
.wv__copy {
margin: 4px 0 0;
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.wv__wave {
animation: none !important;
}
}(function () {
var wv = document.querySelector('.wv');
var btn = document.querySelector('.wv__toggle');
// button 이라 클릭·터치·Enter·Space 가 모두 click 으로 온다
btn.addEventListener('click', function () {
var isPaused = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', isPaused ? 'false' : 'true');
wv.classList.toggle('is-paused', !isPaused);
});
})();import { useCallback, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./WaveTopFooter.module.scss";
export type WaveTopFooterProps = {
/** 앞물결이 한 벌(480px = 타일 4장)을 지나는 시간(ms) */
duration?: number;
/** 흐르는 물결은 등속이어야 이음매가 안 보인다 */
easing?: string;
/** 푸터 바닥 색 — 앞물결과 같은 색이라 경계가 이어진다 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function WaveTopFooter({
duration = 2000,
easing = "linear",
color = "#1b1a20",
}: WaveTopFooterProps): ReactNode {
const [paused, setPaused] = useState(false);
// button 이라 클릭·터치·Enter·Space 가 모두 onClick 으로 온다
const toggleWave = useCallback((): void => setPaused((p) => !p), []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<footer className={`${styles.wv} ${paused ? styles.isPaused : ""}`} style={vars}>
<div className={styles.body}>
<p className={styles.lead}>본문은 여기서 끝납니다</p>
</div>
{/* 경계 — 물결 두 겹. 모양은 SVG path 를 data URI 로 깐 배경이고, 타일 폭은 120px 이다 */}
<div className={styles.edge} aria-hidden="true">
<span className={`${styles.wave} ${styles.waveBack}`} />
<span className={`${styles.wave} ${styles.waveFront}`} />
</div>
<div className={styles.foot}>
<div className={styles.row}>
<nav className={styles.nav} aria-label="푸터 링크">
<a className={styles.link} href="#">소개</a>
<a className={styles.link} href="#">글 목록</a>
<a className={styles.link} href="#">문의</a>
</nav>
<button className={styles.toggle} type="button" aria-pressed={paused} onClick={toggleWave}>
{paused ? "물결 재생" : "물결 멈춤"}
</button>
</div>
<p className={styles.copy}>© 2026 예시 사이트</p>
</div>
</footer>
);
}08 · 웨이브 경계 푸터 — 측정 누적면적 6.332% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
섹션 색이 바뀌는 지점을 부드럽게 마감할 때 씁니다.
.wv__wave {
position: absolute; top: 0; left: 0;
width: 960px; /* 한 벌 480px(타일 4장) × 2 */
height: 28px;
background-repeat: repeat-x;
background-size: 120px 28px; /* 타일을 먼저 못 박는다 */
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-duration: 2s;
}
/* 이동량은 타일 폭(120px)의 정수배 — 한 바퀴 뒤 무늬가 픽셀까지 같다 */
@keyframes waveBack { from { transform: translateX(0); } to { transform: translateX(-240px); } }
@keyframes waveFront { from { transform: translateX(0); } to { transform: translateX(-480px); } }
.wv.is-paused .wv__wave { animation-play-state: paused; }
09다크 잉크 대비 푸터
어떤 움직임인가
밝은 본문 카드가 어두운 잉크 바닥 위 20px 에서 내려앉아 한 번 살짝 튀고, 그다음 푸터 링크 넷에 아쿠아 밑줄이 180ms 간격으로 차례로 그어집니다. 카드 바깥 바닥이 무대와 같은 잉크색이라 카드가 진짜로 바닥에 얹힌 것처럼 보입니다.

<div class="stage">
<!-- footer 가 body 계통에 있으므로 암묵적 role 은 contentinfo 다 -->
<footer class="ic">
<!-- 밝은 본문 카드가 잉크 바닥 위로 내려앉으며 페이지가 닫힌다 -->
<div class="ic__sheet">
<p class="ic__eyebrow">본문 끝</p>
<h2 class="ic__head" tabindex="-1">읽어 주셔서 고맙습니다</h2>
</div>
<div class="ic__floor">
<nav class="ic__nav" aria-label="푸터 링크">
<a class="ic__link" href="#"><span class="ic__lt">소개</span><span class="ic__rule" aria-hidden="true"></span></a>
<a class="ic__link" href="#"><span class="ic__lt">글 목록</span><span class="ic__rule" aria-hidden="true"></span></a>
<a class="ic__link" href="#"><span class="ic__lt">문의</span><span class="ic__rule" aria-hidden="true"></span></a>
<a class="ic__link" href="#"><span class="ic__lt">개인정보</span><span class="ic__rule" aria-hidden="true"></span></a>
</nav>
<div class="ic__bottom">
<p class="ic__copy">© 2026 예시 사이트</p>
<button class="ic__top" type="button">
<span class="ic__ic" aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M200 112H56l72-72Z" opacity=".2"/><path d="m205.66 106.34l-72-72a8 8 0 0 0-11.32 0l-72 72A8 8 0 0 0 56 120h64v96a8 8 0 0 0 16 0v-96h64a8 8 0 0 0 5.66-13.66M75.31 104L128 51.31L180.69 104Z"/></g></svg>
</span>
맨 위로
</button>
</div>
</div>
</footer>
</div>@use "../../../../site/static/demo-tokens" as *;
// 272 푸터 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실제 대비비(WCAG 2.1 1.4.3)
$ft-ink: #1b1a20; // 다크 마감 바닥 = 무대
$ft-ink2: #2c2a33; // 잉크 위 구분선·면
$ft-paper: $stage-paper; // 잉크 위 17.28:1
$ft-cream: #fbf6ee; // 밝은 본문 카드
$ft-stone: #575162; // 크림 위 7.08:1
$ft-mist: #b9b3c4; // 잉크 위 8.48:1
$ft-amber: $stage-yellow; // 잉크 위 11.97:1
$ft-flame: $stage-orange;
$ft-teal: #479089;
$ft-aqua: #b3dad5; // 잉크 위 11.58:1 — 밑줄 색
$duration: $dur-loop; // 카드가 내려앉고 밑줄 넷이 차례로 스캔되는 한 주기
$easing: $ease-spring;
$color: $ft-aqua; // 밑줄·초점 테두리 색
@include stage($ft-ink);
.ic {
--lift: -20px; // 본문 카드가 내려앉기 시작하는 높이
box-sizing: border-box;
width: clamp(288px, 94vw, 432px);
}
// 밝은 본문 — 잉크 위에서 유일하게 밝은 면이라 대비가 세다 (크림 위 잉크 글자 16.06:1)
.ic__sheet {
padding: $sp-4;
border-radius: $r-card;
background: $ft-cream;
text-align: center;
box-shadow: $shadow-raised;
}
.ic__eyebrow {
margin: 0 0 $sp-1;
color: $ft-stone; // 크림 위 7.08:1
font-weight: 700; font-size: 10px; line-height: 1.2; letter-spacing: .08em;
font-family: inherit;
}
.ic__head {
margin: 0;
color: $ft-ink; // 크림 위 16.06:1
font-weight: 800; font-size: clamp(14px, 3.2vw, 17px); line-height: 1.25;
font-family: inherit;
}
.ic__head:focus-visible { outline: 3px solid $ft-teal; outline-offset: 4px; }
// 잉크 바닥 — 무대와 같은 색이라 카드가 그대로 바닥에 내려앉아 보인다
.ic__floor {
padding: $sp-4 $sp-2 0;
}
.ic__nav {
display: flex;
flex-wrap: wrap;
gap: $sp-2 $sp-5;
}
.ic__link {
position: relative;
display: inline-block;
padding-bottom: $sp-1;
color: $ft-paper; // 잉크 위 17.28:1
text-decoration: none;
font-weight: 700; font-size: clamp(12px, 2.8vw, 13px); line-height: 1.3;
}
.ic__link:focus-visible { outline: 2px solid $color; outline-offset: 3px; border-radius: $r-xs; }
// 밑줄 — 기본은 접혀 있고 손을 얹거나 초점이 오면 왼쪽에서 자란다.
// .is-demo 는 이 규칙 위에 스캔 연출을 덧씌울 뿐이다
.ic__rule {
position: absolute;
left: 0;
bottom: 0;
display: block;
width: 100%;
height: 2px;
background: $color;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 밑줄 왼쪽 채움
transition: transform $dur-quick $easing;
}
.ic__link:hover .ic__rule,
.ic__link:focus-visible .ic__rule { transform: scaleX(1); }
.ic__bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
flex-wrap: wrap;
margin: $sp-3 0 0;
padding: $sp-2 0 0;
border-top: 1px solid $ft-ink2;
}
.ic__copy {
margin: 0;
color: $ft-mist; // 잉크 위 8.48:1
font-weight: 500; font-size: 10px; line-height: 1.4;
font-family: inherit;
}
.ic__top {
display: inline-flex;
align-items: center;
gap: $sp-1;
flex: 0 0 auto;
padding: $sp-1 $sp-2;
border: 1px solid $ft-ink2;
border-radius: $r-pill;
background: $ft-ink2;
color: $ft-mist;
cursor: pointer;
font-weight: 600; font-size: 10px; line-height: 1.3;
font-family: inherit;
}
.ic__top:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ic__ic { display: flex; }
// 자동 재생 — 카드가 내려앉고, 밑줄 넷이 시차로 스캔된다.
// animation: 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로 쓰고 fill-mode: backwards 를 함께 준다
.ic.is-demo .ic__sheet {
animation-name: sheetDrop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ic.is-demo .ic__rule {
animation-name: ruleScan;
animation-duration: $duration;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ic.is-demo .ic__link:nth-child(2) .ic__rule { animation-delay: 180ms; }
.ic.is-demo .ic__link:nth-child(3) .ic__rule { animation-delay: 360ms; }
.ic.is-demo .ic__link:nth-child(4) .ic__rule { animation-delay: 540ms; }
@keyframes sheetDrop {
0% { transform: translateY(var(--lift)); }
22% { transform: translateY(0); }
30% { transform: translateY(-3px); }
38%, 100% { transform: translateY(0); }
}
// 복귀는 90% 안에 끝낸다 — 24프레임의 마지막 장은 95.8% 지점이라 그 뒤는 샘플이 없다
@keyframes ruleScan {
0% { transform: scaleX(0); }
24%, 60% { transform: scaleX(1); }
84%, 100% { transform: scaleX(0); }
}
@media (max-width: 340px), (max-height: 220px) {
.ic { --lift: -14px; width: 276px; }
.ic__sheet { padding: $sp-2; }
.ic__head { font-size: 13px; }
.ic__floor { padding: $sp-2 0 0; }
.ic__nav { gap: $sp-1 $sp-3; }
.ic__link { font-size: 12px; }
.ic__bottom { margin: $sp-2 0 0; padding: $sp-1 0 0; }
.ic__copy { font-size: 9px; }
.ic__top { font-size: 9px; }
}
// .ic.is-demo .ic__rule 은 (0,3,0) 이라 @media 안의 낮은 특이도로는 못 이긴다 — !important 로 끈다
@media (prefers-reduced-motion: reduce) {
.ic__sheet { animation: none !important; transform: none !important; }
.ic__rule { animation: none !important; transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1b1a20;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ic {
--lift: -20px;
box-sizing: border-box;
width: clamp(288px, 94vw, 432px);
}
.ic__sheet {
padding: 16px;
border-radius: 16px;
background: #fbf6ee;
text-align: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ic__eyebrow {
margin: 0 0 4px;
color: #575162;
font-weight: 700;
font-size: 10px;
line-height: 1.2;
letter-spacing: 0.08em;
font-family: inherit;
}
.ic__head {
margin: 0;
color: #1b1a20;
font-weight: 800;
font-size: clamp(14px, 3.2vw, 17px);
line-height: 1.25;
font-family: inherit;
}
.ic__head:focus-visible {
outline: 3px solid #479089;
outline-offset: 4px;
}
.ic__floor {
padding: 16px 8px 0;
}
.ic__nav {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
}
.ic__link {
position: relative;
display: inline-block;
padding-bottom: 4px;
color: #ffffff;
text-decoration: none;
font-weight: 700;
font-size: clamp(12px, 2.8vw, 13px);
line-height: 1.3;
}
.ic__link:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 3px;
border-radius: 4px;
}
.ic__rule {
position: absolute;
left: 0;
bottom: 0;
display: block;
width: 100%;
height: 2px;
background: #b3dad5;
transform: scaleX(0);
transform-origin: left center;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ic__link:hover .ic__rule,
.ic__link:focus-visible .ic__rule {
transform: scaleX(1);
}
.ic__bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
margin: 12px 0 0;
padding: 8px 0 0;
border-top: 1px solid #2c2a33;
}
.ic__copy {
margin: 0;
color: #b9b3c4;
font-weight: 500;
font-size: 10px;
line-height: 1.4;
font-family: inherit;
}
.ic__top {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
padding: 4px 8px;
border: 1px solid #2c2a33;
border-radius: 999px;
background: #2c2a33;
color: #b9b3c4;
cursor: pointer;
font-weight: 600;
font-size: 10px;
line-height: 1.3;
font-family: inherit;
}
.ic__top:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 2px;
}
.ic__ic {
display: flex;
}
.ic.is-demo .ic__sheet {
animation-name: sheetDrop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ic.is-demo .ic__rule {
animation-name: ruleScan;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ic.is-demo .ic__link:nth-child(2) .ic__rule {
animation-delay: 180ms;
}
.ic.is-demo .ic__link:nth-child(3) .ic__rule {
animation-delay: 360ms;
}
.ic.is-demo .ic__link:nth-child(4) .ic__rule {
animation-delay: 540ms;
}
@keyframes sheetDrop {
0% {
transform: translateY(var(--lift));
}
22% {
transform: translateY(0);
}
30% {
transform: translateY(-3px);
}
38%, 100% {
transform: translateY(0);
}
}
@keyframes ruleScan {
0% {
transform: scaleX(0);
}
24%, 60% {
transform: scaleX(1);
}
84%, 100% {
transform: scaleX(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.ic {
--lift: -14px;
width: 276px;
}
.ic__sheet {
padding: 8px;
}
.ic__head {
font-size: 13px;
}
.ic__floor {
padding: 8px 0 0;
}
.ic__nav {
gap: 4px 12px;
}
.ic__link {
font-size: 12px;
}
.ic__bottom {
margin: 8px 0 0;
padding: 4px 0 0;
}
.ic__copy {
font-size: 9px;
}
.ic__top {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.ic__sheet {
animation: none !important;
transform: none !important;
}
.ic__rule {
animation: none !important;
transition: none !important;
}
}(function () {
var ic = document.querySelector('.ic');
var headEl = document.querySelector('.ic__head');
var topBtn = document.querySelector('.ic__top');
// 격자 미리보기용 자동 재생. 사람이 손을 대면 떼고 진짜 조작으로 넘긴다
ic.classList.add('is-demo');
function stopDemo() { ic.classList.remove('is-demo'); }
['pointerdown', 'keydown'].forEach(function (t) {
document.addEventListener(t, stopDemo, { once: true });
});
// 버튼을 누르면 스크롤을 되돌리고 초점도 문서 처음으로 옮긴다(키보드 사용자는 초점이 따라가야 한다).
// focus() 에는 반드시 preventScroll: 이 데모는 목록에 iframe 으로 박혀 있어 부모 페이지가 끌려간다
topBtn.addEventListener('click', function () {
window.scrollTo({ top: 0, behavior: 'smooth' });
headEl.focus({ preventScroll: true });
});
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./InkContrastFooter.module.scss";
export type InkContrastFooterProps = {
/** 한 주기(ms) — 카드가 내려앉고 밑줄 넷이 차례로 스캔되는 시간 */
duration?: number;
easing?: string;
/** 밑줄·초점 테두리 색 — 잉크 위 11.58:1 인 아쿠아가 기본 */
color?: string;
/** 푸터 링크 이름 4개 */
links?: string[];
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** ambient 에 HTMLHeadingElement 초점 호출용 타입만 로컬로 둔다 */
type FocusTarget = { focus(o: { preventScroll: boolean }): void };
export default function InkContrastFooter({
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#6ee7d7",
links = ["소개", "글 목록", "문의", "개인정보"],
}: InkContrastFooterProps): ReactNode {
const [demo, setDemo] = useState(true);
const headRef = useRef<FocusTarget>(null);
// 격자 미리보기용 자동 재생. 사람이 손을 대면 떼고 진짜 조작으로 넘긴다
useEffect(() => {
const off = (): void => setDemo(false);
document.addEventListener("pointerdown", off, { once: true });
document.addEventListener("keydown", off, { once: true });
return () => {
document.removeEventListener("pointerdown", off);
document.removeEventListener("keydown", off);
};
}, []);
// 맨 위로 — 스크롤을 되돌리고 초점도 문서 맨 위로 옮긴다.
// focus() 에는 반드시 preventScroll: 데모가 목록에 iframe 으로 박히면 부모 페이지가 끌려간다
const goTop = useCallback((): void => {
window.scrollTo({ top: 0, behavior: "smooth" });
if (headRef.current) headRef.current.focus({ preventScroll: true });
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<footer className={`${styles.ic} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.sheet}>
<p className={styles.eyebrow}>본문 끝</p>
<h2 className={styles.head} tabIndex={-1} ref={headRef}>읽어 주셔서 고맙습니다</h2>
</div>
<div className={styles.floor}>
<nav className={styles.nav} aria-label="푸터 링크">
{links.map((t) => (
<a key={t} className={styles.link} href="#">
<span className={styles.lt}>{t}</span>
<span className={styles.rule} aria-hidden="true" />
</a>
))}
</nav>
<div className={styles.bottom}>
<p className={styles.copy}>© 2026 예시 사이트</p>
<button className={styles.top} type="button" onClick={goTop}>
<span className={styles.ic2} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M200 112H56l72-72Z" opacity=".2" />
<path d="m205.66 106.34l-72-72a8 8 0 0 0-11.32 0l-72 72A8 8 0 0 0 56 120h64v96a8 8 0 0 0 16 0v-96h64a8 8 0 0 0 5.66-13.66M75.31 104L128 51.31L180.69 104Z" />
</g>
</svg>
</span>
맨 위로
</button>
</div>
</div>
</footer>
);
}09 · 다크 잉크 대비 푸터 — 측정 누적면적 14.908% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
밝은 톤 사이트의 맨 끝을 묵직하게 닫을 때 씁니다.
.ic { --lift: -20px; } /* 본문 카드가 내려앉기 시작하는 높이 */
@keyframes sheetDrop {
0% { transform: translateY(var(--lift)); }
22% { transform: translateY(0); }
30% { transform: translateY(-3px); }
38%, 100% { transform: translateY(0); }
}
/* 복귀는 90% 안에 끝낸다 — 24프레임 샘플의 마지막 장이 95.8% 지점이다 */
@keyframes ruleScan {
0% { transform: scaleX(0); }
24%, 60% { transform: scaleX(1); }
84%, 100% { transform: scaleX(0); }
}
.ic.is-demo .ic__link:nth-child(2) .ic__rule { animation-delay: 180ms; }
.ic.is-demo .ic__link:nth-child(3) .ic__rule { animation-delay: 360ms; }
.ic.is-demo .ic__link:nth-child(4) .ic__rule { animation-delay: 540ms; }
어디서 깨지는가 — 함정
가장 먼저 무너진 것은 세로였습니다. 폰 폭 렌더의 무대는 320×200 이고, 위아래 패딩 12.8px 을 두 번 빼면 실제로 쓸 수 있는 세로는 174.4px 뿐입니다. 01 다단 푸터의 첫 설계는 좁은 화면에서 단마다 링크를 세로로 쌓는 흔한 처방을 따랐는데, 그 결과가 176.4px 로 2px 넘쳤습니다. 좁아졌으니 세로로 쌓는다는 반사적인 대응이 오히려 높이를 키운 것입니다. 링크 목록을 flex-direction: row; flex-wrap: wrap 으로 눕히고 여백을 한 단계씩 줄여 160px 로 내렸습니다. 좁은 화면 안에서는 값을 올리지 않고 내린다는 규칙이 여기서 나왔습니다.
두 번째는 썸네일입니다. 대표 이미지는 앞 장과 가장 크게 달라진 한 장으로 뽑히는데, 그래서 등장보다 퇴장이 이기기 쉽습니다. 07 연락처 카드는 처음에 카드 셋을 opacity 로 띄웠습니다. cubic-bezier(.2, .8, .2, 1) 곡선은 구간 30% 지점에서 이미 opacity 가 0.827 이라, 24프레임 샘플에 반투명한 유령 장이 반드시 한 장 걸렸습니다. 게다가 카드가 등장하는 프레임의 변화 면적 14,625px 가 그다음 장의 13,669px 를 이겨, 세 번째 카드가 아직 없는 장이 대표로 뽑혔습니다. 투명도를 버리고 clip-path: inset() 으로 왼쪽부터 드러나게 바꾸니 변화가 프레임마다 고르게 나뉘어 카드 셋과 핀과 물결이 다 있는 장이 대표가 되었습니다. 06 링크 트리도 같은 이유로 밑줄 차오름 9%·물러남 5% 를 5%·9% 로 뒤집어, 밑줄이 그어진 장이 뽑히게 했습니다.
세 번째는 이음매입니다. 08 웨이브의 트랙 폭은 Playwright 의 offsetWidth 로 재어 960px, 물결 타일은 120px, 한 벌이 480px 로 타일 정확히 넉 장입니다. 앞겹은 −480px, 뒷겹은 −240px 로 밀어 둘 다 타일 폭의 정수배가 되게 했고, translateX 가 0 · −480 · −240 인 세 지점의 스크린샷 해시가 완전히 같고 −479px 에서만 달랐습니다. 한 바퀴를 돌아온 뒤 픽셀이 어긋나지 않는다는 증거입니다. 정수배를 어기면 2초마다 무늬가 한 번씩 튀는데, 그 튐은 눈에 잘 안 띄면서도 계속 거슬립니다.
마지막은 04 하단 앱바의 색입니다. 활성 칸의 글자색을 알약 색과 같게 두었더니, 알약이 옆 칸으로 옮겨 가는 프레임에서 잉크색 글자가 잉크색 막대 위에 남아 그 칸이 통째로 사라져 보였습니다. 알약의 도착 시각도 키프레임 끝이 아니었습니다 — cubic-bezier(.2, 1.4, .4, 1) 은 목표를 한 번 지나쳤다 돌아오는 곡선이라, 키프레임 끝인 22·46·70% 가 아니라 실측 도착 시각인 4·27·51·75% 에 아이콘 튐을 맞춰야 했습니다. 글자색 교체는 steps(1, end) 컷으로 바꾸고 그 창을 24프레임 눈금(4.1667% 간격)에 맞춰 잘랐습니다. 링크 대신 위젯을 얹는 푸터라면 위젯 부품 단위 모음이 같은 174px 칸 기준으로 재단한 조각들을 따로 모아 둡니다.
접근성
색은 짐작하지 않고 WCAG 2.1 상대 휘도 공식으로 직접 계산했습니다. 아래 표의 맨 아래 두 줄이 이 팔레트에서 쓰지 않기로 한 조합입니다.
| 앞 | 뒤 | 실측 대비 | 본문 기준 |
|---|---|---|---|
| 흰 글자 #ffffff | 잉크 #1b1a20 | 17.28:1 | 통과 |
| 앰버 #ffd23f | 잉크 #1b1a20 | 11.97:1 | 통과 |
| 아쿠아 #6ee7d7 | 잉크 #1b1a20 | 11.58:1 | 통과 |
| 미스트 #b9b3c4 | 잉크 #1b1a20 | 8.48:1 | 통과 |
| 잉크 #1b1a20 | 크림 #fbf6ee | 16.06:1 | 통과 |
| 스톤 #575162 | 크림 #fbf6ee | 7.08:1 | 통과 |
| 흰 글자 #ffffff | 틸 #0b7269 | 5.79:1 | 통과 |
| 잉크 #1b1a20 | 오렌지 #ff4d1f | 5.21:1 | 통과 |
| 흰 글자 #ffffff | 오렌지 #ff4d1f | 3.32:1 | 미달 — 쓰지 않는다 |
| 흰 글자 #ffffff | 앰버 #ffd23f | 1.44:1 | 미달 — 쓰지 않는다 |
이 표를 만들며 통과한 조합만 골라 다시 렌더한 아홉 벌이 zip 에 그대로 들어 있고, 여는 비밀번호는 7g7q5d5z 입니다. 움직임을 줄여 달라는 설정(prefers-reduced-motion: reduce)에서는 아홉 개 모두 움직임만 끄고 상태와 정보는 남깁니다. 01 은 단이 펼쳐진 채로 서고 aria-expanded 가 그 상태를 그대로 알립니다. 02 는 아이콘·문구·진행 막대가 전부 멈추고 지금 상태 하나만 보입니다. 03 은 애니메이션과 전환이 함께 꺼져 점 색이 즉시 바뀝니다. 04 는 알약과 아이콘이 멈춰도 .dk__tab.is-on 규칙이 남아 활성 칸 하나는 그대로 잉크색입니다. 05 는 타일이 서 있되 호버·초점의 화살표 표시는 살아 있어 어느 것이 열리는 링크인지 알 수 있습니다. 06 은 aria-current="page" 링크의 밑줄이 차 있는 채로 멈춥니다. 07 은 카드 셋이 모두 제자리에 서고 물결 고리만 얇게 남습니다. 08 은 물결이 멈추되 곡선 모양은 그대로입니다. 09 는 카드가 바닥에 얹힌 상태로 고정됩니다.
구조 쪽에서 실제로 넣은 장치는 이렇습니다. 아홉 개 전부 <footer> 로 감싸 contentinfo 랜드마크를 만들었고, 03 처럼 다른 요소 안에 들어갈 수 있는 데모에는 role="contentinfo" 를 명시했습니다. 링크 묶음에는 <nav aria-label="푸터 링크"> 처럼 이름을 붙여 스크린 리더 목록에서 헤더의 메뉴와 구분되도록 했습니다. 01 의 아코디언 버튼은 aria-expanded 와 aria-controls 를 함께 쓰고 위아래 화살표 키로 열 머리 사이를 옮길 수 있습니다. 04 와 06 은 현재 위치에 aria-current="page" 를 답니다. 02 의 결과 문장은 role="status" 와 aria-live="polite" 를 단 한 칸에 모아 두어, 성공이든 오류든 화면을 못 보는 사람에게 같은 자리에서 한 번만 읽힙니다. 08 의 재생 스위치는 aria-pressed 로 상태를 알리고, 07 의 연락처는 <address> 안에 있습니다. 아홉 개 모두 :focus-visible 아웃라인을 무대마다 보이는 색으로 따로 지정했습니다 — 04 는 아쿠아 알약 위에서도 보이도록 흰 테를 씁니다. 여닫기 방식의 근거는 MDN 의 grid-template-rows 문서와 aria-live 리전 문서, 대비 기준은 W3C 의 최소 명도 대비 해설을 따랐습니다. 화면 아래에 붙는 조작바만 더 보고 싶다면 플로팅 독 내비게이션과 내비게이션 모음으로 이어집니다. 이 사이트가 코드를 어떻게 만들고 검증하는지는 소개에 적어 두었습니다.
FAQ
웹사이트 푸터 필수 항목 말고 링크를 몇 개까지 넣어도 괜찮나요?
개수보다 묶음이 중요합니다. 01 다단 사이트맵처럼 제품·회사·지원 같은 머리를 세 개쯤 두고 그 밑에 서너 개씩 담으면 링크가 스무 개여도 읽힙니다. 반대로 묶음 없이 열두 개를 한 줄로 늘어놓으면 다섯 개일 때보다 더 안 읽힙니다. 좁은 화면에서 묶음이 아코디언으로 접히면 세로 높이 문제도 같이 풀립니다. 링크가 아니라 밖에서 온 콘텐츠를 얹는 길은 임베드 위젯 카드에 따로 정리돼 있습니다.
푸터를 화면 아래에 계속 붙여 둬도 되나요?
04 처럼 바닥에 고정하는 것은 푸터가 아니라 조작바로 쓸 때만 맞습니다. 실제로 04 의 막대는 position: sticky; bottom: 0 이고, 그 sticky 가 걸리려면 부모 문서가 화면보다 길어야 합니다. 여유를 padding-bottom 으로 주면 sticky 범위 밖이라 소용이 없어, 데모에서는 문서의 min-height 를 280px 로 잡아 높이 차이를 만들었습니다. 저작권과 사이트맵 같은 진짜 푸터 내용은 문서 끝에 그냥 두는 편이 낫습니다.
어두운 푸터에서 글자색은 어떻게 고르나요?
밝기가 아니라 대비비로 고릅니다. 위 표에서 보듯 잉크 바닥 위의 미스트 회색은 8.48:1 이라 보조 글자로 충분하지만, 같은 흰 글자라도 오렌지 위에서는 3.32:1 로 떨어져 본문 기준 4.5:1 을 못 넘깁니다. 그래서 이 아홉 개는 오렌지 무대 위의 글자를 전부 잉크로 뒤집었습니다. 강조색을 정할 때는 색을 먼저 고르고 대비를 재는 대신, 대비를 먼저 재고 통과한 것 중에서 고르는 편이 빠릅니다.









