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