フッター デザイン コピペで終わるパターン9選
フッター デザイン コピペで探しているのは、ページの一番下でサイト全体を締めくくる区画のひな形です。ここではリンクの一覧で終わらせない九つのフッターを、そのまま貼れるHTMLとSCSSで載せました。サイトマップから波の境目まで、すべて画面の中で実際に動きます。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 多段サイトマップフッター
- 02 ニュースレター統合フッター
- 03 ミニマル一行フッター
- 04 スティッキー下部アプリバー
- 05 ソーシャルタイルフッター
- 06 リーガル・リンクツリーフッター
- 07 連絡先・地図カードフッター
- 08 ウェーブ境界フッター
- 09 ダークインクのコントラストフッター
並び順は人気順ではなく、訪問者がフッターに着いてから出会う順にしました。まずページ全体の地図を広げ(01)、そこで関係が続くように連絡先を預かってもらい(02)、名前と著作権だけで静かに閉じる形を見せ(03)、スマホでは下部に張り付いて消えないバーを置き(04)、外のアカウントへ渡し(05)、規約や方針の置き場を決め(06)、実店舗なら来店への道を示し(07)、最後に上の本文との境目そのものを作り(08)、暗い床で全体を引き締めます(09)。文字も図形もサイト共通のインク・クリーム・アンバーのトークンだけで組んであるので、自分のブランド色に差し替えても配置は崩れません。フッター デザイン スマホ が別物になるのは、画面が狭くなるとフッターが一覧ではなく指の届く操作バーへ変わるからです。この一覧を閉じのページとするなら、開きの章はランディングページ 改善のセクションとファーストビュー デザインが担います。
01多段サイトマップフッター
何が見えるか
広い画面ではリンクのまとまりが三列に並び、狭い画面では各列がアコーディオンになります。開閉は高さではなくグリッドの行を0frと1frの間で動かしているので、中身の行数がいくつでも同じコードで滑らかに開きます。

<main class="stage">
<footer class="fsm is-demo" role="contentinfo">
<p class="fsm__brand">スタジオ</p>
<div class="fsm__cols">
<section class="fsm__col">
<h2 class="fsm__head">
<button class="fsm__btn" type="button" aria-expanded="true" aria-controls="fsm-p1">
<span class="fsm__btn-t">製品</span>
<svg class="fsm__caret" viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
</button>
</h2>
<div class="fsm__panel" id="fsm-p1">
<nav class="fsm__list" aria-label="製品リンク">
<a href="#">機能紹介</a>
<a href="#">料金案内</a>
<a href="#">導入事例</a>
</nav>
</div>
</section>
<section class="fsm__col">
<h2 class="fsm__head">
<button class="fsm__btn" type="button" aria-expanded="true" aria-controls="fsm-p2">
<span class="fsm__btn-t">会社</span>
<svg class="fsm__caret" viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
</button>
</h2>
<div class="fsm__panel" id="fsm-p2">
<nav class="fsm__list" aria-label="会社リンク">
<a href="#">会社概要</a>
<a href="#">採用情報</a>
<a href="#">プレス資料</a>
</nav>
</div>
</section>
<section class="fsm__col">
<h2 class="fsm__head">
<button class="fsm__btn" type="button" aria-expanded="true" aria-controls="fsm-p3">
<span class="fsm__btn-t">サポート</span>
<svg class="fsm__caret" viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
</button>
</h2>
<div class="fsm__panel" id="fsm-p3">
<nav class="fsm__list" aria-label="サポートリンク">
<a href="#">ヘルプ</a>
<a href="#">問い合わせ窓口</a>
<a href="#">稼働状況</a>
</nav>
</div>
</section>
</div>
<p class="fsm__legal">© 2026 スタジオ</p>
</footer>
</main>@use "../../../../site/static/demo-tokens" as *;
// 272 푸터 팔레트 — 세 항목이 같은 값을 쓴다. 괄호 안은 실측 대비비
$ft-ink: #1b1a20; // 어두운 푸터 바닥
$ft-ink2: #2c2a33; // 바닥 위 구분선
$ft-paper: $stage-paper; // 흰 글자 on ink 17.28:1
$ft-cream: #fbf6ee; // 무대
$ft-mist: #b9b3c4; // 보조 글자 on ink 8.48:1
$ft-amber: $stage-yellow; // 브랜드 on ink 11.97:1
$ft-aqua: #b3dad5; // 캐럿·링크 강조 on ink 11.58:1
$duration: $dur-loop; // 세 단이 한 바퀴 열렸다 닫히는 주기
$easing: $ease-spring; // 목록이 펼쳐질 때의 감속
$color: $ft-cream; // 무대(크림)
@include stage($ft-cream);
.fsm {
box-sizing: border-box;
width: clamp(320px, 92vw, 432px);
padding: $sp-4;
border-radius: $r-card;
background: $ft-ink;
color: $ft-paper;
}
.fsm__brand {
margin: 0 0 $sp-3;
color: $ft-amber;
font-weight: 800; font-size: 15px; line-height: 1.2;
letter-spacing: .02em;
}
// 넓은 화면: 링크 묶음 세 단이 나란히. 단이 접혀도 바닥 높이가 흔들리지 않게 min-height 로 자리를 잡아 둔다
.fsm__cols {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: start;
gap: $sp-3;
min-height: 92px;
}
.fsm__head { margin: 0; }
.fsm__btn {
display: flex;
align-items: center;
justify-content: flex-start; // 캐럿은 열 이름 바로 옆 — 단 폭이 넓어도 멀어지지 않는다
gap: $sp-2;
width: 100%;
padding: 0;
border: 0;
border-radius: $r-xs;
background: none;
color: $ft-paper;
font-family: inherit;
font-weight: 700; font-size: 13px; line-height: 1.4;
text-align: left;
cursor: pointer;
}
.fsm__btn:focus-visible {
outline: 2px solid $ft-aqua;
outline-offset: 2px;
}
.fsm__caret {
flex: 0 0 auto;
color: $ft-aqua;
transition: transform $dur-quick $easing;
}
// 접힌 단의 캐럿은 오른쪽을 가리킨다 (정적 상태 규칙 — .is-demo 를 떼도 말이 된다)
.fsm__btn[aria-expanded="false"] .fsm__caret { transform: rotate(-90deg); }
// 여닫기의 본체: 0fr ↔ 1fr. 높이 auto 로는 보간이 안 되지만 grid 트랙은 보간된다
.fsm__panel {
display: grid;
grid-template-rows: 1fr;
overflow: hidden;
transition: grid-template-rows $dur-base $easing;
}
.fsm__panel > * { min-height: 0; }
.fsm__col:has(.fsm__btn[aria-expanded="false"]) .fsm__panel { grid-template-rows: 0fr; }
.fsm__list {
display: flex;
flex-direction: column;
gap: $sp-1;
padding-top: $sp-2;
}
.fsm__list a {
color: $ft-mist;
font-weight: 500; font-size: 12px; line-height: 1.4;
text-decoration: none;
}
.fsm__list a:hover { color: $ft-aqua; }
.fsm__list a:focus-visible {
outline: 2px solid $ft-aqua;
outline-offset: 2px;
border-radius: $r-xs;
}
.fsm__legal {
margin: $sp-3 0 0;
padding-top: $sp-3;
border-top: 1px solid $ft-ink2;
color: $ft-mist;
font-weight: 500; font-size: 11px; line-height: 1.4;
}
// 자동 재생 — 세 단이 1/3 주기씩 어긋나 차례로 열렸다 닫힌다.
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로 쓴다
.fsm.is-demo .fsm__panel,
.fsm.is-demo .fsm__caret {
animation-duration: $duration;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fsm.is-demo .fsm__panel {
animation-name: fsmSweep;
animation-timing-function: $easing;
transition: none;
}
.fsm.is-demo .fsm__caret {
animation-name: fsmCaret;
animation-timing-function: $easing;
transition: none;
}
.fsm.is-demo .fsm__col:nth-child(2) .fsm__panel,
.fsm.is-demo .fsm__col:nth-child(2) .fsm__caret { animation-delay: -.6667s; }
.fsm.is-demo .fsm__col:nth-child(3) .fsm__panel,
.fsm.is-demo .fsm__col:nth-child(3) .fsm__caret { animation-delay: -1.3333s; }
// 복귀는 90% 안에 끝낸다 — 24프레임의 마지막 장은 95.8% 지점이라 그 뒤는 샘플이 없다
@keyframes fsmSweep {
0% { grid-template-rows: 0fr; }
8%, 74% { grid-template-rows: 1fr; }
86%, 100% { grid-template-rows: 0fr; }
}
@keyframes fsmCaret {
0% { transform: rotate(-90deg); }
8%, 74% { transform: rotate(0deg); }
86%, 100% { transform: rotate(-90deg); }
}
// 좁은 화면: 각 단이 아코디언이 된다. 세로 여유가 174px 뿐이라 값을 올리지 않고 내린다
@media (max-width: 340px), (max-height: 220px) {
.fsm { width: 100%; padding: $sp-2; }
.fsm__brand { margin-bottom: $sp-1; font-size: 10px; }
.fsm__cols { grid-template-columns: 1fr; gap: $sp-1; min-height: 0; }
// 한 단이 한 줄을 통째로 쓰는 아코디언이 되므로 캐럿을 줄 오른쪽 끝으로 보낸다
.fsm__btn { justify-content: space-between; font-size: 11px; }
.fsm__list {
flex-direction: row;
flex-wrap: wrap;
gap: 0 $sp-2;
padding-top: $sp-1;
}
.fsm__list a { font-size: 10px; line-height: 1.3; }
.fsm__list a:nth-child(3) { display: none; }
.fsm__legal { margin-top: $sp-1; padding-top: $sp-1; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// .fsm.is-demo .fsm__panel (0,3,0) 을 이기려면 !important 가 필요하다
.fsm__panel, .fsm__caret { animation: none !important; transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #fbf6ee;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fsm {
box-sizing: border-box;
width: clamp(320px, 92vw, 432px);
padding: 16px;
border-radius: 16px;
background: #1b1a20;
color: #ffffff;
}
.fsm__brand {
margin: 0 0 12px;
color: #ffd23f;
font-weight: 800;
font-size: 15px;
line-height: 1.2;
letter-spacing: 0.02em;
}
.fsm__cols {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: start;
gap: 12px;
min-height: 92px;
}
.fsm__head {
margin: 0;
}
.fsm__btn {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
width: 100%;
padding: 0;
border: 0;
border-radius: 4px;
background: none;
color: #ffffff;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.4;
text-align: left;
cursor: pointer;
}
.fsm__btn:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 2px;
}
.fsm__caret {
flex: 0 0 auto;
color: #b3dad5;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fsm__btn[aria-expanded=false] .fsm__caret {
transform: rotate(-90deg);
}
.fsm__panel {
display: grid;
grid-template-rows: 1fr;
overflow: hidden;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fsm__panel > * {
min-height: 0;
}
.fsm__col:has(.fsm__btn[aria-expanded=false]) .fsm__panel {
grid-template-rows: 0fr;
}
.fsm__list {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 8px;
}
.fsm__list a {
color: #b9b3c4;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-decoration: none;
}
.fsm__list a:hover {
color: #b3dad5;
}
.fsm__list a:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 2px;
border-radius: 4px;
}
.fsm__legal {
margin: 12px 0 0;
padding-top: 12px;
border-top: 1px solid #2c2a33;
color: #b9b3c4;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.fsm.is-demo .fsm__panel,
.fsm.is-demo .fsm__caret {
animation-duration: 2s;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fsm.is-demo .fsm__panel {
animation-name: fsmSweep;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
transition: none;
}
.fsm.is-demo .fsm__caret {
animation-name: fsmCaret;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
transition: none;
}
.fsm.is-demo .fsm__col:nth-child(2) .fsm__panel,
.fsm.is-demo .fsm__col:nth-child(2) .fsm__caret {
animation-delay: -0.6667s;
}
.fsm.is-demo .fsm__col:nth-child(3) .fsm__panel,
.fsm.is-demo .fsm__col:nth-child(3) .fsm__caret {
animation-delay: -1.3333s;
}
@keyframes fsmSweep {
0% {
grid-template-rows: 0fr;
}
8%, 74% {
grid-template-rows: 1fr;
}
86%, 100% {
grid-template-rows: 0fr;
}
}
@keyframes fsmCaret {
0% {
transform: rotate(-90deg);
}
8%, 74% {
transform: rotate(0deg);
}
86%, 100% {
transform: rotate(-90deg);
}
}
@media (max-width: 340px), (max-height: 220px) {
.fsm {
width: 100%;
padding: 8px;
}
.fsm__brand {
margin-bottom: 4px;
font-size: 10px;
}
.fsm__cols {
grid-template-columns: 1fr;
gap: 4px;
min-height: 0;
}
.fsm__btn {
justify-content: space-between;
font-size: 11px;
}
.fsm__list {
flex-direction: row;
flex-wrap: wrap;
gap: 0 8px;
padding-top: 4px;
}
.fsm__list a {
font-size: 10px;
line-height: 1.3;
}
.fsm__list a:nth-child(3) {
display: none;
}
.fsm__legal {
margin-top: 4px;
padding-top: 4px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.fsm__panel, .fsm__caret {
animation: none !important;
transition: none !important;
}
}(function () {
var root = document.querySelector('.fsm');
var btns = [].slice.call(root.querySelectorAll('.fsm__btn'));
// 사람이 손을 대면 자동 루프를 끄고 aria-expanded 가 상태의 유일한 출처가 된다
function stopLoop() { root.classList.remove('is-demo'); }
btns.forEach(function (b, i) {
b.addEventListener('click', function () {
stopLoop();
b.setAttribute('aria-expanded', b.getAttribute('aria-expanded') === 'true' ? 'false' : 'true');
});
// 위아래 화살표로 열 머리 사이를 옮긴다. Enter·Space 는 button 이 기본으로 처리한다
b.addEventListener('keydown', function (e) {
var step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
if (!step) { return; }
e.preventDefault();
stopLoop();
btns[(i + step + btns.length) % btns.length].focus({ preventScroll: true });
});
});
root.addEventListener('pointerenter', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./FooterSitemapMega.module.scss";
/* ambient 에 KeyboardEvent 가 없어 파일 안에 필요한 만큼만 선언한다 */
type KeyEvt = { key: string; preventDefault(): void };
export type SitemapColumn = {
/** 열 머리 이름 */
title: string;
/** 링크 묶음의 이름 — nav 의 aria-label 이 된다 */
navLabel: string;
links: string[];
};
export type FooterSitemapMegaProps = {
brand?: string;
legal?: string;
columns?: SitemapColumn[];
/** 세 단이 한 바퀴 열렸다 닫히는 주기(ms) */
duration?: number;
easing?: string;
/** 무대 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const DEFAULT_COLUMNS: SitemapColumn[] = [
{ title: "제품", navLabel: "제품 링크", links: ["기능 소개", "요금 안내", "사용 사례"] },
{ title: "회사", navLabel: "회사 링크", links: ["회사 소개", "채용 정보", "보도 자료"] },
{ title: "지원", navLabel: "지원 링크", links: ["도움말", "문의 창구", "상태 알림"] },
];
const Caret = (): ReactNode => (
<svg className={styles.caret} viewBox="0 0 256 256" width="14" height="14" aria-hidden="true" focusable="false">
<g fill="currentColor">
<path d="m208 96l-80 80l-80-80Z" opacity=".2" />
<path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z" />
</g>
</svg>
);
export default function FooterSitemapMega({
brand = "스튜디오",
legal = "© 2026 스튜디오",
columns = DEFAULT_COLUMNS,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fbf6ee",
}: FooterSitemapMegaProps): ReactNode {
/* aria-expanded 가 열림/닫힘의 유일한 출처다 */
const [open, setOpen] = useState<boolean[]>(columns.map(() => true));
const [loop, setLoop] = useState<boolean>(true);
const btns = useRef<Array<HTMLButtonElement | null>>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const toggle = (i: number): void => {
setLoop(false);
setOpen((prev: boolean[]) => prev.map((v: boolean, k: number) => (k === i ? !v : v)));
};
/* 위아래 화살표로 열 머리 사이를 옮긴다. Enter·Space 는 button 이 기본으로 처리한다 */
const onKey = (e: KeyEvt, i: number): void => {
const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
if (step === 0) { return; }
e.preventDefault();
setLoop(false);
const list = btns.current;
if (!list) { return; }
const next = list[(i + step + list.length) % list.length];
if (next) { next.focus({ preventScroll: true }); }
};
return (
<footer
className={loop ? `${styles.fsm} ${styles.isDemo}` : styles.fsm}
style={vars}
role="contentinfo"
onPointerEnter={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<p className={styles.brand}>{brand}</p>
<div className={styles.cols}>
{columns.map((c: SitemapColumn, i: number) => (
<section className={styles.col} key={c.title}>
<h2 className={styles.head}>
<button
className={styles.btn}
type="button"
aria-expanded={open[i] ? "true" : "false"}
aria-controls={`fsm-p${i + 1}`}
ref={(el: HTMLButtonElement | null) => { if (btns.current) { btns.current[i] = el; } }}
onClick={() => toggle(i)}
onKeyDown={(e: KeyEvt) => onKey(e, i)}
>
<span className={styles.btnT}>{c.title}</span>
<Caret />
</button>
</h2>
<div className={styles.panel} id={`fsm-p${i + 1}`}>
<nav className={styles.list} aria-label={c.navLabel}>
{c.links.map((l: string) => (
<a href="#" key={l}>{l}</a>
))}
</nav>
</div>
</section>
))}
</div>
<p className={styles.legal}>{legal}</p>
</footer>
);
}01 · 多段サイトマップフッター — 計測 累積面積 2.053% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
24コマ中12コマで動きが出て、累積の変化面積は2.053%と控えめです。
.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; }
@keyframes fsmSweep {
0% { grid-template-rows: 0fr; }
8%, 74% { grid-template-rows: 1fr; }
86%, 100% { grid-template-rows: 0fr; }
}
02ニュースレター統合フッター
フッター デザイン css で登録欄は画面でどう開くか
メール欄の横のボタンが押し込まれ、紙飛行機が回る輪の読み込みに変わり、その読み込みがチェック印に入れ替わります。入れ替えはsteps(1, end)の一コマ切り替えなので、二つの絵が薄く重なった中間のコマが生まれません。

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

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

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

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

<div class="stage">
<footer class="lt">
<nav class="lt__tree" aria-label="主要リンク">
<a class="lt__link lt__link--lead" href="#" aria-current="page">作品まとめ</a>
<a class="lt__link lt__link--lead" href="#">記事一覧</a>
<a class="lt__link lt__link--sub" href="#">紹介</a>
<a class="lt__link lt__link--sub" href="#">お問い合わせ</a>
</nav>
<nav class="lt__legal" aria-label="法的なお知らせ">
<a class="lt__fine" href="#">利用規約</a>
<a class="lt__fine" href="#">プライバシーポリシー</a>
<a class="lt__fine" href="#">Cookieの設定</a>
</nav>
</footer>
</div>@use "../../../../site/static/demo-tokens" as *;
// 편 272 푸터 팔레트 — 04·05·06 이 같은 값을 쓴다.
// 대비 실측(WCAG 2.1): ink on amber 11.97:1
// ⛔ 흰 글자 on amber 는 1.44:1 이라 이 무대의 글자는 전부 잉크다
$ft-ink: #1b1a20;
$ft-ink2: #2c2a33;
$ft-paper: #ffffff;
$ft-cream: #fbf6ee;
$ft-stone: #575162;
$ft-mist: #b9b3c4;
$ft-amber: $stage-yellow;
$ft-flame: $stage-orange;
$ft-teal: #479089;
$ft-aqua: #b3dad5;
$duration: $dur-loop; // 링크 넷에 차례로 밑줄이 차는 주기
$easing: $ease-out; // 밑줄은 튕기지 않고 곧게 차오른다
$color: $ft-ink; // 밑줄·글자 색
@include stage($ft-amber);
.lt {
box-sizing: border-box;
width: clamp(248px, 84vw, 392px);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $sp-4;
}
.lt__tree {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $sp-2;
}
// 중요도 계단 — font-size 3단. 큰 것이 주요 링크, 작은 것이 법적 문서
.lt__link {
position: relative;
display: inline-block;
color: $color;
text-decoration: none;
transform: translateX(0);
transition: transform $dur-quick $easing;
font-family: inherit;
}
.lt__link--lead { font-weight: 800; font-size: clamp(20px, 5.4vw, 26px); line-height: 1.15; }
.lt__link--sub { font-weight: 600; font-size: clamp(14px, 3.6vw, 17px); line-height: 1.2; }
// 밑줄은 왼쪽 원점에서 scaleX 로 차오른다 — 폭이 아니라 transform 이라 레이아웃을 다시 계산하지 않는다
.lt__link::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 3px;
border-radius: $r-pill;
background: $color;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 밑줄 왼쪽 채움
transition: transform $dur-quick $easing;
}
// 정적 경로를 먼저 쓴다 — .is-demo 를 떼도 호버·포커스에서 같은 동작이 남는다
.lt__link:hover,
.lt__link:focus-visible { transform: translateX(6px); }
.lt__link:hover::after,
.lt__link:focus-visible::after { transform: scaleX(1); }
// 현재 위치 — aria-current 에 굵기와 점 표시를 둘 다 준다(색·굵기 하나만으로는 약하다)
.lt__link[aria-current="page"] { font-weight: 900; }
.lt__link[aria-current="page"]::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
margin-right: $sp-2;
border-radius: 50%;
background: $color;
vertical-align: middle;
}
.lt__legal {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-1 $sp-4;
}
.lt__fine {
position: relative;
color: $color;
text-decoration: none;
font-weight: 500; font-size: clamp(10px, 2.7vw, 12px); line-height: 1.4;
font-family: inherit;
border-bottom: 1px solid $color;
}
.lt__link:focus-visible,
.lt__fine:focus-visible {
outline: 3px solid $color;
outline-offset: 4px;
}
// ── 자동 재생 ───────────────────────────────────────────────
// 계단 링크 넷에 500ms 씩 차례로 밑줄이 찬다. 시차는 음수 delay 로 —
// animation 단축을 쓰면 delay 가 0 으로 리셋된다
.is-demo .lt__link::after {
animation-name: fillLine;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .lt__link {
animation-name: stepIn;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .lt__link:nth-child(2),
.is-demo .lt__link:nth-child(2)::after { animation-delay: -1.5s; }
.is-demo .lt__link:nth-child(3),
.is-demo .lt__link:nth-child(3)::after { animation-delay: -1s; }
.is-demo .lt__link:nth-child(4),
.is-demo .lt__link:nth-child(4)::after { animation-delay: -.5s; }
// 차오름은 빠르게(0~5%), 물러남은 느리게(16~25%) 둔다.
// poster 는 "앞 장과 가장 크게 달라진 장" 이라 물러남이 더 빠르면 밑줄이 없는 장이 대표가 된다 (272-06 실측)
@keyframes fillLine {
0% { transform: scaleX(0); }
5%, 16% { transform: scaleX(1); }
25%, 100% { transform: scaleX(0); }
}
@keyframes stepIn {
0% { transform: translateX(0); }
5%, 16% { transform: translateX(6px); }
25%, 100% { transform: translateX(0); }
}
@media (max-width: 340px), (max-height: 220px) {
.lt { width: 248px; gap: $sp-3; }
.lt__link--lead { font-size: 19px; }
.lt__link--sub { font-size: 13px; }
.lt__legal { gap: 0 $sp-3; }
.lt__fine { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// .is-demo 쪽 선택자가 (0,3,0) 이라 !important 없이는 진다 (260 실측)
.is-demo .lt__link,
.is-demo .lt__link::after { animation: none !important; }
.lt__link, .lt__link::after { transition: none !important; }
// 움직임을 끊어도 현재 위치의 밑줄은 그대로 차 있다
.lt__link[aria-current="page"]::after { transform: scaleX(1); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lt {
box-sizing: border-box;
width: clamp(248px, 84vw, 392px);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.lt__tree {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.lt__link {
position: relative;
display: inline-block;
color: #1b1a20;
text-decoration: none;
transform: translateX(0);
transition: transform 220ms ease-out;
font-family: inherit;
}
.lt__link--lead {
font-weight: 800;
font-size: clamp(20px, 5.4vw, 26px);
line-height: 1.15;
}
.lt__link--sub {
font-weight: 600;
font-size: clamp(14px, 3.6vw, 17px);
line-height: 1.2;
}
.lt__link::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 3px;
border-radius: 999px;
background: #1b1a20;
transform: scaleX(0);
transform-origin: left center;
transition: transform 220ms ease-out;
}
.lt__link:hover,
.lt__link:focus-visible {
transform: translateX(6px);
}
.lt__link:hover::after,
.lt__link:focus-visible::after {
transform: scaleX(1);
}
.lt__link[aria-current=page] {
font-weight: 900;
}
.lt__link[aria-current=page]::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
margin-right: 8px;
border-radius: 50%;
background: #1b1a20;
vertical-align: middle;
}
.lt__legal {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 16px;
}
.lt__fine {
position: relative;
color: #1b1a20;
text-decoration: none;
font-weight: 500;
font-size: clamp(10px, 2.7vw, 12px);
line-height: 1.4;
font-family: inherit;
border-bottom: 1px solid #1b1a20;
}
.lt__link:focus-visible,
.lt__fine:focus-visible {
outline: 3px solid #1b1a20;
outline-offset: 4px;
}
.is-demo .lt__link::after {
animation-name: fillLine;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .lt__link {
animation-name: stepIn;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .lt__link:nth-child(2),
.is-demo .lt__link:nth-child(2)::after {
animation-delay: -1.5s;
}
.is-demo .lt__link:nth-child(3),
.is-demo .lt__link:nth-child(3)::after {
animation-delay: -1s;
}
.is-demo .lt__link:nth-child(4),
.is-demo .lt__link:nth-child(4)::after {
animation-delay: -0.5s;
}
@keyframes fillLine {
0% {
transform: scaleX(0);
}
5%, 16% {
transform: scaleX(1);
}
25%, 100% {
transform: scaleX(0);
}
}
@keyframes stepIn {
0% {
transform: translateX(0);
}
5%, 16% {
transform: translateX(6px);
}
25%, 100% {
transform: translateX(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.lt {
width: 248px;
gap: 12px;
}
.lt__link--lead {
font-size: 19px;
}
.lt__link--sub {
font-size: 13px;
}
.lt__legal {
gap: 0 12px;
}
.lt__fine {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .lt__link,
.is-demo .lt__link::after {
animation: none !important;
}
.lt__link, .lt__link::after {
transition: none !important;
}
.lt__link[aria-current=page]::after {
transform: scaleX(1);
}
}(function () {
var body = document.body;
var root = document.querySelector('.lt');
var links = Array.prototype.slice.call(root.querySelectorAll('a'));
function hand() { body.classList.remove('is-demo'); } // 손이 닿으면 자동 재생을 끈다
// 계단은 세로 한 줄이라 ↑↓ 가 다음·이전 링크, Home·End 가 처음·끝
root.addEventListener('keydown', function (e) {
var i = links.indexOf(document.activeElement);
var to = -1;
if (e.key === 'ArrowDown') to = (i + 1 + links.length) % links.length;
else if (e.key === 'ArrowUp') to = (i - 1 + links.length) % links.length;
else if (e.key === 'Home') to = 0;
else if (e.key === 'End') to = links.length - 1;
if (to < 0) return;
e.preventDefault();
hand();
links[to].focus({ preventScroll: true });
});
root.addEventListener('pointerdown', hand);
root.addEventListener('pointerover', hand);
root.addEventListener('focusin', hand);
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./FooterLinktreeSteps.module.scss";
/* ambient 에 React 의 KeyboardEvent 가 없어 필요한 것만 로컬로 선언한다 */
type KeyEvt = { key: string; preventDefault(): void };
export type FooterLinktreeStepsProps = {
/** 링크 넷에 차례로 밑줄이 차는 주기(ms) */
duration?: number;
easing?: string;
/** 밑줄·글자 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/* 중요도 계단 — lead 가 주요 링크, sub 가 보조 링크, fine 이 법적 문서 */
const TREE: { text: string; lead: boolean; current?: boolean }[] = [
{ text: "작업 모음", lead: true, current: true },
{ text: "글 목록", lead: true },
{ text: "소개", lead: false },
{ text: "문의하기", lead: false },
];
const LEGAL = ["이용약관", "개인정보 처리방침", "쿠키 설정"];
export default function FooterLinktreeSteps({
duration = 2000,
easing = "ease-out",
color = "#1b1a20",
}: FooterLinktreeStepsProps): ReactNode {
const [demo, setDemo] = useState(true); // 손이 닿으면 자동 재생을 끈다
const rootRef = useRef<HTMLElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
/* 계단은 세로 한 줄이라 ↑↓ 가 다음·이전 링크, Home·End 가 처음·끝 */
const onKey = (e: KeyEvt) => {
const root = rootRef.current;
if (!root) return;
const list = root.querySelectorAll("a");
const now = Array.prototype.indexOf.call(list, document.activeElement);
let to = -1;
if (e.key === "ArrowDown") to = (now + 1 + list.length) % list.length;
else if (e.key === "ArrowUp") to = (now - 1 + list.length) % list.length;
else if (e.key === "Home") to = 0;
else if (e.key === "End") to = list.length - 1;
if (to < 0) return;
e.preventDefault();
setDemo(false);
const el = list[to] as HTMLElement | undefined;
if (el) el.focus({ preventScroll: true });
};
return (
<footer
ref={rootRef}
className={`${styles.lt} ${demo ? styles.isDemo : ""}`}
style={vars}
onKeyDown={onKey}
onPointerDown={() => setDemo(false)}
onPointerOver={() => setDemo(false)}
>
<nav className={styles.tree} aria-label="주요 링크">
{TREE.map((l) => (
<a
key={l.text}
className={`${styles.link} ${l.lead ? styles.lead : styles.sub}`}
href="#"
aria-current={l.current ? "page" : undefined}
>
{l.text}
</a>
))}
</nav>
<nav className={styles.legal} aria-label="법적 고지">
{LEGAL.map((t) => (
<a key={t} className={styles.fine} href="#">{t}</a>
))}
</nav>
</footer>
);
}06 · リーガル・リンクツリーフッター — 計測 累積面積 3.451% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
累積の変化面積は3.451%、動いたコマは24コマ中19です。
.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); }
07連絡先・地図カードフッター
何が見えるか
地図は画像ファイルではなくrepeating-linear-gradientを三枚重ねた道路の模様で、外部への読み込みが一件もありません。そこへピンが落ちて一度跳ね、輪が広がり、横の住所・電話・メールのカードが左から順に姿を現します。

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

<div class="stage">
<!-- footer 가 body 계통에 있으므로 암묵적 role 은 contentinfo 다 -->
<footer class="wv">
<div class="wv__body">
<p class="wv__lead">本文はここで終わります</p>
</div>
<!-- 경계 — 물결 두 겹. 모양은 SVG path 를 data URI 로 깐 배경이고, 타일 폭은 120px 이다 -->
<div class="wv__edge" aria-hidden="true">
<span class="wv__wave wv__wave--back"></span>
<span class="wv__wave wv__wave--front"></span>
</div>
<div class="wv__foot">
<div class="wv__row">
<nav class="wv__nav" aria-label="フッターのリンク">
<a class="wv__link" href="#">紹介</a>
<a class="wv__link" href="#">記事一覧</a>
<a class="wv__link" href="#">お問い合わせ</a>
</nav>
<!-- 움직임을 끄는 스위치. 라벨 두 개를 다 심어 두고 aria-pressed 로 갈라 보인다 -->
<button class="wv__toggle" type="button" aria-pressed="false">
<span class="wv__t wv__t--stop">波を止める</span>
<span class="wv__t wv__t--play">波を動かす</span>
</button>
</div>
<p class="wv__copy">© 2026 サンプルサイト</p>
</div>
</footer>
</div>@use "../../../../site/static/demo-tokens" as *;
// 272 푸터 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실제 대비비(WCAG 2.1 1.4.3)
$ft-ink: #1b1a20; // 어두운 푸터 바닥
$ft-ink2: #2c2a33;
$ft-paper: $stage-paper; // 잉크 위 17.28:1
$ft-cream: #fbf6ee; // 밝은 본문
$ft-stone: #575162; // 크림 위 7.08:1
$ft-mist: #b9b3c4; // 잉크 위 8.48:1 — 뒷물결의 옅은 색이기도 하다
$ft-amber: $stage-yellow;
$ft-flame: $stage-orange;
$ft-teal: #479089;
$ft-aqua: #b3dad5; // 잉크 위 11.58:1
$duration: $dur-loop; // 앞물결이 한 벌(480px = 타일 4장)을 지나는 시간
$easing: linear; // 흐르는 물결은 등속이어야 이음매가 안 보인다
$color: $ft-ink; // 앞물결 = 푸터 바닥과 같은 색이라 경계가 이어진다
// 물결 모양은 SVG path 를 data URI 로 깐 배경이다 (외부 요청 0).
// 타일은 120×28, 양 끝의 접선 기울기가 같아 옆으로 붙여도 이음매가 없다.
// 색은 URL 안에 들어가므로 # 을 %23 으로 인코딩한 문자열로 둔다
$w-front: "%231b1a20"; // = $ft-ink
$w-back: "%23b9b3c4"; // = $ft-mist
@include stage($ft-ink);
.wv {
box-sizing: border-box;
width: clamp(288px, 94vw, 432px);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
// 위 — 밝은 본문
.wv__body {
padding: $sp-4;
background: $ft-cream;
text-align: center;
}
.wv__lead {
margin: 0;
color: $ft-stone;
font-weight: 600; font-size: clamp(11px, 2.6vw, 13px); line-height: 1.3;
font-family: inherit;
}
// 경계 — 두 겹이 서로 다른 속도로 흐른다. 상자는 크림이고 물결이 잉크를 끌어올린다
.wv__edge {
position: relative;
height: 28px;
overflow: hidden;
background: $ft-cream;
}
.wv__wave {
position: absolute;
top: 0;
left: 0;
display: block;
width: 960px; // 한 벌 480px(타일 4장) × 2 — 정확히 두 벌이라 반 바퀴가 한 벌이다
height: 28px;
background-repeat: repeat-x;
background-size: 120px 28px; // 타일을 먼저 못 박는다
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-duration: $duration;
}
// 뒷겹 — 옅고 느리다. 한 주기에 타일 2장(240px)만 간다
.wv__wave--back {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='28' viewBox='0 0 120 28' preserveAspectRatio='none'%3E%3Cpath d='M0 12C20 2 40 2 60 12S100 22 120 12V28H0Z' fill='#{$w-back}'/%3E%3C/svg%3E");
animation-name: waveBack;
}
// 앞겹 — 진하고 빠르다. 한 주기에 타일 4장(480px). 둘 다 타일 폭의 정수배라 텔레포트가 없다
.wv__wave--front {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='28' viewBox='0 0 120 28' preserveAspectRatio='none'%3E%3Cpath d='M0 14C15 0 45 0 60 14S105 28 120 14V28H0Z' fill='#{$w-front}'/%3E%3C/svg%3E");
animation-name: waveFront;
}
// 아래 — 어두운 푸터
.wv__foot {
padding: $sp-3 $sp-4 $sp-4;
background: $color;
}
.wv__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
flex-wrap: wrap;
}
.wv__nav { display: flex; gap: $sp-3; flex-wrap: wrap; }
.wv__link {
color: $ft-paper; // 잉크 위 17.28:1
text-decoration: none;
font-weight: 600; font-size: clamp(11px, 2.6vw, 12px); line-height: 1.4;
}
.wv__link:hover { color: $ft-aqua; } // 잉크 위 11.58:1
.wv__link:focus-visible { outline: 2px solid $ft-aqua; outline-offset: 3px; border-radius: $r-xs; }
.wv__toggle {
flex: 0 0 auto;
padding: $sp-1 $sp-2;
border: 1px solid $ft-mist;
border-radius: $r-pill;
background: $ft-ink2;
color: $ft-mist; // 잉크2 위 8.1:1
cursor: pointer;
font-weight: 600; font-size: 11px; line-height: 1.3;
font-family: inherit;
}
.wv__toggle:focus-visible { outline: 2px solid $ft-aqua; outline-offset: 2px; }
.wv__toggle[aria-pressed="false"] .wv__t--play { display: none; }
.wv__toggle[aria-pressed="true"] .wv__t--stop { display: none; }
.wv__copy {
margin: $sp-2 0 0;
color: $ft-mist; // 잉크 위 8.48:1
font-weight: 500; font-size: 10px; line-height: 1.4;
font-family: inherit;
}
// 이동량은 타일 폭(120px)의 정수배 — 한 바퀴 뒤 무늬가 픽셀까지 같아 이음매가 없다
@keyframes waveBack { from { transform: translateX(0); } to { transform: translateX(-240px); } }
@keyframes waveFront { from { transform: translateX(0); } to { transform: translateX(-480px); } }
// 스위치로 멈춘 상태 — 움직임만 멈추고 모양은 그대로다
.wv.is-paused .wv__wave { animation-play-state: paused; }
@media (max-width: 340px), (max-height: 220px) {
.wv { width: 276px; }
.wv__body { padding: $sp-2; }
.wv__edge { height: 20px; }
.wv__wave { height: 20px; background-size: 120px 20px; }
.wv__foot { padding: $sp-2; }
.wv__nav { gap: $sp-2; }
.wv__link { font-size: 11px; }
.wv__toggle { font-size: 10px; }
.wv__copy { margin: $sp-1 0 0; font-size: 9px; }
}
// .wv.is-paused .wv__wave 처럼 특이도가 올라간 규칙이 있으므로 !important 로 끈다
@media (prefers-reduced-motion: reduce) {
.wv__wave { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1b1a20;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wv {
box-sizing: border-box;
width: clamp(288px, 94vw, 432px);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.wv__body {
padding: 16px;
background: #fbf6ee;
text-align: center;
}
.wv__lead {
margin: 0;
color: #575162;
font-weight: 600;
font-size: clamp(11px, 2.6vw, 13px);
line-height: 1.3;
font-family: inherit;
}
.wv__edge {
position: relative;
height: 28px;
overflow: hidden;
background: #fbf6ee;
}
.wv__wave {
position: absolute;
top: 0;
left: 0;
display: block;
width: 960px;
height: 28px;
background-repeat: repeat-x;
background-size: 120px 28px;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-duration: 2s;
}
.wv__wave--back {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='28' viewBox='0 0 120 28' preserveAspectRatio='none'%3E%3Cpath d='M0 12C20 2 40 2 60 12S100 22 120 12V28H0Z' fill='%23b9b3c4'/%3E%3C/svg%3E");
animation-name: waveBack;
}
.wv__wave--front {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='28' viewBox='0 0 120 28' preserveAspectRatio='none'%3E%3Cpath d='M0 14C15 0 45 0 60 14S105 28 120 14V28H0Z' fill='%231b1a20'/%3E%3C/svg%3E");
animation-name: waveFront;
}
.wv__foot {
padding: 12px 16px 16px;
background: #1b1a20;
}
.wv__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.wv__nav {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.wv__link {
color: #ffffff;
text-decoration: none;
font-weight: 600;
font-size: clamp(11px, 2.6vw, 12px);
line-height: 1.4;
}
.wv__link:hover {
color: #b3dad5;
}
.wv__link:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 3px;
border-radius: 4px;
}
.wv__toggle {
flex: 0 0 auto;
padding: 4px 8px;
border: 1px solid #b9b3c4;
border-radius: 999px;
background: #2c2a33;
color: #b9b3c4;
cursor: pointer;
font-weight: 600;
font-size: 11px;
line-height: 1.3;
font-family: inherit;
}
.wv__toggle:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 2px;
}
.wv__toggle[aria-pressed=false] .wv__t--play {
display: none;
}
.wv__toggle[aria-pressed=true] .wv__t--stop {
display: none;
}
.wv__copy {
margin: 8px 0 0;
color: #b9b3c4;
font-weight: 500;
font-size: 10px;
line-height: 1.4;
font-family: inherit;
}
@keyframes waveBack {
from {
transform: translateX(0);
}
to {
transform: translateX(-240px);
}
}
@keyframes waveFront {
from {
transform: translateX(0);
}
to {
transform: translateX(-480px);
}
}
.wv.is-paused .wv__wave {
animation-play-state: paused;
}
@media (max-width: 340px), (max-height: 220px) {
.wv {
width: 276px;
}
.wv__body {
padding: 8px;
}
.wv__edge {
height: 20px;
}
.wv__wave {
height: 20px;
background-size: 120px 20px;
}
.wv__foot {
padding: 8px;
}
.wv__nav {
gap: 8px;
}
.wv__link {
font-size: 11px;
}
.wv__toggle {
font-size: 10px;
}
.wv__copy {
margin: 4px 0 0;
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.wv__wave {
animation: none !important;
}
}(function () {
var wv = document.querySelector('.wv');
var btn = document.querySelector('.wv__toggle');
// button 이라 클릭·터치·Enter·Space 가 모두 click 으로 온다
btn.addEventListener('click', function () {
var isPaused = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', isPaused ? 'false' : 'true');
wv.classList.toggle('is-paused', !isPaused);
});
})();import { useCallback, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./WaveTopFooter.module.scss";
export type WaveTopFooterProps = {
/** 앞물결이 한 벌(480px = 타일 4장)을 지나는 시간(ms) */
duration?: number;
/** 흐르는 물결은 등속이어야 이음매가 안 보인다 */
easing?: string;
/** 푸터 바닥 색 — 앞물결과 같은 색이라 경계가 이어진다 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function WaveTopFooter({
duration = 2000,
easing = "linear",
color = "#1b1a20",
}: WaveTopFooterProps): ReactNode {
const [paused, setPaused] = useState(false);
// button 이라 클릭·터치·Enter·Space 가 모두 onClick 으로 온다
const toggleWave = useCallback((): void => setPaused((p) => !p), []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<footer className={`${styles.wv} ${paused ? styles.isPaused : ""}`} style={vars}>
<div className={styles.body}>
<p className={styles.lead}>본문은 여기서 끝납니다</p>
</div>
{/* 경계 — 물결 두 겹. 모양은 SVG path 를 data URI 로 깐 배경이고, 타일 폭은 120px 이다 */}
<div className={styles.edge} aria-hidden="true">
<span className={`${styles.wave} ${styles.waveBack}`} />
<span className={`${styles.wave} ${styles.waveFront}`} />
</div>
<div className={styles.foot}>
<div className={styles.row}>
<nav className={styles.nav} aria-label="푸터 링크">
<a className={styles.link} href="#">소개</a>
<a className={styles.link} href="#">글 목록</a>
<a className={styles.link} href="#">문의</a>
</nav>
<button className={styles.toggle} type="button" aria-pressed={paused} onClick={toggleWave}>
{paused ? "물결 재생" : "물결 멈춤"}
</button>
</div>
<p className={styles.copy}>© 2026 예시 사이트</p>
</div>
</footer>
);
}08 · ウェーブ境界フッター — 計測 累積面積 6.332% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
動いたコマは24コマ中23と九つの中で最多、平均の強度も149.1で最も高い値です。
.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;
}
@keyframes waveBack { from { transform: translateX(0); } to { transform: translateX(-240px); } }
@keyframes waveFront { from { transform: translateX(0); } to { transform: translateX(-480px); } }
.wv.is-paused .wv__wave { animation-play-state: paused; }
09ダークインクのコントラストフッター
どんな動きか
明るい本文のカードが暗い床へ降り、少しだけ跳ね返ってから落ち着きます。床の上の4つのリンクには0.18秒ずつずれてアクア色の下線が走り、この下線は自動再生を外してもマウスとキーボードの両方でそのまま出ます。

<div class="stage">
<!-- footer 가 body 계통에 있으므로 암묵적 role 은 contentinfo 다 -->
<footer class="ic">
<!-- 밝은 본문 카드가 잉크 바닥 위로 내려앉으며 페이지가 닫힌다 -->
<div class="ic__sheet">
<p class="ic__eyebrow">本文の終わり</p>
<h2 class="ic__head" tabindex="-1">お読みいただきありがとうございます</h2>
</div>
<div class="ic__floor">
<nav class="ic__nav" aria-label="フッターのリンク">
<a class="ic__link" href="#"><span class="ic__lt">紹介</span><span class="ic__rule" aria-hidden="true"></span></a>
<a class="ic__link" href="#"><span class="ic__lt">記事一覧</span><span class="ic__rule" aria-hidden="true"></span></a>
<a class="ic__link" href="#"><span class="ic__lt">お問い合わせ</span><span class="ic__rule" aria-hidden="true"></span></a>
<a class="ic__link" href="#"><span class="ic__lt">プライバシー</span><span class="ic__rule" aria-hidden="true"></span></a>
</nav>
<div class="ic__bottom">
<p class="ic__copy">© 2026 サンプルサイト</p>
<button class="ic__top" type="button">
<span class="ic__ic" aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M200 112H56l72-72Z" opacity=".2"/><path d="m205.66 106.34l-72-72a8 8 0 0 0-11.32 0l-72 72A8 8 0 0 0 56 120h64v96a8 8 0 0 0 16 0v-96h64a8 8 0 0 0 5.66-13.66M75.31 104L128 51.31L180.69 104Z"/></g></svg>
</span>
先頭へ
</button>
</div>
</div>
</footer>
</div>@use "../../../../site/static/demo-tokens" as *;
// 272 푸터 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실제 대비비(WCAG 2.1 1.4.3)
$ft-ink: #1b1a20; // 다크 마감 바닥 = 무대
$ft-ink2: #2c2a33; // 잉크 위 구분선·면
$ft-paper: $stage-paper; // 잉크 위 17.28:1
$ft-cream: #fbf6ee; // 밝은 본문 카드
$ft-stone: #575162; // 크림 위 7.08:1
$ft-mist: #b9b3c4; // 잉크 위 8.48:1
$ft-amber: $stage-yellow; // 잉크 위 11.97:1
$ft-flame: $stage-orange;
$ft-teal: #479089;
$ft-aqua: #b3dad5; // 잉크 위 11.58:1 — 밑줄 색
$duration: $dur-loop; // 카드가 내려앉고 밑줄 넷이 차례로 스캔되는 한 주기
$easing: $ease-spring;
$color: $ft-aqua; // 밑줄·초점 테두리 색
@include stage($ft-ink);
.ic {
--lift: -20px; // 본문 카드가 내려앉기 시작하는 높이
box-sizing: border-box;
width: clamp(288px, 94vw, 432px);
}
// 밝은 본문 — 잉크 위에서 유일하게 밝은 면이라 대비가 세다 (크림 위 잉크 글자 16.06:1)
.ic__sheet {
padding: $sp-4;
border-radius: $r-card;
background: $ft-cream;
text-align: center;
box-shadow: $shadow-raised;
}
.ic__eyebrow {
margin: 0 0 $sp-1;
color: $ft-stone; // 크림 위 7.08:1
font-weight: 700; font-size: 10px; line-height: 1.2; letter-spacing: .08em;
font-family: inherit;
}
.ic__head {
margin: 0;
color: $ft-ink; // 크림 위 16.06:1
font-weight: 800; font-size: clamp(14px, 3.2vw, 17px); line-height: 1.25;
font-family: inherit;
}
.ic__head:focus-visible { outline: 3px solid $ft-teal; outline-offset: 4px; }
// 잉크 바닥 — 무대와 같은 색이라 카드가 그대로 바닥에 내려앉아 보인다
.ic__floor {
padding: $sp-4 $sp-2 0;
}
.ic__nav {
display: flex;
flex-wrap: wrap;
gap: $sp-2 $sp-5;
}
.ic__link {
position: relative;
display: inline-block;
padding-bottom: $sp-1;
color: $ft-paper; // 잉크 위 17.28:1
text-decoration: none;
font-weight: 700; font-size: clamp(12px, 2.8vw, 13px); line-height: 1.3;
}
.ic__link:focus-visible { outline: 2px solid $color; outline-offset: 3px; border-radius: $r-xs; }
// 밑줄 — 기본은 접혀 있고 손을 얹거나 초점이 오면 왼쪽에서 자란다.
// .is-demo 는 이 규칙 위에 스캔 연출을 덧씌울 뿐이다
.ic__rule {
position: absolute;
left: 0;
bottom: 0;
display: block;
width: 100%;
height: 2px;
background: $color;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 밑줄 왼쪽 채움
transition: transform $dur-quick $easing;
}
.ic__link:hover .ic__rule,
.ic__link:focus-visible .ic__rule { transform: scaleX(1); }
.ic__bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
flex-wrap: wrap;
margin: $sp-3 0 0;
padding: $sp-2 0 0;
border-top: 1px solid $ft-ink2;
}
.ic__copy {
margin: 0;
color: $ft-mist; // 잉크 위 8.48:1
font-weight: 500; font-size: 10px; line-height: 1.4;
font-family: inherit;
}
.ic__top {
display: inline-flex;
align-items: center;
gap: $sp-1;
flex: 0 0 auto;
padding: $sp-1 $sp-2;
border: 1px solid $ft-ink2;
border-radius: $r-pill;
background: $ft-ink2;
color: $ft-mist;
cursor: pointer;
font-weight: 600; font-size: 10px; line-height: 1.3;
font-family: inherit;
}
.ic__top:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ic__ic { display: flex; }
// 자동 재생 — 카드가 내려앉고, 밑줄 넷이 시차로 스캔된다.
// animation: 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로 쓰고 fill-mode: backwards 를 함께 준다
.ic.is-demo .ic__sheet {
animation-name: sheetDrop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ic.is-demo .ic__rule {
animation-name: ruleScan;
animation-duration: $duration;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ic.is-demo .ic__link:nth-child(2) .ic__rule { animation-delay: 180ms; }
.ic.is-demo .ic__link:nth-child(3) .ic__rule { animation-delay: 360ms; }
.ic.is-demo .ic__link:nth-child(4) .ic__rule { animation-delay: 540ms; }
@keyframes sheetDrop {
0% { transform: translateY(var(--lift)); }
22% { transform: translateY(0); }
30% { transform: translateY(-3px); }
38%, 100% { transform: translateY(0); }
}
// 복귀는 90% 안에 끝낸다 — 24프레임의 마지막 장은 95.8% 지점이라 그 뒤는 샘플이 없다
@keyframes ruleScan {
0% { transform: scaleX(0); }
24%, 60% { transform: scaleX(1); }
84%, 100% { transform: scaleX(0); }
}
@media (max-width: 340px), (max-height: 220px) {
.ic { --lift: -14px; width: 276px; }
.ic__sheet { padding: $sp-2; }
.ic__head { font-size: 13px; }
.ic__floor { padding: $sp-2 0 0; }
.ic__nav { gap: $sp-1 $sp-3; }
.ic__link { font-size: 12px; }
.ic__bottom { margin: $sp-2 0 0; padding: $sp-1 0 0; }
.ic__copy { font-size: 9px; }
.ic__top { font-size: 9px; }
}
// .ic.is-demo .ic__rule 은 (0,3,0) 이라 @media 안의 낮은 특이도로는 못 이긴다 — !important 로 끈다
@media (prefers-reduced-motion: reduce) {
.ic__sheet { animation: none !important; transform: none !important; }
.ic__rule { animation: none !important; transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1b1a20;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ic {
--lift: -20px;
box-sizing: border-box;
width: clamp(288px, 94vw, 432px);
}
.ic__sheet {
padding: 16px;
border-radius: 16px;
background: #fbf6ee;
text-align: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ic__eyebrow {
margin: 0 0 4px;
color: #575162;
font-weight: 700;
font-size: 10px;
line-height: 1.2;
letter-spacing: 0.08em;
font-family: inherit;
}
.ic__head {
margin: 0;
color: #1b1a20;
font-weight: 800;
font-size: clamp(14px, 3.2vw, 17px);
line-height: 1.25;
font-family: inherit;
}
.ic__head:focus-visible {
outline: 3px solid #479089;
outline-offset: 4px;
}
.ic__floor {
padding: 16px 8px 0;
}
.ic__nav {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
}
.ic__link {
position: relative;
display: inline-block;
padding-bottom: 4px;
color: #ffffff;
text-decoration: none;
font-weight: 700;
font-size: clamp(12px, 2.8vw, 13px);
line-height: 1.3;
}
.ic__link:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 3px;
border-radius: 4px;
}
.ic__rule {
position: absolute;
left: 0;
bottom: 0;
display: block;
width: 100%;
height: 2px;
background: #b3dad5;
transform: scaleX(0);
transform-origin: left center;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ic__link:hover .ic__rule,
.ic__link:focus-visible .ic__rule {
transform: scaleX(1);
}
.ic__bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
margin: 12px 0 0;
padding: 8px 0 0;
border-top: 1px solid #2c2a33;
}
.ic__copy {
margin: 0;
color: #b9b3c4;
font-weight: 500;
font-size: 10px;
line-height: 1.4;
font-family: inherit;
}
.ic__top {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
padding: 4px 8px;
border: 1px solid #2c2a33;
border-radius: 999px;
background: #2c2a33;
color: #b9b3c4;
cursor: pointer;
font-weight: 600;
font-size: 10px;
line-height: 1.3;
font-family: inherit;
}
.ic__top:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 2px;
}
.ic__ic {
display: flex;
}
.ic.is-demo .ic__sheet {
animation-name: sheetDrop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ic.is-demo .ic__rule {
animation-name: ruleScan;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ic.is-demo .ic__link:nth-child(2) .ic__rule {
animation-delay: 180ms;
}
.ic.is-demo .ic__link:nth-child(3) .ic__rule {
animation-delay: 360ms;
}
.ic.is-demo .ic__link:nth-child(4) .ic__rule {
animation-delay: 540ms;
}
@keyframes sheetDrop {
0% {
transform: translateY(var(--lift));
}
22% {
transform: translateY(0);
}
30% {
transform: translateY(-3px);
}
38%, 100% {
transform: translateY(0);
}
}
@keyframes ruleScan {
0% {
transform: scaleX(0);
}
24%, 60% {
transform: scaleX(1);
}
84%, 100% {
transform: scaleX(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.ic {
--lift: -14px;
width: 276px;
}
.ic__sheet {
padding: 8px;
}
.ic__head {
font-size: 13px;
}
.ic__floor {
padding: 8px 0 0;
}
.ic__nav {
gap: 4px 12px;
}
.ic__link {
font-size: 12px;
}
.ic__bottom {
margin: 8px 0 0;
padding: 4px 0 0;
}
.ic__copy {
font-size: 9px;
}
.ic__top {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.ic__sheet {
animation: none !important;
transform: none !important;
}
.ic__rule {
animation: none !important;
transition: none !important;
}
}(function () {
var ic = document.querySelector('.ic');
var headEl = document.querySelector('.ic__head');
var topBtn = document.querySelector('.ic__top');
// 격자 미리보기용 자동 재생. 사람이 손을 대면 떼고 진짜 조작으로 넘긴다
ic.classList.add('is-demo');
function stopDemo() { ic.classList.remove('is-demo'); }
['pointerdown', 'keydown'].forEach(function (t) {
document.addEventListener(t, stopDemo, { once: true });
});
// 버튼을 누르면 스크롤을 되돌리고 초점도 문서 처음으로 옮긴다(키보드 사용자는 초점이 따라가야 한다).
// focus() 에는 반드시 preventScroll: 이 데모는 목록에 iframe 으로 박혀 있어 부모 페이지가 끌려간다
topBtn.addEventListener('click', function () {
window.scrollTo({ top: 0, behavior: 'smooth' });
headEl.focus({ preventScroll: true });
});
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./InkContrastFooter.module.scss";
export type InkContrastFooterProps = {
/** 한 주기(ms) — 카드가 내려앉고 밑줄 넷이 차례로 스캔되는 시간 */
duration?: number;
easing?: string;
/** 밑줄·초점 테두리 색 — 잉크 위 11.58:1 인 아쿠아가 기본 */
color?: string;
/** 푸터 링크 이름 4개 */
links?: string[];
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** ambient 에 HTMLHeadingElement 초점 호출용 타입만 로컬로 둔다 */
type FocusTarget = { focus(o: { preventScroll: boolean }): void };
export default function InkContrastFooter({
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#6ee7d7",
links = ["소개", "글 목록", "문의", "개인정보"],
}: InkContrastFooterProps): ReactNode {
const [demo, setDemo] = useState(true);
const headRef = useRef<FocusTarget>(null);
// 격자 미리보기용 자동 재생. 사람이 손을 대면 떼고 진짜 조작으로 넘긴다
useEffect(() => {
const off = (): void => setDemo(false);
document.addEventListener("pointerdown", off, { once: true });
document.addEventListener("keydown", off, { once: true });
return () => {
document.removeEventListener("pointerdown", off);
document.removeEventListener("keydown", off);
};
}, []);
// 맨 위로 — 스크롤을 되돌리고 초점도 문서 맨 위로 옮긴다.
// focus() 에는 반드시 preventScroll: 데모가 목록에 iframe 으로 박히면 부모 페이지가 끌려간다
const goTop = useCallback((): void => {
window.scrollTo({ top: 0, behavior: "smooth" });
if (headRef.current) headRef.current.focus({ preventScroll: true });
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<footer className={`${styles.ic} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.sheet}>
<p className={styles.eyebrow}>본문 끝</p>
<h2 className={styles.head} tabIndex={-1} ref={headRef}>읽어 주셔서 고맙습니다</h2>
</div>
<div className={styles.floor}>
<nav className={styles.nav} aria-label="푸터 링크">
{links.map((t) => (
<a key={t} className={styles.link} href="#">
<span className={styles.lt}>{t}</span>
<span className={styles.rule} aria-hidden="true" />
</a>
))}
</nav>
<div className={styles.bottom}>
<p className={styles.copy}>© 2026 예시 사이트</p>
<button className={styles.top} type="button" onClick={goTop}>
<span className={styles.ic2} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M200 112H56l72-72Z" opacity=".2" />
<path d="m205.66 106.34l-72-72a8 8 0 0 0-11.32 0l-72 72A8 8 0 0 0 56 120h64v96a8 8 0 0 0 16 0v-96h64a8 8 0 0 0 5.66-13.66M75.31 104L128 51.31L180.69 104Z" />
</g>
</svg>
</span>
맨 위로
</button>
</div>
</div>
</footer>
);
}09 · ダークインクのコントラストフッター — 計測 累積面積 14.908% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
累積の変化面積は14.908%、1コマあたりの最大変化は10.526%で九つの中で最も大きな一撃です。
.ic__rule {
position: absolute;
left: 0;
bottom: 0;
display: block;
width: 100%;
height: 2px;
background: $color;
transform: scaleX(0);
transform-origin: left center;
transition: transform $dur-quick $easing;
}
.ic__link:hover .ic__rule,
.ic__link:focus-visible .ic__rule { transform: scaleX(1); }
@keyframes ruleScan {
0% { transform: scaleX(0); }
24%, 60% { transform: scaleX(1); }
84%, 100% { transform: scaleX(0); }
}
どこで壊れるか — 落とし穴
九つを仕上げるまでに、同じ失敗を四つの形で踏みました。
一つ目は、スマホ幅に残された縦の余地です。320×200で描かせると実際に使える縦は「200 − 舞台の余白12.8×2 = 174.4px」しかありません。01は最初、各列のリンクを縦に積む作りにしていて176.4pxになり、2pxだけはみ出しました。狭い画面では縦に積むという定番の処方が、ここではフッターを長くする方向に働きます。リンクの並びをflex-direction: row; flex-wrap: wrapで横に寝かせ、余白を詰めて160pxまで下げて収めました。狭い画面向けのメディアクエリの中では、値を上げずに下げるだけにするのが安全です。
二つ目は、代表画像の選ばれ方です。poster は「直前のコマから一番大きく変わったコマ」なので、登場より退場のほうが勝ちやすくなります。07の連絡先カードは当初opacityで出していましたが、cubic-bezier(.2,.8,.2,1)は区間の30%地点ですでにopacityが0.827まで上がるため、24コマのサンプルに半透明の幽霊が必ず紛れ込みます。さらにカードが出るコマの変化面積14,625pxが、次のコマの13,669pxに勝ってしまい、3枚目のカードがまだないコマが代表に選ばれました。透明度をやめてclip-path: inset()で左から現す形にすると変化がコマごとに均され、カード3枚とピンと輪がそろったコマが代表になりました。06も同じ問題で、下線が満ちるのに9%・引くのに5%かけていた配分を5%・9%へ逆転させ、下線のあるコマが勝つようにしています。
三つ目は、継ぎ目のない波です。08のトラック幅は Playwright のoffsetWidthで測って960px、波のタイルは120px、一組が480pxでタイルちょうど4枚にあたります。手前の層は−480px、奥の層は−240pxと、どちらもタイルの整数倍だけ動かしました。translateXが0・−480・−240のときのスクリーンショットのハッシュは完全に一致し、−479pxのときだけ違いました — 一周してもピクセルがずれない証拠です。
四つ目は、アクティブ表示が移動する最中に枠がまるごと消えた件です。04では当初アクティブな文字色をピルと同じ色にしていたため、ピルが隣へ移るコマでその枠の文字が背景に溶けました。ピルの到着時刻もキーフレームの終わりではありません。cubic-bezier(.2,1.4,.4,1)は行き過ぎてから戻るので、Playwright の実測では[46,58]%の区間の52%ですでに到着していました。ピルの幅も当てずっぽうではなくグリッドに決めさせています。nav が312pxなら一枠は78px、ピルも78px、タブのx座標は84・162・240・318で、きれいに一致しました。リンクの代わりにウィジェットを載せるフッターなら、ウィジェット単体のUI部品に同じ174pxの枠で裁断した部品が並んでいます。
アクセシビリティ
暗い床に明るい文字を置く配色は、それ自体がコントラストの試験です。九つで使った組み合わせを WCAG 2.1 の相対輝度の式でそのまま計算した結果が次の表で、最後の2行は落ちたので本文には使っていません。
| 前景 | 背景 | 実測コントラスト | 本文の基準 |
|---|---|---|---|
| 白 #ffffff | インク #1b1a20 | 17.28:1 | 合格 |
| アンバー #ffd23f | インク #1b1a20 | 11.97:1 | 合格 |
| アクア #6ee7d7 | インク #1b1a20 | 11.58:1 | 合格 |
| ミスト #b9b3c4 | インク #1b1a20 | 8.48:1 | 合格 |
| インク #1b1a20 | クリーム #fbf6ee | 16.06:1 | 合格 |
| ストーン #575162 | クリーム #fbf6ee | 7.08:1 | 合格 |
| 白 #ffffff | ティール #0b7269 | 5.79:1 | 合格 |
| インク #1b1a20 | オレンジ #ff4d1f | 5.21:1 | 合格 |
| 白 #ffffff | オレンジ #ff4d1f | 3.32:1 | 不足 — 使わない |
| 白 #ffffff | アンバー #ffd23f | 1.44:1 | 不足 — 使わない |
prefers-reduced-motion: reduceでは、九つとも飾りの動きだけを止めて、状態と情報のほうは残します。01はアコーディオンの開閉アニメーションを切ってもaria-expandedが開閉の唯一の出どころとして残り、02は紙飛行機と読み込みとチェック印の切り替えを止めても、どの絵を見せるかは根元のクラス一つが決めているので結果表示は消えません。03は点の浮き沈みがなくなるだけで、ホバーとフォーカスの色変化は指定ごと残ります。04はピルの移動を切ってもアクティブな枠の文字がインクのまま見えます。05は浮き沈みを切っても隅の「開く」印がマウスとキーボードで出せ、06は現在地の下線がscaleX(1)のまま満ちた状態で止まります。07はピンと輪とカードを止めたうえでopacity: 1に戻すので、地図も連絡先も欠けません。08は波の流れが止まるだけで曲線の形はそのまま、09はカードの落下と下線の走りが消えても配色は動きません。ここまでの切り替えを含む九つ分のSCSSは、7g7q5d5zを入れて開く zip にそのまま入っています。
支えている仕掛けは派手ではありません。九つともフッターの一番外側は<footer>で、01・02・03にはrole="contentinfo"も明示してあります。リンクのまとまりには<nav aria-label>で名前を付け(01は製品・会社・サポートの三つ)、01のアコーディオンはaria-expanded、04と06の現在地はaria-current、02の申込結果はrole="status"とaria-live="polite"、08の一時停止スイッチはaria-pressedで伝えます。キーボードで触れる要素にはすべて:focus-visibleの輪郭を付けてあり、04ではアクア色のピルの上でも見えるように白い輪郭にしました。根拠はMDN の grid-template-rowsとMDN の prefers-reduced-motion、コントラストの基準はW3C の Understanding Contrast (Minimum)です。
ほかの区画のひな形はテンプレート一覧にまとまっています。このサイトを作っている人のことはサイト紹介に書きました。
FAQ
フッターの一番外側は<footer>だけで十分ですか
本文やセクションの外側に置いた<footer>は、それだけで contentinfo というランドマークになります。この九つでは念のため01・02・03にrole="contentinfo"も書き足しましたが、<article>や<section>の内側に入れるとランドマーク扱いが消えるので、置き場所のほうに気を配ってください。リンクではなく外から持ってきた中身を埋める道は、埋め込みウィジェット カードに別途まとめてあります。
波(08)の継ぎ目は本当に見えませんか
見えません。トラック幅960pxに対して波のタイルは120px、一周で動かす量は手前が480px・奥が240pxと、どちらもタイルの整数倍です。ずらした位置のスクリーンショットを比べると、整数倍のときだけハッシュが元と完全に一致しました。
フッター デザイン 参考 として自動再生をやめ、自分のサイトの操作に差し替えられますか
はい、九つとも自動再生は.is-demoという印を付けたときだけ働く上書きで、ホバーとフォーカスの静的な指定が先に書いてあります。印を外せば、開閉も下線もタイルの浮き沈みも、訪問者の操作だけで同じように動きます。









