03バッジ件数バウンド切替 — 通知件数の値が上がるたびその表示の大きさの値が一度大きくなってから戻る方式
件数の値が上がった瞬間、その表示の大きさの値が一瞬大きくなり、元の大きさに戻ります。
使いどころ未読通知数を示すタブ、待機中の件数表示
BUMP_ZETAtranslateYscale

<main class="stage">
<div class="bg is-demo" id="bg">
<div class="bg__phone">
<div class="bg__track">
<button type="button" class="bg__tab is-active" data-idx="0">
<svg class="bg__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="bg__label">ホーム</span>
</button>
<button type="button" class="bg__tab" data-idx="1">
<svg class="bg__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="bg__label">ショッピング</span>
</button>
<button type="button" class="bg__tab" data-idx="2" id="bgAlertTab">
<span class="bg__iconWrap">
<svg class="bg__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="bg__badge" id="bgBadge">
<span class="bg__strip" id="bgStrip">
<span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span>
</span>
</span>
</span>
<span class="bg__label">通知</span>
</button>
<button type="button" class="bg__tab" data-idx="3">
<svg class="bg__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="bg__label">プロフィール</span>
</button>
</div>
<button type="button" class="bg__hit" id="bgAdd">通知を追加</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html increment() 가 카운트를 올릴 때마다 springTo 감쇠 조화진동자가
// 숫자 롤(strip)의 translateY 를 목표 칸으로 당기고(오버슛 포함), badge 자체는
// bumpScale() 감쇠 코사인 진동으로 짧게 튀어올랐다 가라앉는다.
@include stage($stage-yellow);
.bg__phone { width: 100%; max-width: 300px; }
.bg__track {
display: flex; justify-content: space-between; background: $subject-cream; border-radius: $r-pill;
box-shadow: $shadow-raised; padding: $sp-1 $sp-2; box-sizing: border-box;
}
.bg__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);
}
.bg__tab.is-active { color: $subject-blue-deep; }
.bg__icon { width: 20px; height: 20px; }
.bg__label { font-size: 10px; font-weight: 700; line-height: 1; }
.bg__iconWrap { position: relative; display: inline-flex; }
.bg__badge {
position: absolute; top: -4px; right: -8px; min-width: $sp-4; height: $sp-4;
border-radius: $r-pill; background: $subject-orange-deep; color: $subject-cream;
display: none; align-items: center; justify-content: center; overflow: hidden;
box-shadow: $shadow-raised; transform-origin: 50% 50%;
}
.bg__strip {
display: flex; flex-direction: column; align-items: center; line-height: 14px;
font-size: 10px; font-weight: 800;
span { height: 14px; display: block; }
}
.bg__hit {
margin-top: $sp-2; width: 100%; border: none; border-radius: $r-control;
background: $subject-blue-deep; color: $subject-cream; font-weight: 700; font-size: 11px;
padding: $sp-1 0; cursor: pointer;
}
@media (max-width: 340px) {
.bg__phone { max-width: 230px; }
.bg__tab { padding: $sp-1 $sp-2; }
.bg__icon { width: 18px; height: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.bg__badge, .bg__strip { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bg__phone {
width: 100%;
max-width: 300px;
}
.bg__track {
display: flex;
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;
}
.bg__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);
}
.bg__tab.is-active {
color: #0a4fe8;
}
.bg__icon {
width: 20px;
height: 20px;
}
.bg__label {
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.bg__iconWrap {
position: relative;
display: inline-flex;
}
.bg__badge {
position: absolute;
top: -4px;
right: -8px;
min-width: 16px;
height: 16px;
border-radius: 999px;
background: #c93000;
color: #fff7e6;
display: none;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: 50% 50%;
}
.bg__strip {
display: flex;
flex-direction: column;
align-items: center;
line-height: 14px;
font-size: 10px;
font-weight: 800;
}
.bg__strip span {
height: 14px;
display: block;
}
.bg__hit {
margin-top: 8px;
width: 100%;
border: none;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
padding: 4px 0;
cursor: pointer;
}
@media (max-width: 340px) {
.bg__phone {
max-width: 230px;
}
.bg__tab {
padding: 4px 8px;
}
.bg__icon {
width: 18px;
height: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.bg__badge, .bg__strip {
transition: none;
}
}(function () {
var ROW_H = 14; // px, 숫자 롤 한 칸 높이
var INCR_MS = 520; // 계산 겹 상수: 자동 루프가 수량을 올리는 간격
var SPRING_K = 260; // 계산 겹: 숫자 롤 스프링 상수
var SPRING_C = 22; // 계산 겹: 숫자 롤 감쇠 상수
var BUMP_DUR = 320; // ms, 배지 bump 지속
var BUMP_FREQ = 2 * Math.PI * 1.8 / BUMP_DUR; // 계산 겹: bump 각속도
var BUMP_ZETA = 4.8; // 계산 겹: bump 감쇠 상수
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var bg = document.getElementById('bg');
var badge = document.getElementById('bgBadge');
var strip = document.getElementById('bgStrip');
var addBtn = document.getElementById('bgAdd');
var alertTab = document.getElementById('bgAlertTab');
var isDemo = true;
var count = 0;
var pos = 0, vel = 0;
var bumpStart = -9999;
var lastTs = 0;
var cycleStart = 0;
function bumpScale(t) {
// 계산 겹: 배지 bump — 감쇠 코사인 진동
if (t < 0 || t > BUMP_DUR) return 1;
var amp = Math.exp(-BUMP_ZETA * (t / BUMP_DUR));
return 1 + amp * (1 - Math.cos(BUMP_FREQ * t)) * 0.5;
}
function bump(ts) {
bumpStart = ts;
}
function increment(ts) {
count = (count + 1) % 10;
badge.style.display = 'inline-flex';
bump(ts);
}
function render(ts) {
var s = bumpScale(ts - bumpStart);
badge.style.transform = 'scale(' + s.toFixed(3) + ')';
strip.style.transform = 'translateY(' + (-pos).toFixed(2) + 'px)';
}
function human() {
if (isDemo) { bg.classList.remove('is-demo'); isDemo = false; }
}
addBtn.addEventListener('click', function () { human(); increment(performance.now()); });
addBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
addBtn.click();
});
alertTab.addEventListener('click', function () { human(); });
function loop(ts) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
var dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo) {
var n = Math.floor((ts - cycleStart) / INCR_MS);
if (n > loop._lastN) {
loop._lastN = n;
increment(ts);
}
}
var target = -count * ROW_H;
var acc = (target - pos) * SPRING_K - vel * SPRING_C;
vel += acc * dt;
pos += vel * dt;
render(ts);
requestAnimationFrame(loop);
}
loop._lastN = -1;
render(0);
if (!reduce) {
requestAnimationFrame(loop);
}
})();// BadgeCountBump — vanilla 판(run/367/demo/03-bottom-nav-bar-badge-count-bump/index.html) 의 React 포트.
// increment() 가 카운트를 올릴 때마다 springTo 감쇠 조화진동자가 숫자 롤(strip)의
// translateY 를 목표 칸으로 당기고(오버슛 포함), badge 자체는 bumpScale() 감쇠 코사인
// 진동으로 짧게 튀어올랐다 가라앉는다 — 둘 다 rAF 계산, CSS transition 아님.
import { useEffect, useRef } from "react";
import styles from "./BadgeCountBump.module.scss";
type Box<T> = { current: T };
const ROW_H = 14; // px, 숫자 롤 한 칸 높이
const INCR_MS = 520; // 계산 겹 상수: 자동 루프가 수량을 올리는 간격
const SPRING_K = 260; // 계산 겹: 숫자 롤 스프링 상수
const SPRING_C = 22; // 계산 겹: 숫자 롤 감쇠 상수
const BUMP_DUR = 320; // ms, 배지 bump 지속
const BUMP_FREQ = (2 * Math.PI * 1.8) / BUMP_DUR; // 계산 겹: bump 각속도
const BUMP_ZETA = 4.8; // 계산 겹: bump 감쇠 상수
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: "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" },
];
const ALERT_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";
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>
);
}
function bumpScale(t: number): number {
if (t < 0 || t > BUMP_DUR) return 1;
const amp = Math.exp(-BUMP_ZETA * (t / BUMP_DUR));
return 1 + amp * (1 - Math.cos(BUMP_FREQ * t)) * 0.5;
}
export default function BadgeCountBump() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const badgeRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const stripRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const addRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const alertTabRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
let count = 0;
let pos = 0, vel = 0;
let bumpStart = -9999;
let lastTs = 0, cycleStart = 0;
let lastN = -1;
function bump(ts: number) { bumpStart = ts; }
function increment(ts: number) {
count = (count + 1) % 10;
const badge = badgeRef.current;
if (badge) badge.style.display = "inline-flex";
bump(ts);
}
function render(ts: number) {
const badge = badgeRef.current;
const strip = stripRef.current;
if (!badge || !strip) return;
const s = bumpScale(ts - bumpStart);
badge.style.transform = "scale(" + s.toFixed(3) + ")";
strip.style.transform = "translateY(" + (-pos).toFixed(2) + "px)";
}
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function onAddClick() { human(); increment(performance.now()); }
function onAddKeydown(ev: KeyboardEvent) {
if (ev.key !== "Enter" && ev.key !== " ") return;
ev.preventDefault();
addRef.current?.click();
}
function onAlertClick() { human(); }
addRef.current?.addEventListener("click", onAddClick);
addRef.current?.addEventListener("keydown", onAddKeydown);
alertTabRef.current?.addEventListener("click", onAlertClick);
function loop(ts: number) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
const dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo) {
const n = Math.floor((ts - cycleStart) / INCR_MS);
if (n > lastN) { lastN = n; increment(ts); }
}
const target = -count * ROW_H;
const acc = (target - pos) * SPRING_K - vel * SPRING_C;
vel += acc * dt;
pos += vel * dt;
render(ts);
rafRef.current = requestAnimationFrame(loop);
}
render(0);
if (!reduce) { rafRef.current = requestAnimationFrame(loop); }
return () => {
cancelAnimationFrame(rafRef.current);
addRef.current?.removeEventListener("click", onAddClick);
addRef.current?.removeEventListener("keydown", onAddKeydown);
alertTabRef.current?.removeEventListener("click", onAlertClick);
};
}, []);
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}>
<Icon spec={t.d} />
<span className={styles.label}>{t.label}</span>
</button>
))}
<button type="button" className={styles.tab} ref={alertTabRef}>
<span className={styles.iconWrap}>
<Icon spec={ALERT_D} />
<span className={styles.badge} ref={badgeRef}>
<span className={styles.strip} ref={stripRef}>
<span>0</span><span>1</span><span>2</span><span>3</span><span>4</span>
<span>5</span><span>6</span><span>7</span><span>8</span><span>9</span>
</span>
</span>
</span>
<span className={styles.label}>알림</span>
</button>
<button type="button" className={styles.tab}>
<Icon spec={TABS[2].d} />
<span className={styles.label}>{TABS[2].label}</span>
</button>
</div>
<button type="button" className={styles.hit} ref={addRef}>알림 추가</button>
</div>
</div>
</main>
);
}03 バッジ件数バウンド切替 — 通知件数の値が上がるたびその表示の大きさの値が一度大きくなってから戻る方式 — 計測 累積面積 0.217% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · ボトムナビゲーション 9選 — 状態値で変わる9つの転換パターン
このデモが属する記事
ボトムナビゲーション 9選 — 状態値で変わる9つの転換パターンCSS ボトムナビゲーション:9つのうち1つは選択値が変わると強調表示の位置と幅の値が新しい場所へ一緒に移動し、1つは通知件数の値が上がるとその表示の大きさの値が一瞬大きくなってから収まり、もう1つはスクロール方向の値が変わるとバー全体の縦 この記事のデモをすべて見る


