04스티키 하단 앱바
화면 아래에 붙어 있는 막대의 네 칸 중 활성 칸이 옆으로 옮겨 가며 아이콘이 튀고, 화살표 키와 누르기로도 칸을 옮길 수 있습니다.
쓰임모바일 웹앱 하단 고정 조작바
translateXaria-current:focus-visible

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


