02アイコン塗り切替 — 選択タブが変わるとその場所の塗り具合の値が低い状態から満ちた状態へ変わる方式
選択値がオンになった瞬間、その場所の塗り具合の値が最大まで上がり、他の場所の値は逆に下がります。
使いどころアイコン中心の下部ナビゲーション、選択状態をはっきり示すタブバー
FILL_RATEPULSE_ZETAscale
<main class="stage">
<div class="bf is-demo" id="bf">
<div class="bf__phone">
<div class="bf__track">
<button type="button" class="bf__tab is-active" data-idx="0" id="bfTab0" aria-current="true">
<span class="bf__iconWrap" id="bfIcon0">
<svg class="bf__icon bf__icon--outline" 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>
<svg class="bf__icon bf__icon--fill" viewBox="0 0 256 256" aria-hidden="true"><path fill="currentColor" d="M224 120v96a8 8 0 0 1-8 8h-56a8 8 0 0 1-8-8v-52a4 4 0 0 0-4-4h-40a4 4 0 0 0-4 4v52a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-96a16 16 0 0 1 4.69-11.31l80-80a16 16 0 0 1 22.62 0l80 80A16 16 0 0 1 224 120"/></svg>
</span>
<span class="bf__label">ホーム</span>
</button>
<button type="button" class="bf__tab" data-idx="1" id="bfTab1">
<span class="bf__iconWrap" id="bfIcon1">
<svg class="bf__icon bf__icon--outline" 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>
<svg class="bf__icon bf__icon--fill" viewBox="0 0 256 256" aria-hidden="true"><path fill="currentColor" d="M168 112a56 56 0 1 1-56-56a56 56 0 0 1 56 56m61.66 117.66a8 8 0 0 1-11.32 0l-50.06-50.07a88 88 0 1 1 11.32-11.31l50.06 50.06a8 8 0 0 1 0 11.32M112 184a72 72 0 1 0-72-72a72.08 72.08 0 0 0 72 72"/></svg>
</span>
<span class="bf__label">検索</span>
</button>
<button type="button" class="bf__tab" data-idx="2" id="bfTab2">
<span class="bf__iconWrap" id="bfIcon2">
<svg class="bf__icon bf__icon--outline" 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>
<svg class="bf__icon bf__icon--fill" viewBox="0 0 256 256" aria-hidden="true"><path fill="currentColor" 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-32"/></svg>
</span>
<span class="bf__label">ショッピング</span>
</button>
<button type="button" class="bf__tab" data-idx="3" id="bfTab3">
<span class="bf__iconWrap" id="bfIcon3">
<svg class="bf__icon bf__icon--outline" 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>
<svg class="bf__icon bf__icon--fill" viewBox="0 0 256 256" aria-hidden="true"><path fill="currentColor" d="M230.93 220a8 8 0 0 1-6.93 4H32a8 8 0 0 1-6.92-12c15.23-26.33 38.7-45.21 66.09-54.16a72 72 0 1 1 73.66 0c27.39 8.95 50.86 27.83 66.09 54.16a8 8 0 0 1 .01 8"/></svg>
</span>
<span class="bf__label">プロフィール</span>
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 이 채움 정도(fillAmt)를 지수 평활로 목표(0/1)에 붙이고, 활성화 순간엔
// pulseScale() 감쇠 코사인 진동으로 아이콘이 살짝 튀어올랐다 가라앉는 스케일 펄스를 얹는다.
@include stage($stage-orange);
.bf__phone { width: 100%; max-width: 300px; }
.bf__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;
}
.bf__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);
}
.bf__iconWrap { position: relative; width: 22px; height: 22px; transform-origin: 50% 50%; }
.bf__icon { position: absolute; inset: 0; width: 22px; height: 22px; will-change: opacity; }
.bf__icon--fill { opacity: 0; color: $subject-orange-deep; }
.bf__icon--outline { opacity: 1; }
.bf__label { font-size: 10px; font-weight: 700; line-height: 1; }
.bf__tab.is-active { color: $subject-orange-deep; }
.bf__tab.is-active .bf__label { font-weight: 800; }
@media (max-width: 340px) {
.bf__phone { max-width: 230px; }
.bf__tab { padding: $sp-1 $sp-2; }
.bf__iconWrap, .bf__icon { width: 19px; height: 19px; }
}
@media (prefers-reduced-motion: reduce) {
.bf__icon, .bf__iconWrap { 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);
}
.bf__phone {
width: 100%;
max-width: 300px;
}
.bf__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;
}
.bf__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);
}
.bf__iconWrap {
position: relative;
width: 22px;
height: 22px;
transform-origin: 50% 50%;
}
.bf__icon {
position: absolute;
inset: 0;
width: 22px;
height: 22px;
will-change: opacity;
}
.bf__icon--fill {
opacity: 0;
color: #c93000;
}
.bf__icon--outline {
opacity: 1;
}
.bf__label {
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.bf__tab.is-active {
color: #c93000;
}
.bf__tab.is-active .bf__label {
font-weight: 800;
}
@media (max-width: 340px) {
.bf__phone {
max-width: 230px;
}
.bf__tab {
padding: 4px 8px;
}
.bf__iconWrap, .bf__icon {
width: 19px;
height: 19px;
}
}
@media (prefers-reduced-motion: reduce) {
.bf__icon, .bf__iconWrap {
transition: none;
}
}(function () {
var HOLD_MS = 650; // 계산 겹 상수: 자동 루프가 각 탭에 머무는 시간
var FILL_RATE = 0.012; // 계산 겹: 채움 정도 지수 평활 상수(ms 기준)
var PULSE_DUR = 360; // ms, 활성화 순간 스케일 펄스 지속
var PULSE_FREQ = 2 * Math.PI * 1.6 / PULSE_DUR; // 계산 겹: 펄스 각속도
var PULSE_ZETA = 4.4; // 계산 겹: 펄스 감쇠 상수
var TAB_COUNT = 4;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var bf = document.getElementById('bf');
var tabs = [0, 1, 2, 3].map(function (i) { return document.getElementById('bfTab' + i); });
var wraps = [0, 1, 2, 3].map(function (i) { return document.getElementById('bfIcon' + i); });
var isDemo = true;
var activeIdx = 0;
var fillAmt = [1, 0, 0, 0];
var pulseStart = [-9999, -9999, -9999, -9999];
var cycleStart = 0;
var lastTs = 0;
function pulseScale(t) {
// 계산 겹: 활성화 순간의 스케일 펄스 — 감쇠 코사인 진동으로 목표를 넘어섰다 되돌아온다
if (t < 0 || t > PULSE_DUR) return 1;
var amp = Math.exp(-PULSE_ZETA * (t / PULSE_DUR));
return 1 + amp * (1 - Math.cos(PULSE_FREQ * t)) * 0.16;
}
function setActive(idx, ts) {
if (idx === activeIdx) return;
activeIdx = idx;
pulseStart[idx] = ts;
tabs.forEach(function (t, i) {
t.classList.toggle('is-active', i === idx);
t.setAttribute('aria-current', i === idx ? 'true' : 'false');
});
}
function render(ts) {
wraps.forEach(function (w, i) {
var outline = w.children[0], fill = w.children[1];
var f = fillAmt[i];
fill.style.opacity = f.toFixed(3);
outline.style.opacity = (1 - f * 0.85).toFixed(3);
var s = pulseScale(ts - pulseStart[i]);
w.style.transform = 'scale(' + s.toFixed(3) + ')';
});
}
function human() {
if (isDemo) { bf.classList.remove('is-demo'); isDemo = false; }
}
tabs.forEach(function (t, i) {
t.addEventListener('click', function () { human(); setActive(i, performance.now()); });
});
function loop(ts) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
var dt = ts - lastTs;
lastTs = ts;
if (isDemo) {
var elapsed = ts - cycleStart;
var idx = Math.floor(elapsed / HOLD_MS) % TAB_COUNT;
if (idx !== activeIdx) setActive(idx, ts);
}
for (var i = 0; i < TAB_COUNT; i++) {
var target = i === activeIdx ? 1 : 0;
fillAmt[i] += (target - fillAmt[i]) * (1 - Math.exp(-FILL_RATE * dt));
}
render(ts);
requestAnimationFrame(loop);
}
render(0);
if (!reduce) {
requestAnimationFrame(loop);
}
})();// IconFillSwap — vanilla 판(run/367/demo/02-bottom-nav-bar-icon-fill-swap/index.html) 의 React 포트.
// fillAmt(0~1) 를 지수 평활로 목표까지 움직이고, 활성화 순간엔 pulseScale() 감쇠 코사인
// 진동으로 아이콘이 살짝 튀어올랐다 가라앉는다 — 둘 다 rAF 계산, CSS transition 아님.
import { useEffect, useRef } from "react";
import styles from "./IconFillSwap.module.scss";
type Box<T> = { current: T };
const HOLD_MS = 650; // 계산 겹 상수: 자동 루프가 각 탭에 머무는 시간
const FILL_RATE = 0.012; // 계산 겹: 채움 정도 지수 평활 상수(ms 기준)
const PULSE_DUR = 360; // ms, 활성화 순간 스케일 펄스 지속
const PULSE_FREQ = (2 * Math.PI * 1.6) / PULSE_DUR; // 계산 겹: 펄스 각속도
const PULSE_ZETA = 4.4; // 계산 겹: 펄스 감쇠 상수
const TABS = [
{ label: "홈",
outline: ["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", "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"],
fill: "M224 120v96a8 8 0 0 1-8 8h-56a8 8 0 0 1-8-8v-52a4 4 0 0 0-4-4h-40a4 4 0 0 0-4 4v52a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-96a16 16 0 0 1 4.69-11.31l80-80a16 16 0 0 1 22.62 0l80 80A16 16 0 0 1 224 120" },
{ label: "검색",
outline: ["M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80", "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"],
fill: "M168 112a56 56 0 1 1-56-56a56 56 0 0 1 56 56m61.66 117.66a8 8 0 0 1-11.32 0l-50.06-50.07a88 88 0 1 1 11.32-11.31l50.06 50.06a8 8 0 0 1 0 11.32M112 184a72 72 0 1 0-72-72a72.08 72.08 0 0 0 72 72" },
{ label: "쇼핑",
outline: ["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", "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"],
fill: "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-32" },
{ label: "프로필",
outline: ["M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64", "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"],
fill: "M230.93 220a8 8 0 0 1-6.93 4H32a8 8 0 0 1-6.92-12c15.23-26.33 38.7-45.21 66.09-54.16a72 72 0 1 1 73.66 0c27.39 8.95 50.86 27.83 66.09 54.16a8 8 0 0 1 .01 8" },
];
function pulseScale(t: number): number {
if (t < 0 || t > PULSE_DUR) return 1;
const amp = Math.exp(-PULSE_ZETA * (t / PULSE_DUR));
return 1 + amp * (1 - Math.cos(PULSE_FREQ * t)) * 0.16;
}
export default function IconFillSwap() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const tabRefs = useRef([null, null, null, null]) as unknown as Box<(HTMLButtonElement | null)[]>;
const wrapRefs = useRef([null, null, null, null]) as unknown as Box<(HTMLSpanElement | 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;
const fillAmt = [1, 0, 0, 0];
const pulseStart = [-9999, -9999, -9999, -9999];
let cycleStart = 0, lastTs = 0;
function setActive(idx: number, ts: number) {
if (idx === activeIdx) return;
activeIdx = idx;
pulseStart[idx] = ts;
tabRefs.current.forEach((t, i) => {
if (!t) return;
t.classList.toggle(styles.isActive, i === idx);
t.setAttribute("aria-current", i === idx ? "true" : "false");
});
}
function render(ts: number) {
wrapRefs.current.forEach((w, i) => {
if (!w) return;
const outline = w.children[0] as HTMLElement;
const fill = w.children[1] as HTMLElement;
const f = fillAmt[i];
fill.style.opacity = f.toFixed(3);
outline.style.opacity = (1 - f * 0.85).toFixed(3);
const s = pulseScale(ts - pulseStart[i]);
w.style.transform = "scale(" + s.toFixed(3) + ")";
});
}
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
const handlers = TABS.map((_, i) => () => { human(); setActive(i, performance.now()); });
tabRefs.current.forEach((t, i) => t?.addEventListener("click", handlers[i]));
function loop(ts: number) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
const dt = ts - lastTs;
lastTs = ts;
if (isDemo) {
const elapsed = ts - cycleStart;
const idx = Math.floor(elapsed / HOLD_MS) % TABS.length;
if (idx !== activeIdx) setActive(idx, ts);
}
for (let i = 0; i < TABS.length; i++) {
const target = i === activeIdx ? 1 : 0;
fillAmt[i] += (target - fillAmt[i]) * (1 - Math.exp(-FILL_RATE * dt));
}
render(ts);
rafRef.current = requestAnimationFrame(loop);
}
render(0);
if (!reduce) { rafRef.current = requestAnimationFrame(loop); }
return () => {
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}>
{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; }}
>
<span className={styles.iconWrap} ref={(el: HTMLSpanElement | null) => { wrapRefs.current[i] = el; }}>
<svg className={styles.icon + " " + styles.iconOutline} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor"><path d={t.outline[0]} opacity=".2" /><path d={t.outline[1]} /></g>
</svg>
<svg className={styles.icon + " " + styles.iconFill} viewBox="0 0 256 256" aria-hidden="true">
<path fill="currentColor" d={t.fill} />
</svg>
</span>
<span className={styles.label}>{t.label}</span>
</button>
))}
</div>
</div>
</div>
</main>
);
}02 アイコン塗り切替 — 選択タブが変わるとその場所の塗り具合の値が低い状態から満ちた状態へ変わる方式 — 計測 累積面積 1.267% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · ボトムナビゲーション 9選 — 状態値で変わる9つの転換パターン
このデモが属する記事
ボトムナビゲーション 9選 — 状態値で変わる9つの転換パターンCSS ボトムナビゲーション:9つのうち1つは選択値が変わると強調表示の位置と幅の値が新しい場所へ一緒に移動し、1つは通知件数の値が上がるとその表示の大きさの値が一瞬大きくなってから収まり、もう1つはスクロール方向の値が変わるとバー全体の縦 この記事のデモをすべて見る

