04Center-lift scale toggle — when the middle tab's selection value turns on, only that spot's height value grows above the background
The instant the center spot's selection value turns on, its height and size values grow together, standing out above the other spots.
Best forA bottom navigation with a primary action in the middle, a tab bar emphasizing a create/add entry point
LIFT_PXSHADOW_BLUR_GAINtranslateY

<main class="stage">
<div class="cl is-demo" id="cl">
<div class="cl__phone">
<div class="cl__track">
<button type="button" class="cl__tab is-active" data-idx="0">
<svg class="cl__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="cl__label">Home</span>
</button>
<button type="button" class="cl__tab" data-idx="1">
<svg class="cl__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="cl__label">Search</span>
</button>
<div class="cl__centerSlot">
<button type="button" class="cl__center" id="clCenter" data-idx="2">
<svg class="cl__centerIcon" viewBox="0 0 256 256" aria-hidden="true"><path fill="currentColor" d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8"/></svg>
</button>
</div>
<button type="button" class="cl__tab" data-idx="3">
<svg class="cl__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="cl__label">Shopping</span>
</button>
<button type="button" class="cl__tab" data-idx="4">
<svg class="cl__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="cl__label">Profile</span>
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 이 liftT(0~1) 를 springTo 감쇠 조화진동자로 목표까지 당기고,
// 그 값으로 translateY·scale 뿐 아니라 box-shadow 의 blur·y오프셋·alpha 까지 매 프레임 다시 계산한다
// (들릴수록 그림자가 커지고 옅어지는 물리적 높이감).
@include stage($stage-paper);
.cl__phone { width: 100%; max-width: 300px; }
.cl__track {
position: relative; display: flex; align-items: center; justify-content: space-between;
background: $subject-cream; border-radius: $r-pill; box-shadow: $shadow-raised;
padding: $sp-1 $sp-2; box-sizing: border-box;
}
.cl__tab {
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);
}
.cl__tab.is-active { color: $subject-blue-deep; }
.cl__icon { width: 20px; height: 20px; }
.cl__label { font-size: 10px; font-weight: 700; line-height: 1; }
.cl__centerSlot { flex: 0 0 auto; width: 52px; display: flex; justify-content: center; }
.cl__center {
width: 46px; height: 46px; border-radius: 50%; border: none; cursor: pointer;
background: $subject-blue-deep; color: $subject-cream; display: flex; align-items: center;
justify-content: center; transform-origin: 50% 50%; will-change: transform, box-shadow;
box-shadow: 0 2px 6px rgba(10, 20, 40, .32);
}
.cl__center.is-lifted { background: $subject-orange-deep; }
.cl__centerIcon { width: 22px; height: 22px; }
@media (max-width: 340px) {
.cl__phone { max-width: 230px; }
.cl__tab { padding: $sp-1 $sp-2; }
.cl__icon { width: 18px; height: 18px; }
.cl__center { width: 40px; height: 40px; }
}
@media (prefers-reduced-motion: reduce) {
.cl__center { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cl__phone {
width: 100%;
max-width: 300px;
}
.cl__track {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff7e6;
border-radius: 999px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px 8px;
box-sizing: border-box;
}
.cl__tab {
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);
}
.cl__tab.is-active {
color: #0a4fe8;
}
.cl__icon {
width: 20px;
height: 20px;
}
.cl__label {
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.cl__centerSlot {
flex: 0 0 auto;
width: 52px;
display: flex;
justify-content: center;
}
.cl__center {
width: 46px;
height: 46px;
border-radius: 50%;
border: none;
cursor: pointer;
background: #0a4fe8;
color: #fff7e6;
display: flex;
align-items: center;
justify-content: center;
transform-origin: 50% 50%;
will-change: transform, box-shadow;
box-shadow: 0 2px 6px rgba(10, 20, 40, 0.32);
}
.cl__center.is-lifted {
background: #c93000;
}
.cl__centerIcon {
width: 22px;
height: 22px;
}
@media (max-width: 340px) {
.cl__phone {
max-width: 230px;
}
.cl__tab {
padding: 4px 8px;
}
.cl__icon {
width: 18px;
height: 18px;
}
.cl__center {
width: 40px;
height: 40px;
}
}
@media (prefers-reduced-motion: reduce) {
.cl__center {
transition: none;
}
}(function () {
var HOLD_MS = 900; // 계산 겹 상수: 자동 루프가 각 상태에 머무는 시간
var LIFT_PX = 22; // px, 완전히 들렸을 때 위로 이동하는 거리
var SCALE_MAX = 1.14; // 완전히 들렸을 때 배율
var SHADOW_BLUR_BASE = 6, SHADOW_BLUR_GAIN = 20; // 계산 겹: 들림 정도(liftT)에 따라 blur 를 늘린다
var SHADOW_Y_BASE = 2, SHADOW_Y_GAIN = 14; // 계산 겹: 들림 정도에 따라 그림자 y 오프셋을 늘린다(높이감)
var SHADOW_ALPHA_BASE = 0.32, SHADOW_ALPHA_GAIN = -0.10; // 계산 겹: 들릴수록 그림자가 옅어진다(광원과 멀어짐)
var SPRING_K = 190, SPRING_C = 18; // 계산 겹: liftT 스프링 상수·감쇠
var cl = document.getElementById('cl');
var center = document.getElementById('clCenter');
var tabs = Array.prototype.slice.call(document.querySelectorAll('.cl__tab'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
var lifted = false;
var liftT = 0, liftV = 0;
var lastTs = 0;
var cycleStart = 0;
function setLifted(v) {
lifted = v;
center.classList.toggle('is-lifted', v);
tabs.forEach(function (t) { t.classList.toggle('is-active', false); });
}
function render() {
// 계산 겹: 물리 그림자 근사 — 들린 높이(liftT)가 클수록 blur·y오프셋은 커지고 alpha는 옅어진다
var ty = -(LIFT_PX * liftT);
var scale = 1 + (SCALE_MAX - 1) * liftT;
var blur = SHADOW_BLUR_BASE + SHADOW_BLUR_GAIN * liftT;
var y = SHADOW_Y_BASE + SHADOW_Y_GAIN * liftT;
var alpha = Math.max(0.08, SHADOW_ALPHA_BASE + SHADOW_ALPHA_GAIN * liftT);
center.style.transform = 'translateY(' + ty.toFixed(2) + 'px) scale(' + scale.toFixed(3) + ')';
center.style.boxShadow = '0 ' + y.toFixed(1) + 'px ' + blur.toFixed(1) + 'px rgba(10, 20, 40,' + alpha.toFixed(3) + ')';
}
function human() {
if (isDemo) { cl.classList.remove('is-demo'); isDemo = false; }
}
center.addEventListener('click', function () { human(); setLifted(!lifted); });
tabs.forEach(function (t) {
t.addEventListener('click', function () { human(); setLifted(false); tabs.forEach(function (o) { o.classList.remove('is-active'); }); t.classList.add('is-active'); });
});
function loop(ts) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
var dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo) {
var phase = Math.floor((ts - cycleStart) / HOLD_MS) % 2;
setLifted(phase === 1);
}
var target = lifted ? 1 : 0;
var acc = (target - liftT) * SPRING_K - liftV * SPRING_C;
liftV += acc * dt;
liftT += liftV * dt;
render();
requestAnimationFrame(loop);
}
render();
if (!reduce) {
requestAnimationFrame(loop);
}
})();// CenterLiftScale — vanilla 판(run/367/demo/04-bottom-nav-bar-center-lift-scale/index.html) 의 React 포트.
// liftT(0~1) 를 springTo 감쇠 조화진동자로 목표까지 당기고, 그 값으로 translateY·scale 뿐 아니라
// box-shadow 의 blur·y오프셋·alpha 까지 매 프레임 다시 계산한다(들릴수록 그림자가 커지고 옅어지는
// 물리적 높이감) — CSS transition 이 아니라 rAF 계산이 만든다.
import { useEffect, useRef } from "react";
import styles from "./CenterLiftScale.module.scss";
type Box<T> = { current: T };
const HOLD_MS = 900; // 계산 겹 상수: 자동 루프가 각 상태에 머무는 시간
const LIFT_PX = 22; // px, 완전히 들렸을 때 위로 이동하는 거리
const SCALE_MAX = 1.14; // 완전히 들렸을 때 배율
const SHADOW_BLUR_BASE = 6, SHADOW_BLUR_GAIN = 20; // 계산 겹: 들림 정도(liftT)에 따라 blur 를 늘린다
const SHADOW_Y_BASE = 2, SHADOW_Y_GAIN = 14; // 계산 겹: 들림 정도에 따라 그림자 y 오프셋을 늘린다(높이감)
const SHADOW_ALPHA_BASE = 0.32, SHADOW_ALPHA_GAIN = -0.10; // 계산 겹: 들릴수록 그림자가 옅어진다(광원과 멀어짐)
const SPRING_K = 190, SPRING_C = 18; // 계산 겹: liftT 스프링 상수·감쇠
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, className }: { spec: string; className?: string }) {
const [a, b] = spec.split("#");
const [pathA, opacity] = a.split("|");
return (
<svg className={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 CenterLiftScale() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const centerRef = useRef(null) as unknown as Box<HTMLButtonElement>;
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 lifted = false;
let liftT = 0, liftV = 0;
let lastTs = 0, cycleStart = 0;
function setLifted(v: boolean) {
lifted = v;
centerRef.current?.classList.toggle(styles.isLifted, v);
tabRefs.current.forEach((t) => t?.classList.toggle(styles.isActive, false));
}
function render() {
const center = centerRef.current;
if (!center) return;
const ty = -(LIFT_PX * liftT);
const scale = 1 + (SCALE_MAX - 1) * liftT;
const blur = SHADOW_BLUR_BASE + SHADOW_BLUR_GAIN * liftT;
const y = SHADOW_Y_BASE + SHADOW_Y_GAIN * liftT;
const alpha = Math.max(0.08, SHADOW_ALPHA_BASE + SHADOW_ALPHA_GAIN * liftT);
center.style.transform = "translateY(" + ty.toFixed(2) + "px) scale(" + scale.toFixed(3) + ")";
center.style.boxShadow = "0 " + y.toFixed(1) + "px " + blur.toFixed(1) + "px rgba(10, 20, 40," + alpha.toFixed(3) + ")";
}
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function onCenterClick() { human(); setLifted(!lifted); }
centerRef.current?.addEventListener("click", onCenterClick);
const tabHandlers = tabRefs.current.map((_, i) => () => {
human(); setLifted(false);
tabRefs.current.forEach((o) => o?.classList.remove(styles.isActive));
tabRefs.current[i]?.classList.add(styles.isActive);
});
tabRefs.current.forEach((t, i) => t?.addEventListener("click", tabHandlers[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 phase = Math.floor((ts - cycleStart) / HOLD_MS) % 2;
setLifted(phase === 1);
}
const target = lifted ? 1 : 0;
const acc = (target - liftT) * SPRING_K - liftV * SPRING_C;
liftV += acc * dt;
liftT += liftV * dt;
render();
rafRef.current = requestAnimationFrame(loop);
}
render();
if (!reduce) { rafRef.current = requestAnimationFrame(loop); }
return () => {
cancelAnimationFrame(rafRef.current);
centerRef.current?.removeEventListener("click", onCenterClick);
tabRefs.current.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}>
{TABS.slice(0, 2).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 className={styles.centerSlot}>
<button type="button" className={styles.center} ref={centerRef}>
<svg className={styles.centerIcon} viewBox="0 0 256 256" aria-hidden="true">
<path fill="currentColor" d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8" />
</svg>
</button>
</div>
{TABS.slice(2).map((t, i) => (
<button key={t.label} type="button"
className={styles.tab}
ref={(el: HTMLButtonElement | null) => { tabRefs.current[i + 2] = el; }}>
<Icon spec={t.d} />
<span className={styles.label}>{t.label}</span>
</button>
))}
</div>
</div>
</div>
</main>
);
}04 Center-lift scale toggle — when the middle tab's selection value turns on, only that spot's height value grows above the background — Measured area 2.599% · moving frames 12/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
Transitions01 Row expand-value toggle — when an item's expand value changes, the detail area's height value grows or shrinks along with itCollapsible Table Row: 9 UI Patterns
Transitions07 Sticky apply-bar reveal — a bottom bar repositioned by a selection-state value9 Filter UI Patterns (Panel Slide to Chip Remove)
Transitions07 Checkout bar lift — a bottom bar whose shadow and height values grow with scroll positionCart Drawer UI Patterns: 9 Motion Techniques