Website Footer Design: 9 Copy-Paste Patterns
Website footer design is how the last band of a page is laid out — sitemap links, a signup box, contact details, a copyright line — and these nine patterns
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Multi-column sitemap footer
- 02 Newsletter-integrated footer
- 03 Minimal one-line footer
- 04 Sticky bottom app bar
- 05 Social tile footer
- 06 Legal link-tree footer
- 07 Contact and map card footer
- 08 Wavy-edge footer
- 09 Dark-ink contrast footer
The nine follow the order in which a visitor actually meets a footer, not a popularity ranking. First the full map of the site (01), then the one thing the site wants back from you (02), then the smallest possible way to close a page (03). Next come the bars and tiles that live at the bottom of a phone screen (04, 05), then the ladder of links and legal pages a profile-style site ends on (06), then the real-world address block (07). The last two treat the footer's own edge and floor as the design: a wave that hands the page over to the dark band (08), and the dark band itself catching the light sheet above it (09). Every measurement below comes from the same 24-frame render of each demo at 480×300, and none of the nine overflowed its stage at desktop or phone width. Website footer design for mobile is not a shrunken copy of the desktop band, which is why the sticky bar and the tile grid sit in the middle of this list. Treat this list as the closing page and the opening chapters go to sections that convert and hero section patterns.
01Multi-column sitemap footer
What you see
Three link groups stand side by side on a wide screen, and under 340px wide or 220px tall each group folds into an accordion whose caret rotates as the list opens. Reach for it when the footer has to carry the whole sitemap.

<main class="stage">
<footer class="fsm is-demo" role="contentinfo">
<p class="fsm__brand">Studio</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">Product</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="Product links">
<a href="#">Features</a>
<a href="#">Pricing</a>
<a href="#">Use cases</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">Company</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="Company links">
<a href="#">About us</a>
<a href="#">Careers</a>
<a href="#">Press</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">Support</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="Support links">
<a href="#">Help</a>
<a href="#">Contact</a>
<a href="#">Status</a>
</nav>
</div>
</section>
</div>
<p class="fsm__legal">© 2026 Studio</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 · Multi-column sitemap footer — Measured area 2.053% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The open and close run on a grid-template-rows animation from 0fr to 1fr — plain height: auto does not interpolate, but a grid track does — and the panel moved in 12 of 24 frames for 2.053% cumulative changed area.
.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; }
02Newsletter-integrated footer
How website footer design examples handle a signup box on screen
Entering an email address and pressing send pushes the button down 3px, swaps the paper plane for a spinner, then stamps in a checkmark while a thin bar fills from scaleX(0) to scaleX(1). It suits any footer whose real job is collecting one address.

<main class="stage">
<footer class="fn is-demo is-idle" role="contentinfo">
<p class="fn__title">Get the weekly digest</p>
<p class="fn__desc">New posts and code snippets, once a week. Nothing else.</p>
<form class="fn__form" novalidate>
<label class="fn__label" for="fn-mail">Email address</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">Subscribe</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">You can unsubscribe in one click, anytime.</span>
<span class="fn__m fn__m--send">Sending…</span>
<span class="fn__m fn__m--done">Confirmation email sent.</span>
<span class="fn__m fn__m--err"></span>
</p>
<p class="fn__legal">© 2026 Studio</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 = 'Please enter an email address.'; setState('is-error'); return; }
if (v.indexOf('@') < 0) { err.textContent = 'The address is missing an @.'; 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 · Newsletter-integrated footer — Measured area 3.778% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The three icons are stacked in a single grid cell so the button never changes width as the state changes, and the loop registered 11 animations across 10 moving frames at intensity 109.8.
.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; }
03Minimal one-line footer
What moves
A wordmark, a copyright line, and three round social dots hold one row at every width, and a hovered or focused dot rises 6px and floods with its own brand color. This is the one to paste into a portfolio where the footer should take up as little room as possible.

<main class="stage">
<footer class="fo is-demo" role="contentinfo">
<a class="fo__mark" href="#">
<span class="fo__mark-t">Studio</span>
<span class="fo__line" aria-hidden="true"></span>
</a>
<p class="fo__copy">© 2026 Studio</p>
<nav class="fo__social" aria-label="Social links">
<a class="fo__dot fo__dot--a" href="#" aria-label="Photo account">
<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="Short updates account">
<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="Code repository">
<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 · Minimal one-line footer — Measured area 1.865% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
It is the quietest of the nine — 1.868% cumulative changed area — yet the busiest in sampling terms, with 18 of 24 frames showing movement at intensity 140.0.
.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);
}
04Sticky bottom app bar
What you see
A bar sticks to the bottom of a scrolling app screen, and the aqua pill slides between four slots while the arriving icon pops up 5px and scales to 1.16. Use it for a mobile web app where the bottom row is navigation rather than a footer.

<div class="stage">
<div class="dk">
<div class="dk__view">
<div class="dk__doc">
<div class="dk__body">
<p class="dk__title">Today's log</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="Primary screen navigation">
<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">Home</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">Search</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">Saved</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">More</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 · Sticky bottom app bar — Measured area 9.898% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The pill is one slot wide at width: 25%, so a single translateX(100%) is exactly one slot and nothing has to be measured in pixels; the whole cycle changed 9.898% of the frame across 18 moving frames.
.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%; // one slot = 100%, so translateX(100%) is exactly one slot
height: 100%;
border-radius: $r-control;
background: $color;
transform: translateX(calc(var(--n, 0) * 100%));
transition: transform $dur-base $ease-spring;
}
05Social tile footer
What happens on screen
Four large tiles take turns lifting 6px out of a pressed shadow into a raised one, the lifted tile's glyph switching to its account color while a small arrow scales up from nothing in the corner. Pick it when following the account is the point of the footer, not reading the links.

<div class="stage">
<footer class="sc">
<p class="sc__head">Follow</p>
<nav class="sc__grid" aria-label="Social account links">
<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">Each account posts different updates</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 · Social tile footer — Measured area 13.894% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Lifting takes 5% of the loop and settling takes 9%, which is deliberate — 13.897% cumulative changed area over 19 moving frames, weighted toward the lift.
.sc__go {
position: absolute;
top: 8px;
right: 8px;
display: block;
color: $ft-teal;
transform: scale(0);
transform-origin: top right;
transition: transform $dur-quick $easing;
}
@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; }
}
06Legal link-tree footer
What moves
The more important a link, the bigger its step on the staircase: lead links run up to 26px, secondary ones to 17px, and the terms and policy line sits small underneath. Hovering or focusing nudges the link 6px to the right while an underline fills from the left.

<div class="stage">
<footer class="lt">
<nav class="lt__tree" aria-label="Primary links">
<a class="lt__link lt__link--lead" href="#" aria-current="page">Work archive</a>
<a class="lt__link lt__link--lead" href="#">Posts</a>
<a class="lt__link lt__link--sub" href="#">About</a>
<a class="lt__link lt__link--sub" href="#">Contact</a>
</nav>
<nav class="lt__legal" aria-label="Legal notices">
<a class="lt__fine" href="#">Terms of use</a>
<a class="lt__fine" href="#">Privacy policy</a>
<a class="lt__fine" href="#">Cookie settings</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 · Legal link-tree footer — Measured area 3.451% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The underline is a scaleX on a pseudo-element rather than an animated width, so no layout is recalculated — 3.451% changed area across 19 moving frames.
.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;
transition: transform $dur-quick $easing;
}
.lt__link:hover,
.lt__link:focus-visible { transform: translateX(6px); }
.lt__link:hover::after,
.lt__link:focus-visible::after { transform: scaleX(1); }
07Contact and map card footer
What you see
A pin falls into the map card, bounces back up 9px once, and a ring ripples out to 2.4× and fades while the address, phone, and mail cards wipe in, 220ms apart. It belongs at the bottom of any store or office page that ends with directions.

<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="Drop the pin again">
<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">Directions</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">Address</span><span class="fc__v">12 Main Road, 4F</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">Phone</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">Email</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 · Contact and map card footer — Measured area 29.003% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
There is no map image and no network request: the streets are three repeating-linear-gradient layers over a cream base, and this is the widest-moving of the nine at 29.003% cumulative changed area over 20 frames.
.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;
}
08Wavy-edge footer
What happens on screen
The seam between the cream body and the ink footer is two wave layers drifting left at different speeds, the dark front layer covering four tiles per cycle and the pale back layer two. Use it wherever a section color change would otherwise land as a hard horizontal line.

<div class="stage">
<!-- footer 가 body 계통에 있으므로 암묵적 role 은 contentinfo 다 -->
<footer class="wv">
<div class="wv__body">
<p class="wv__lead">The main text ends here</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="Footer links">
<a class="wv__link" href="#">About</a>
<a class="wv__link" href="#">Posts</a>
<a class="wv__link" href="#">Contact</a>
</nav>
<!-- 움직임을 끄는 스위치. 라벨 두 개를 다 심어 두고 aria-pressed 로 갈라 보인다 -->
<button class="wv__toggle" type="button" aria-pressed="false">
<span class="wv__t wv__t--stop">Pause waves</span>
<span class="wv__t wv__t--play">Play waves</span>
</button>
</div>
<p class="wv__copy">© 2026 Example Site</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 · Wavy-edge footer — Measured area 6.332% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Both waves are 120×28 SVG paths inlined as data: URIs, so the effect costs zero network requests, and it moved in 23 of 24 frames — the steadiest loop in the set at intensity 149.1.
.wv__wave {
position: absolute;
top: 0;
left: 0;
display: block;
width: 960px; // two 480px sets of four 120px tiles
height: 28px;
background-repeat: repeat-x;
background-size: 120px 28px;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-duration: $duration;
}
@keyframes waveBack { from { transform: translateX(0); } to { transform: translateX(-240px); } }
@keyframes waveFront { from { transform: translateX(0); } to { transform: translateX(-480px); } }
09Dark-ink contrast footer
What moves
The cream sheet drops onto the ink floor, overshoots 3px, settles, and then four aqua underlines scan one after another across the links, 180ms apart. This is the closing frame for a light-toned site that needs weight at the bottom.

<div class="stage">
<!-- footer 가 body 계통에 있으므로 암묵적 role 은 contentinfo 다 -->
<footer class="ic">
<!-- 밝은 본문 카드가 잉크 바닥 위로 내려앉으며 페이지가 닫힌다 -->
<div class="ic__sheet">
<p class="ic__eyebrow">End of page</p>
<h2 class="ic__head" tabindex="-1">Thank you for reading</h2>
</div>
<div class="ic__floor">
<nav class="ic__nav" aria-label="Footer links">
<a class="ic__link" href="#"><span class="ic__lt">About</span><span class="ic__rule" aria-hidden="true"></span></a>
<a class="ic__link" href="#"><span class="ic__lt">Posts</span><span class="ic__rule" aria-hidden="true"></span></a>
<a class="ic__link" href="#"><span class="ic__lt">Contact</span><span class="ic__rule" aria-hidden="true"></span></a>
<a class="ic__link" href="#"><span class="ic__lt">Privacy</span><span class="ic__rule" aria-hidden="true"></span></a>
</nav>
<div class="ic__bottom">
<p class="ic__copy">© 2026 Example Site</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>
Back to top
</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 · Dark-ink contrast footer — Measured area 14.908% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Cream on ink measures 16.06:1 and white on ink 17.28:1, which is why the sheet edge reads as a hard line rather than a gradient; 14.908% changed area over 22 moving frames.
.ic__sheet {
padding: $sp-4;
border-radius: $r-card;
background: $ft-cream;
text-align: center;
box-shadow: $shadow-raised;
}
@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); }
}
Where it breaks — the trap
The first wall was vertical. The phone render is 320×200, and after the stage's own 12.8px padding on the top and bottom there are exactly 174.4px left for the footer. The first build of 01 stacked each column's links vertically on a narrow screen and came back at 176.4px — two pixels over. The reflex fix, stacking things when the screen gets small, is what caused it: stacking trades width you have for height you do not. Laying the link lists out with flex-direction: row; flex-wrap: wrap and trimming the spacing brought it to 160px, and the rule that came out of it is that you never raise a value inside a narrow-screen media query, only lower it. Two more decisions came off the same 174.4px budget: the caret moves to the far end of the header with justify-content: space-between so each column reads as one full-width accordion bar, and the third link in each group is dropped with display: none instead of being allowed to wrap onto a line nobody has room for.
The second is about which frame becomes the thumbnail. The poster is picked as the frame that changed most from the one before it, which means an exit can beat an entrance. The contact cards in 07 first faded in with opacity, and on a cubic-bezier(.2, .8, .2, 1) curve the value is already 0.827 at 30% of its window, so a half-transparent ghost frame was guaranteed somewhere in the 24-frame sample. Worse, the entrance frame's changed area of 14,625px beat the next frame's 13,669px, so the frame chosen as the poster was the one still missing the third card. Replacing the fade with a clip-path: inset() wipe spread the change evenly across frames, and the poster became the frame that has all three cards, the pin, and the ripple. Item 06 had the same disease in miniature: the underline filled in 9% of the loop and retracted in 5%, so the retraction was the bigger change and the thumbnail showed a bare link. Swapping those to 5% fill and 9% retract made the frame with the underline win.
The third is arithmetic. A seamless wave only works if it shifts by whole tiles. Playwright reported the wave track in 08 at an offsetWidth of 960px, the tile is 120px, and one set is 480px — exactly four tiles. The front layer shifts −480px per cycle and the back layer −240px, both whole multiples, and screenshot hashes taken at translateX 0, −480px, and −240px came back identical while −479px differed. That last comparison is the proof: the loop lands on the same pixels, so there is no seam to see.
The fourth cost a whole navigation slot. In 04 the active label turns ink so it can be read on the aqua pill, and while the pill traveled away, an ink label left sitting on the ink bar simply disappeared — the slot looked empty. Nor was the pill's arrival the end of its keyframe: cubic-bezier(.2, 1.4, .4, 1) overshoots, and Playwright showed the pill already parked at 52% of its [46, 58]% window, so the color cut had to be moved to the measured arrival time rather than the keyframe boundary. The pill's width was never guessed either — a four-column grid put the nav at 312px and each slot at 78px, the pill measured 78px, and the tab positions came back at 84, 162, 240, and 318px, an exact match. Footers that carry widgets instead of links are covered by the standalone dashboard widget pieces, cut to the same 174px cell. The archive you unlock with 7g7q5d5z carries all nine at these corrected values, so none of this has to be rediscovered.
Accessibility
Contrast was computed with the WCAG 2.1 relative-luminance formula for every pairing used in the nine, and two pairings were computed, failed, and then kept out of the demos entirely.
| Foreground | Background | Measured ratio | Body-text bar |
|---|---|---|---|
| White #ffffff | Ink #1b1a20 | 17.28:1 | Pass |
| Amber #ffd23f | Ink #1b1a20 | 11.97:1 | Pass |
| Aqua #6ee7d7 | Ink #1b1a20 | 11.58:1 | Pass |
| Mist #b9b3c4 | Ink #1b1a20 | 8.48:1 | Pass |
| Ink #1b1a20 | Cream #fbf6ee | 16.06:1 | Pass |
| Stone #575162 | Cream #fbf6ee | 7.08:1 | Pass |
| White #ffffff | Teal #0b7269 | 5.79:1 | Pass |
| Ink #1b1a20 | Orange #ff4d1f | 5.21:1 | Pass |
| White #ffffff | Orange #ff4d1f | 3.32:1 | Fails — never used |
| White #ffffff | Amber #ffd23f | 1.44:1 | Fails — never used |
That is the reason every word on the orange stage of 05 and 07 is ink rather than white, and why the amber stage of 06 carries ink text only. The minimum body-text bar and the way these numbers are derived are set out in the W3C's contrast guidance.
Under prefers-reduced-motion: reduce all nine drop the motion and keep the state and the information. In 01 the panels and carets stop animating, but aria-expanded still decides which column is open. In 02 the icons, messages, button, bar, and spinner all stop, while the result sentence still appears in the live region. In 03 the autoplay loop stops, and hover and focus still recolor a dot instantly, the 6px lift included. In 04 the pill freezes and the rule .is-demo .dk__tab.is-on { color: $ft-ink; } keeps the active slot readable. In 05 the tiles stop lifting but hover and focus still scale the corner arrow to 1. In 06 the underline on [aria-current="page"] stays filled at scaleX(1) so the current page is still marked. In 07 the pin, ripple, and all three cards are locked to opacity: 1 and transform: none, so nothing is mid-entrance. In 08 the two waves simply stop, shape intact. In 09 the sheet and the scan lines stop and the layout holds. Every one of those blocks needs !important, because the autoplay selectors carry a (0, 3, 0) specificity that a bare rule inside a media query loses to.
The markup that carries all of this is there to be checked. Each of the nine is a real <footer> element, and 01, 02, and 03 spell out role="contentinfo" on top of the implicit landmark. Seven of them label a <nav> — 01 labels one per column, 06 separates a main list from a legal list, and 03, 04, 05, 08, and 09 label a single one — while 02 is a <form> and 07 wraps its details in <address>. The accordion in 01 uses aria-expanded with aria-controls, 04 and 06 mark the current destination with aria-current="page", 02 announces its result through role="status" and aria-live="polite" following MDN's live regions guide, and 08's pause button is a real aria-pressed toggle. Keyboard paths are wired in code, not implied: 01 moves between column headers with the up and down arrows, and 04 runs a roving tabIndex so Tab enters the bar once and the left and right arrows move the pill from there. All nine define :focus-visible outlines, and the 0fr to 1fr track animation follows MDN's grid-template-rows reference.
Neighboring pieces of a page live in the template collection, and the about page explains how each demo here gets measured before it ships.
FAQ
Do these nine work without any JavaScript?
Seven of them do. The motion in 01, 03, 05, 06, 07, 08, and 09 is CSS animation plus hover and focus rules, so pasting the HTML and the compiled CSS is enough. Only 02 needs script for the send-and-confirm sequence, and 04 needs it for the roving tabIndex and the arrow keys — 01's accordion toggle runs on script as well, but the footer ships with every panel open, so nothing is hidden without it — and even there the visible states are CSS classes, so you can wire them to your own framework instead. Embedding outside content rather than links takes the other road, mapped in embed widget cards.
Why is the wave in 08 an inline SVG background instead of an image file?
Because a footer edge that arrives a moment after the page does looks broken. The wave is a 120×28 path encoded as a data: URI inside the stylesheet, which means zero extra requests, no flash before it loads, and a color you change by editing one hex value in the SCSS rather than re-exporting an asset.
How do I make one of these footer design ui blocks the actual bottom of my site?
Drop the .stage wrapper the demo uses to center itself on the preview canvas, put the pattern's own root element at the end of your page, and let it span the full width. The narrow-screen rules are written against max-width: 340px and max-height: 220px, so raise those breakpoints to your real phone width before you judge how it looks.









