01Active-indicator slide-morph toggle — when the selected tab changes, the highlight's position and width values shift together from the old spot to the new one
The moment the selection value changes, the highlight's horizontal position and width values move together to the new tab's spot.
Best forA mobile screen switching between bottom tabs, navigation moving across several sections
SPRING_KtranslateXwidth

<main class="stage">
<div class="bn is-demo" id="bn">
<div class="bn__phone">
<div class="bn__track" id="bnTrack">
<span class="bn__indicator" id="bnIndicator"></span>
<button type="button" class="bn__tab is-active" data-idx="0" id="bnTab0" aria-current="true">
<svg class="bn__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="bn__label">Home</span>
</button>
<button type="button" class="bn__tab" data-idx="1" id="bnTab1">
<svg class="bn__icon" 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 class="bn__label">Search</span>
</button>
<button type="button" class="bn__tab" data-idx="2" id="bnTab2">
<svg class="bn__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="bn__label">Shopping</span>
</button>
<button type="button" class="bn__tab" data-idx="3" id="bnTab3">
<svg class="bn__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="bn__label">Profile</span>
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html measure() 가 Playwright/브라우저 실측(getBoundingClientRect)으로 탭 폭·좌표를
// 얻고, springTo() 가 감쇠 조화진동자로 인디케이터 위치·너비를 매 프레임 목표로 당긴다(오버슛 포함).
@include stage($stage-ink);
.bn__phone { width: 100%; max-width: 300px; }
.bn__track {
position: relative; display: flex; background: $subject-cream; border-radius: $r-pill;
box-shadow: $shadow-raised; padding: $sp-1; box-sizing: border-box;
}
.bn__indicator {
position: absolute; top: $sp-1; left: 0; height: calc(100% - #{$sp-1} * 2);
border-radius: $r-pill; background: rgba($subject-blue, .16);
will-change: transform, width;
}
.bn__tab {
position: relative; flex: 0 0 auto; display: flex; flex-direction: column; align-items: center;
gap: $sp-1; border: none; background: transparent; cursor: pointer;
padding: $sp-2 $sp-3; color: rgba($subject-ink, .72); z-index: 1;
}
.bn__icon { width: 20px; height: 20px; }
.bn__label { font-size: 10px; font-weight: 700; line-height: 1; }
.bn__tab.is-active { color: $subject-blue-deep; }
@media (max-width: 340px) {
.bn__phone { max-width: 230px; }
.bn__tab { padding: $sp-1 $sp-2; }
.bn__icon { width: 18px; height: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.bn__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bn__phone {
width: 100%;
max-width: 300px;
}
.bn__track {
position: relative;
display: flex;
background: #fff7e6;
border-radius: 999px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px;
box-sizing: border-box;
}
.bn__indicator {
position: absolute;
top: 4px;
left: 0;
height: calc(100% - 4px * 2);
border-radius: 999px;
background: rgba(47, 109, 246, 0.16);
will-change: transform, width;
}
.bn__tab {
position: relative;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border: none;
background: transparent;
cursor: pointer;
padding: 8px 12px;
color: rgba(23, 20, 26, 0.72);
z-index: 1;
}
.bn__icon {
width: 20px;
height: 20px;
}
.bn__label {
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.bn__tab.is-active {
color: #0a4fe8;
}
@media (max-width: 340px) {
.bn__phone {
max-width: 230px;
}
.bn__tab {
padding: 4px 8px;
}
.bn__icon {
width: 18px;
height: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.bn__indicator {
transition: none;
}
}(function () {
var HOLD_MS = 650; // 계산 겹 상수: 자동 루프가 각 탭에 머무는 시간
var SPRING_K = 210; // 계산 겹: 스프링 상수(위치·너비 공용) — 값이 클수록 빨리 목표에 붙는다
var SPRING_C = 19; // 계산 겹: 감쇠 상수 — 낮을수록 목표를 지나쳤다 되돌아오는 정도가 크다
var TAB_COUNT = 4;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var bn = document.getElementById('bn');
var track = document.getElementById('bnTrack');
var indicator = document.getElementById('bnIndicator');
var tabs = [0, 1, 2, 3].map(function (i) { return document.getElementById('bnTab' + i); });
var isDemo = true;
var activeIdx = 0;
var rects = [];
var posX = 0, velX = 0, posW = 0, velW = 0;
var lastTs = 0;
var cycleStart = 0;
function measure() {
// 계산 겹: 탭 폭·위치를 실측해 인디케이터 목표 좌표를 만든다 — 짐작 px 를 쓰지 않는다
var tRect = track.getBoundingClientRect();
rects = tabs.map(function (t) {
var r = t.getBoundingClientRect();
return { x: r.left - tRect.left, w: r.width };
});
if (!posW) { posX = rects[0].x; posW = rects[0].w; }
}
function setActive(idx) {
activeIdx = idx;
tabs.forEach(function (t, i) {
t.classList.toggle('is-active', i === idx);
t.setAttribute('aria-current', i === idx ? 'true' : 'false');
});
}
function springTo(pos, vel, target, dt) {
var acc = (target - pos) * SPRING_K - vel * SPRING_C;
vel += acc * dt;
pos += vel * dt;
return [pos, vel];
}
function render() {
indicator.style.transform = 'translateX(' + posX.toFixed(2) + 'px)';
indicator.style.width = posW.toFixed(2) + 'px';
}
function human() {
if (isDemo) { bn.classList.remove('is-demo'); isDemo = false; }
}
tabs.forEach(function (t, i) {
t.addEventListener('click', function () { human(); setActive(i); });
});
function loop(ts) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
var dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo) {
var elapsed = ts - cycleStart;
var idx = Math.floor(elapsed / HOLD_MS) % TAB_COUNT;
if (idx !== activeIdx) setActive(idx);
}
var target = rects[activeIdx] || rects[0] || { x: posX, w: posW };
var rx = springTo(posX, velX, target.x, dt);
posX = rx[0]; velX = rx[1];
var rw = springTo(posW, velW, target.w, dt);
posW = rw[0]; velW = rw[1];
render();
requestAnimationFrame(loop);
}
window.addEventListener('resize', measure);
measure();
render();
if (!reduce) {
requestAnimationFrame(loop);
}
})();// IndicatorSlideMorph — vanilla 판(run/367/demo/01-bottom-nav-bar-indicator-slide-morph/index.html) 의 React 포트.
// 탭 폭·좌표를 getBoundingClientRect 로 실측하고, springTo() 감쇠 조화진동자가 인디케이터의
// x·width 를 매 프레임 목표로 당긴다(오버슛 포함) — CSS transition 이 아니라 rAF 계산이 위치를 만든다.
import { useEffect, useRef } from "react";
import styles from "./IndicatorSlideMorph.module.scss";
type Box<T> = { current: T };
type ClickEvt = { preventDefault(): void };
const HOLD_MS = 650; // 계산 겹 상수: 자동 루프가 각 탭에 머무는 시간
const SPRING_K = 210; // 계산 겹: 스프링 상수(위치·너비 공용)
const SPRING_C = 19; // 계산 겹: 감쇠 상수
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: "M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80|.2#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" },
{ 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: "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 IndicatorSlideMorph() {
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<HTMLSpanElement>;
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;
let isDemo = true;
let activeIdx = 0;
let rects: { x: number; w: number }[] = [];
let posX = 0, velX = 0, posW = 0, velW = 0;
let lastTs = 0, cycleStart = 0;
function measure() {
const track = trackRef.current;
if (!track) return;
const tRect = track.getBoundingClientRect();
rects = tabRefs.current.map((t) => {
if (!t) return { x: 0, w: 0 };
const r = t.getBoundingClientRect();
return { x: r.left - tRect.left, w: r.width };
});
if (!posW) { posX = rects[0].x; posW = rects[0].w; }
}
function setActive(idx: number) {
activeIdx = idx;
tabRefs.current.forEach((t, i) => {
if (!t) return;
t.classList.toggle(styles.isActive, i === idx);
t.setAttribute("aria-current", i === idx ? "true" : "false");
});
}
function springTo(pos: number, vel: number, target: number, dt: number): [number, number] {
const acc = (target - pos) * SPRING_K - vel * SPRING_C;
vel += acc * dt;
pos += vel * dt;
return [pos, vel];
}
function render() {
const ind = indicatorRef.current;
if (!ind) return;
ind.style.transform = "translateX(" + posX.toFixed(2) + "px)";
ind.style.width = posW.toFixed(2) + "px";
}
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function onTabClick(i: number) { return () => { human(); setActive(i); }; }
const handlers = TABS.map((_, i) => onTabClick(i));
tabRefs.current.forEach((t, i) => t?.addEventListener("click", handlers[i]));
function loop(ts: number) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
const dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo) {
const elapsed = ts - cycleStart;
const idx = Math.floor(elapsed / HOLD_MS) % TABS.length;
if (idx !== activeIdx) setActive(idx);
}
const target = rects[activeIdx] || rects[0] || { x: posX, w: posW };
const rx = springTo(posX, velX, target.x, dt); posX = rx[0]; velX = rx[1];
const rw = springTo(posW, velW, target.w, dt); posW = rw[0]; velW = rw[1];
render();
rafRef.current = requestAnimationFrame(loop);
}
function onResize() { measure(); }
window.addEventListener("resize", onResize);
measure();
render();
if (!reduce) { rafRef.current = requestAnimationFrame(loop); }
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
tabRefs.current.forEach((t, i) => t?.removeEventListener("click", handlers[i]));
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root + " " + styles.isDemo} ref={rootRef}>
<div className={styles.phone}>
<div className={styles.track} ref={trackRef}>
<span 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>
);
}01 Active-indicator slide-morph toggle — when the selected tab changes, the highlight's position and width values shift together from the old spot to the new one — Measured area 6.096% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · CSS Bottom Navigation Bar: 9 Transition Patterns
The post this demo lives in
CSS Bottom Navigation Bar: 9 Transition PatternsCSS Bottom Navigation Bar: One of nine slides the highlight to the new tab as selection changes, one grows a badge brief See all demos in this post
If you like this effect - similar demos
Transitions04 Row-insert and position values changing together — when a new row is added, its position value settles into place while the rows below shift their position values tooCollapsible Table Row: 9 UI Patterns
Transitions01 Sliding underline indicator9 CSS Tab Switch Animations — No Library, Pure CSS
Transitions02 Pill slide indicatorMac Dock CSS — 9 Floating Dock Styles, No Library