06법적·링크 트리 푸터
중요한 링크일수록 글자가 큰 계단식 한 줄로 서고, 마우스를 얹으면 밑줄이 왼쪽에서 오른쪽으로 차오르며 법적 문서는 아랫줄에 조용히 모여 있습니다.
쓰임링크 하나로 모으는 프로필 하단 · 약관·정책 정리
scaleXfont-size:focus-visible

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


