07スワイプ追従切替 — 横方向の移動距離の値が増える間、強調表示の位置の値がその割合分だけ隣のタブ側へ追従する方式
移動距離の値が増える間、強調表示の位置の値がその割合の分だけ隣のタブ側へ一緒に動きます。
使いどころ画面を横にスワイプしてタブを切り替えるモバイルナビゲーション、ジェスチャー中心の画面
SPRING_KDRAG_MStranslateX

<main class="stage">
<div class="sf is-demo" id="sf">
<div class="sf__phone">
<div class="sf__track" id="sfTrack">
<div class="sf__indicator" id="sfIndicator"></div>
<button type="button" class="sf__tab is-active" data-idx="0">
<svg class="sf__icon" 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 class="sf__label">ホーム</span>
</button>
<button type="button" class="sf__tab" data-idx="1">
<svg class="sf__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 80v120a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V80a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 64h-40a48 48 0 0 0-96 0H40a16 16 0 0 0-16 16v120a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16m-88-32a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m88 168H40V80h176Z"/></g></svg>
<span class="sf__label">ショッピング</span>
</button>
<button type="button" class="sf__tab" data-idx="2">
<svg class="sf__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
<span class="sf__label">通知</span>
</button>
<button type="button" class="sf__tab" data-idx="3">
<svg class="sf__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
<span class="sf__label">プロフィール</span>
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 드래그 중엔 포인터 x 를 그대로 clamp 해 인디케이터가 1:1 로 따라오고,
// 놓으면 가장 가까운 탭의 실측 rect(getBoundingClientRect) 로 스프링(SPRING_K·C) 정착한다.
@include stage($stage-orange);
// .sf 는 is-demo 클래스만 들고 있는 래퍼 — display:contents 로 트리에서 지워
// .sf__phone 이 .stage 그리드의 직계 아이템이 되게 한다(367-05 실측 버그 회피).
.sf { display: contents; }
.sf__phone {
width: 100%; max-width: 300px; position: relative; height: 96px;
display: flex; align-items: center;
}
.sf__track {
position: relative; width: 100%; display: flex; justify-content: space-around;
background: $subject-cream; border-radius: $r-card; padding: $sp-2 0;
box-shadow: $shadow-raised; touch-action: none; cursor: grab;
}
.sf__indicator {
position: absolute; left: 0; top: 8px;
border-radius: $r-pill; background: $subject-blue-light; will-change: transform;
}
.sf__tab {
position: relative; z-index: 1; flex: 0 0 auto; display: flex; flex-direction: column;
align-items: center; gap: $sp-1; border: none; background: transparent; cursor: pointer;
padding: $sp-1 $sp-3; color: rgba($subject-ink, .72);
}
.sf__tab.is-active { color: $subject-blue-deep; }
.sf__icon { width: 18px; height: 18px; }
.sf__label { font-size: 10px; font-weight: 700; line-height: 1; }
@media (max-width: 340px) {
.sf__phone { max-width: 230px; }
.sf__tab { padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.sf__indicator { transition: none; }
}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);
}
.sf {
display: contents;
}
.sf__phone {
width: 100%;
max-width: 300px;
position: relative;
height: 96px;
display: flex;
align-items: center;
}
.sf__track {
position: relative;
width: 100%;
display: flex;
justify-content: space-around;
background: #fff7e6;
border-radius: 16px;
padding: 8px 0;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
touch-action: none;
cursor: grab;
}
.sf__indicator {
position: absolute;
left: 0;
top: 8px;
border-radius: 999px;
background: #5b8cf8;
will-change: transform;
}
.sf__tab {
position: relative;
z-index: 1;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border: none;
background: transparent;
cursor: pointer;
padding: 4px 12px;
color: rgba(23, 20, 26, 0.72);
}
.sf__tab.is-active {
color: #0a4fe8;
}
.sf__icon {
width: 18px;
height: 18px;
}
.sf__label {
font-size: 10px;
font-weight: 700;
line-height: 1;
}
@media (max-width: 340px) {
.sf__phone {
max-width: 230px;
}
.sf__tab {
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sf__indicator {
transition: none;
}
}(function () {
var SPRING_K = 240; // 계산 겹: 정착(settle) 스프링 상수 — 드래그를 놓은 뒤 가장 가까운 탭으로 당긴다
var SPRING_C = 20; // 감쇠 상수
var DRAG_MS = 520; // 자동 루프에서 가상 손가락이 다음 탭까지 이동하는 시간
var WAIT_MS = 700; // 정착 뒤 머무는 시간
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var sf = document.getElementById('sf');
var track = document.getElementById('sfTrack');
var indicator = document.getElementById('sfIndicator');
var tabs = Array.prototype.slice.call(document.querySelectorAll('.sf__tab'));
var isDemo = true;
var geo = []; // 계산 겹: 탭마다 실제 렌더 폭·left 오프셋(getBoundingClientRect 실측)
var maxX = 0;
var indW = 0, indH = 0;
var activeIdx = 0;
var posX = 0; // 인디케이터 현재 x(px)
var vel = 0; // 정착 스프링 속도
var settling = false;
var settleTarget = 0;
var dragging = false;
var dragStartClientX = 0;
var dragStartX = 0;
var lastTs = 0;
function measure() {
var trackRect = track.getBoundingClientRect();
geo = tabs.map(function (t) {
var r = t.getBoundingClientRect();
return { left: r.left - trackRect.left, width: r.width, height: r.height };
});
var first = geo[0];
indW = first.width; indH = first.height;
indicator.style.width = indW + 'px';
indicator.style.height = indH + 'px';
maxX = geo[geo.length - 1].left;
posX = geo[activeIdx].left;
indicator.style.transform = 'translateX(' + posX.toFixed(1) + 'px)';
}
function human() {
if (isDemo) { sf.classList.remove('is-demo'); isDemo = false; }
}
function nearestIndex(centerX) {
var best = 0, bestDist = Infinity;
geo.forEach(function (g, i) {
var c = g.left + g.width / 2;
var d = Math.abs(c - centerX);
if (d < bestDist) { bestDist = d; best = i; }
});
return best;
}
function setActive(idx) {
activeIdx = idx;
tabs.forEach(function (t, i) { t.classList.toggle('is-active', i === idx); });
}
function onPointerDown(e) {
human();
dragging = true;
settling = false;
dragStartClientX = e.clientX;
dragStartX = posX;
track.setPointerCapture && e.pointerId != null && track.setPointerCapture(e.pointerId);
}
function onPointerMove(e) {
if (!dragging) return;
var dx = e.clientX - dragStartClientX;
posX = Math.max(0, Math.min(maxX, dragStartX + dx));
indicator.style.transform = 'translateX(' + posX.toFixed(1) + 'px)';
}
function onPointerUp() {
if (!dragging) return;
dragging = false;
var idx = nearestIndex(posX + indW / 2);
setActive(idx);
settling = true;
settleTarget = geo[idx].left;
vel = 0;
}
track.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
tabs.forEach(function (t, i) {
t.addEventListener('click', function () {
human();
if (dragging) return;
setActive(i);
settling = true;
settleTarget = geo[i].left;
vel = 0;
});
});
var demoOrder = [2, 1, 3, 0]; // 자동 루프가 방문할 탭 순서
var demoStep = 0;
var demoPhase = 'wait'; // 'drag' | 'settle' | 'wait'
var demoElapsed = 0;
var demoFromIdx = 0;
function tick(ts) {
if (!lastTs) { lastTs = ts; }
var dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo && !reduce && !dragging) {
demoElapsed += dt * 1000;
if (demoPhase === 'drag') {
var t = Math.min(1, demoElapsed / DRAG_MS);
var toIdx = demoOrder[demoStep];
var fromCenter = geo[demoFromIdx].left + indW / 2;
var toCenter = geo[toIdx].left + indW / 2;
var virtualCenter = fromCenter + (toCenter - fromCenter) * t;
posX = Math.max(0, Math.min(maxX, virtualCenter - indW / 2));
indicator.style.transform = 'translateX(' + posX.toFixed(1) + 'px)';
if (t >= 1) {
setActive(toIdx);
settling = true; settleTarget = geo[toIdx].left; vel = 0;
demoPhase = 'settle'; demoElapsed = 0;
}
} else if (demoPhase === 'settle') {
if (!settling) {
demoPhase = 'wait'; demoElapsed = 0;
}
} else if (demoPhase === 'wait') {
if (demoElapsed >= WAIT_MS) {
demoFromIdx = demoOrder[demoStep];
demoStep = (demoStep + 1) % demoOrder.length;
demoPhase = 'drag'; demoElapsed = 0;
}
}
}
if (settling) {
var acc = (settleTarget - posX) * SPRING_K - vel * SPRING_C;
vel += acc * dt;
posX += vel * dt;
if (Math.abs(settleTarget - posX) < 0.4 && Math.abs(vel) < 4) {
posX = settleTarget; vel = 0; settling = false;
}
indicator.style.transform = 'translateX(' + posX.toFixed(1) + 'px)';
}
requestAnimationFrame(tick);
}
measure();
window.addEventListener('resize', measure);
requestAnimationFrame(tick);
})();// SwipeFollow — vanilla 판(run/367/demo/07-bottom-nav-bar-swipe-follow/index.html) 의 React 포트.
// 드래그 중엔 포인터 x 를 그대로 clamp 해 인디케이터가 1:1 로 따라오고, 놓으면 가장 가까운
// 탭의 실측 rect(getBoundingClientRect) 로 스프링(SPRING_K·C) 정착한다.
import { useEffect, useRef } from "react";
import styles from "./SwipeFollow.module.scss";
type Box<T> = { current: T };
type Geo = { left: number; width: number; height: number };
const SPRING_K = 240; // 계산 겹: 정착(settle) 스프링 상수 — 드래그를 놓은 뒤 가장 가까운 탭으로 당긴다
const SPRING_C = 20; // 감쇠 상수
const DRAG_MS = 520; // 자동 루프에서 가상 손가락이 다음 탭까지 이동하는 시간
const WAIT_MS = 700; // 정착 뒤 머무는 시간
const TABS = [
{ label: "홈", 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|.2#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" },
{ label: "쇼핑", d: "M224 80v120a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V80a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8|.2#M216 64h-40a48 48 0 0 0-96 0H40a16 16 0 0 0-16 16v120a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16m-88-32a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m88 168H40V80h176Z" },
{ label: "알림", d: "M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12|.2#M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z" },
{ label: "프로필", d: "M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64|.2#M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56" },
];
function Icon({ spec }: { spec: string }) {
const [a, b] = spec.split("#");
const [pathA, opacity] = a.split("|");
return (
<svg className={styles.icon} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d={pathA} opacity={opacity} />
<path d={b} />
</g>
</svg>
);
}
export default function SwipeFollow() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const indicatorRef = useRef(null) as unknown as Box<HTMLDivElement>;
const tabRefs = useRef([null, null, null, null]) as unknown as Box<(HTMLButtonElement | null)[]>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const track = trackRef.current;
const indicator = indicatorRef.current;
const tabs = tabRefs.current;
if (!track || !indicator) return;
let isDemo = true;
let geo: Geo[] = [];
let maxX = 0, indW = 0, indH = 0;
let activeIdx = 0;
let posX = 0;
let vel = 0;
let settling = false;
let settleTarget = 0;
let dragging = false;
let dragStartClientX = 0;
let dragStartX = 0;
let lastTs = 0;
function measure() {
const trackRect = track!.getBoundingClientRect();
geo = tabs.map((t) => {
const r = t!.getBoundingClientRect();
return { left: r.left - trackRect.left, width: r.width, height: r.height };
});
const first = geo[0];
indW = first.width; indH = first.height;
indicator!.style.width = indW + "px";
indicator!.style.height = indH + "px";
maxX = geo[geo.length - 1].left;
posX = geo[activeIdx].left;
indicator!.style.transform = "translateX(" + posX.toFixed(1) + "px)";
}
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function nearestIndex(centerX: number) {
let best = 0, bestDist = Infinity;
geo.forEach((g, i) => {
const c = g.left + g.width / 2;
const d = Math.abs(c - centerX);
if (d < bestDist) { bestDist = d; best = i; }
});
return best;
}
function setActive(idx: number) {
activeIdx = idx;
tabs.forEach((t, i) => t?.classList.toggle(styles.isActive, i === idx));
}
function onPointerDown(e: PointerEvent) {
human();
dragging = true;
settling = false;
dragStartClientX = e.clientX;
dragStartX = posX;
if (e.pointerId != null) {
try { track!.setPointerCapture(e.pointerId); } catch { /* noop */ }
}
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const dx = e.clientX - dragStartClientX;
posX = Math.max(0, Math.min(maxX, dragStartX + dx));
indicator!.style.transform = "translateX(" + posX.toFixed(1) + "px)";
}
function onPointerUp() {
if (!dragging) return;
dragging = false;
const idx = nearestIndex(posX + indW / 2);
setActive(idx);
settling = true;
settleTarget = geo[idx].left;
vel = 0;
}
track.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerUp);
const tabHandlers = tabs.map((_, i) => () => {
human();
if (dragging) return;
setActive(i);
settling = true;
settleTarget = geo[i].left;
vel = 0;
});
tabs.forEach((t, i) => t?.addEventListener("click", tabHandlers[i]));
const demoOrder = [2, 1, 3, 0]; // 자동 루프가 방문할 탭 순서
let demoStep = 0;
let demoPhase: "drag" | "settle" | "wait" = "wait";
let demoElapsed = 0;
let demoFromIdx = 0;
function tick(ts: number) {
if (!lastTs) { lastTs = ts; }
const dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo && !reduce && !dragging) {
demoElapsed += dt * 1000;
if (demoPhase === "drag") {
const t = Math.min(1, demoElapsed / DRAG_MS);
const toIdx = demoOrder[demoStep];
const fromCenter = geo[demoFromIdx].left + indW / 2;
const toCenter = geo[toIdx].left + indW / 2;
const virtualCenter = fromCenter + (toCenter - fromCenter) * t;
posX = Math.max(0, Math.min(maxX, virtualCenter - indW / 2));
indicator!.style.transform = "translateX(" + posX.toFixed(1) + "px)";
if (t >= 1) {
setActive(toIdx);
settling = true; settleTarget = geo[toIdx].left; vel = 0;
demoPhase = "settle"; demoElapsed = 0;
}
} else if (demoPhase === "settle") {
if (!settling) { demoPhase = "wait"; demoElapsed = 0; }
} else if (demoPhase === "wait") {
if (demoElapsed >= WAIT_MS) {
demoFromIdx = demoOrder[demoStep];
demoStep = (demoStep + 1) % demoOrder.length;
demoPhase = "drag"; demoElapsed = 0;
}
}
}
if (settling) {
const acc = (settleTarget - posX) * SPRING_K - vel * SPRING_C;
vel += acc * dt;
posX += vel * dt;
if (Math.abs(settleTarget - posX) < 0.4 && Math.abs(vel) < 4) {
posX = settleTarget; vel = 0; settling = false;
}
indicator!.style.transform = "translateX(" + posX.toFixed(1) + "px)";
}
rafRef.current = requestAnimationFrame(tick);
}
function onResize() { measure(); }
measure();
window.addEventListener("resize", onResize);
rafRef.current = requestAnimationFrame(tick);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
track.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
tabs.forEach((t, i) => t?.removeEventListener("click", tabHandlers[i]));
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root + " " + styles.isDemo} ref={rootRef}>
<div className={styles.phone}>
<div className={styles.track} ref={trackRef}>
<div className={styles.indicator} ref={indicatorRef} />
{TABS.map((t, i) => (
<button key={t.label} type="button"
className={styles.tab + (i === 0 ? " " + styles.isActive : "")}
aria-current={i === 0 ? "true" : undefined}
ref={(el: HTMLButtonElement | null) => { tabRefs.current[i] = el; }}>
<Icon spec={t.d} />
<span className={styles.label}>{t.label}</span>
</button>
))}
</div>
</div>
</div>
</main>
);
}07 スワイプ追従切替 — 横方向の移動距離の値が増える間、強調表示の位置の値がその割合分だけ隣のタブ側へ追従する方式 — 計測 累積面積 5.814% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · ボトムナビゲーション 9選 — 状態値で変わる9つの転換パターン
このデモが属する記事
ボトムナビゲーション 9選 — 状態値で変わる9つの転換パターンCSS ボトムナビゲーション:9つのうち1つは選択値が変わると強調表示の位置と幅の値が新しい場所へ一緒に移動し、1つは通知件数の値が上がるとその表示の大きさの値が一瞬大きくなってから収まり、もう1つはスクロール方向の値が変わるとバー全体の縦 この記事のデモをすべて見る


