デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
画面の下のタブを押すと、濃いスレートの丸がそのタブへ滑って移り、少し沈んでから弾んで持ち上がります。押したタブのアイコンは丸の中へ持ち上がって白くなり、下に名前が出ます。バーの上端のくぼみは丸と同じ中心の弧で、丸の位置と沈みから毎回描き直すので、動いている間もいつも同じ隙間で丸を包みます。
丸は 0.5 秒の power3.inOut で横へ滑らせながら、0.22 秒で下へ 6px 沈め、0.4 秒の back.out(2.2) で戻します。戻りの行き過ぎで一度上へ抜けるので、着いたところで弾んで見えます。アイコンは 0.28 秒遅れて丸の中心まで持ち上げ、前のタブのアイコンは 0.25 秒で列へ戻して灰色にします。
バーは SVG の path で描き、上端のくぼみを「丸の半径 + 隙間」の弧にして、小さな肩の曲線で上端につなぎます。丸とくぼみを別々にトゥイーンすると、途中でずれて隙間が偏ります。丸の位置と沈みを 1 つのオブジェクトの値としてトゥイーンし、onUpdate で丸とくぼみを同じ値から描きます。
画面の中身は fade through で切り替えます。前の画面が 0.1 秒で消えきってから次の画面を 0.24 秒で出すので、2 つの画面の文字が重なりません。今の画面のリンクにだけ aria-current を付け、タブの名前は opacity だけで隠すので、選んでいないタブも名前が読み上げに届きます。続けて押したときは前の動きを止め、今の位置から次のタブへ向かいます。
丸の色・大きさ・隙間・沈む量は、CSS の変数で変えられます。ページを読み直して移るサイトでは動きが見えないので、PJAX のようにページを読み直さずに移る作りと組み合わせます。そのときは画面の切り替えを外し、リンクの既定の動作を止めずにバーの動きだけを使います。スマホ向けのアプリのようなサイトや PWA、アプリの紹介ページに向いています。
コード
<!-- 画面の中身。今の画面だけを見せ、ほかは hidden にしておく(id はタブのリンクの # とそろえる) -->
<main class="screens">
<section class="screen" id="home" aria-labelledby="home-title">
<h1 class="screen__title" id="home-title">ホーム</h1>
<p class="screen__text">ここに説明文が入ります。</p>
</section>
<section class="screen" id="explore" aria-labelledby="explore-title" hidden>
<h1 class="screen__title" id="explore-title">さがす</h1>
<p class="screen__text">ここに説明文が入ります。</p>
</section>
<section class="screen" id="saved" aria-labelledby="saved-title" hidden>
<h1 class="screen__title" id="saved-title">保存済み</h1>
<p class="screen__text">ここに説明文が入ります。</p>
</section>
<section class="screen" id="profile" aria-labelledby="profile-title" hidden>
<h1 class="screen__title" id="profile-title">マイページ</h1>
<p class="screen__text">ここに説明文が入ります。</p>
</section>
</main>
<!-- 画面の下のタブバー。今の画面のリンクにだけ aria-current="page" を付ける -->
<nav class="tab-bar" aria-label="メインメニュー">
<!-- 上端のくぼみのある面。形(d)は JS が丸の位置から描く -->
<svg class="tab-bar__shape" aria-hidden="true"><path class="tab-bar__fill" /><path class="tab-bar__edge" /></svg>
<span class="tab-bar__bubble" aria-hidden="true"></span>
<ul class="tab-bar__list">
<li>
<a class="tab-bar__tab" href="#home" aria-current="page">
<svg class="tab-bar__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 10.5 12 4l8 6.5V19a1.5 1.5 0 0 1-1.5 1.5H15v-6H9v6H5.5A1.5 1.5 0 0 1 4 19v-8.5z" /></svg>
<!-- ラベルは opacity だけで隠すので、選んでいないタブも名前が読み上げに残る -->
<span class="tab-bar__label">ホーム</span>
</a>
</li>
<li>
<a class="tab-bar__tab" href="#explore">
<svg class="tab-bar__icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5" /><path d="M15.5 8.5l-2 5-5 2 2-5 5-2z" /></svg>
<span class="tab-bar__label">さがす</span>
</a>
</li>
<li>
<a class="tab-bar__tab" href="#saved">
<svg class="tab-bar__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10z" /></svg>
<span class="tab-bar__label">保存済み</span>
</a>
</li>
<li>
<a class="tab-bar__tab" href="#profile">
<svg class="tab-bar__icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8.5" r="3.8" /><path d="M4.5 20c.9-3.6 3.9-5.6 7.5-5.6s6.6 2 7.5 5.6" /></svg>
<span class="tab-bar__label">マイページ</span>
</a>
</li>
</ul>
</nav>
.screens,
.tab-bar {
--ink: #262626; /* 文字・選んだタブのラベル */
--bubble-fill: #4a5b66; /* 丸(墨のスレート。白いアイコンとの比は 7:1) */
--idle: #6b6b6b; /* 選んでいないアイコン */
--on-ink: #ffffff; /* 丸の中に持ち上げたアイコン */
--surface: #ffffff; /* バーの面 */
--line: rgba(38, 38, 38, 0.12); /* バーの上端の罫線 */
}
.screens {
box-sizing: border-box;
min-height: 100vh;
padding: 48px 24px 144px; /* 下はタブバーに隠れないよう空ける */
color: var(--ink);
}
.screen__title { margin: 0 0 16px; font-size: 44px; font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.screen__text { margin: 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; } /* 和文を文節で折り返す */
/* 寸法は px で書く(JS が数として読み、丸とくぼみを描く) */
.tab-bar {
--bubble: 56px; /* 丸の直径 */
--gap: 7px; /* くぼみと丸の隙間 */
--raise: 4px; /* 丸の中心を、バーの上端より上に置く量 */
--dip: 6px; /* 移る途中で沈む量 */
--row: 30px; /* 選んでいないアイコンの中心の高さ(バーの上端から) */
position: fixed;
z-index: 10;
right: 0;
bottom: 0;
left: 0;
max-width: 480px;
height: calc(72px + env(safe-area-inset-bottom, 0px));
margin: 0 auto;
}
/* 面は SVG の path で描く。上端の罫線は、くぼみに沿って引く */
.tab-bar__shape { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.tab-bar__fill { fill: var(--surface); }
.tab-bar__edge { fill: none; stroke: var(--line); stroke-width: 1; }
/* 丸。位置と沈みは JS が transform で入れる(影は付けない) */
.tab-bar__bubble {
position: absolute;
top: calc((var(--raise) + var(--bubble) / 2) * -1);
left: 0;
width: var(--bubble);
height: var(--bubble);
border-radius: 50%;
background-color: var(--bubble-fill);
}
.tab-bar__list { display: flex; height: 100%; margin: 0; padding: 0 8px; list-style: none; }
.tab-bar__list li { flex: 1; }
.tab-bar__tab {
position: relative;
display: block;
height: 100%;
color: var(--idle);
text-decoration: none;
-webkit-tap-highlight-color: transparent;
}
/* アイコンは列の高さに置き、選ばれたら JS が丸の中心まで持ち上げて白くする */
.tab-bar__icon {
position: absolute;
top: calc(var(--row) - 12px);
left: 50%;
width: 24px;
height: 24px;
margin-left: -12px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.tab-bar__label {
position: absolute;
top: 40px;
right: 0;
left: 0;
color: var(--ink);
font-size: 11.5px;
font-weight: 600;
line-height: 1.2;
letter-spacing: 0.04em;
text-align: center;
white-space: nowrap; /* 和文は字の途中でも折り返すため */
opacity: 0; /* 選んだタブだけ、JS が出す */
}
.tab-bar__tab:focus-visible { outline: 2px solid var(--ink); outline-offset: -4px; border-radius: 8px; }
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const SHOULDER = 12; // くぼみの肩(上端とくぼみの弧をつなぐ曲線の幅)
const SHOULDER_DEPTH = 6; // 肩が弧につながる深さ
document.querySelectorAll(".tab-bar").forEach((bar) => {
const tabs = [...bar.querySelectorAll(".tab-bar__tab")];
const icons = tabs.map((tab) => tab.querySelector(".tab-bar__icon"));
const labels = tabs.map((tab) => tab.querySelector(".tab-bar__label"));
const screens = tabs.map((tab) => document.getElementById(tab.hash.slice(1)));
const bubble = bar.querySelector(".tab-bar__bubble");
const fill = bar.querySelector(".tab-bar__fill");
const edge = bar.querySelector(".tab-bar__edge");
// 寸法と色は CSS が正。GSAP は var(--name) のままでは読めないので、値にして渡す
const css = getComputedStyle(bar);
const px = (name) => parseFloat(css.getPropertyValue(name));
const color = (name) => css.getPropertyValue(name).trim();
const radius = px("--bubble") / 2;
const R = radius + px("--gap"); // くぼみの弧の半径
const raise = px("--raise");
const lift = px("--row") + raise; // アイコンを丸の中心まで持ち上げる量
const state = { x: 0, dip: 0 };
let current = Math.max(0, tabs.findIndex((tab) => tab.getAttribute("aria-current") === "page"));
let centers = [];
let size = [0, 0]; // バーの幅と高さ
let move;
// 上端のくぼみは、丸と同じ中心の弧。丸の位置と沈みから毎回描き直すので、動いている間も同じ隙間で丸を包む
const render = () => {
const x = Math.round(state.x);
const [w, h] = size;
const cy = state.dip - raise;
const xa = Math.sqrt(R * R - cy * cy); // 弧が上端を横切る半幅
const x1 = Math.sqrt(R * R - (SHOULDER_DEPTH - cy) ** 2);
const top = `M0 0H${Math.max(0, x - xa - SHOULDER)}Q${x - xa} 0 ${x - x1} ${SHOULDER_DEPTH}A${R} ${R} 0 0 0 ${x + x1} ${SHOULDER_DEPTH}Q${x + xa} 0 ${Math.min(w, x + xa + SHOULDER)} 0H${w}`;
edge.setAttribute("d", top);
fill.setAttribute("d", `${top}V${h}H0Z`);
bubble.style.transform = `translate(${x - radius}px, ${state.dip}px)`;
};
// タブの中心を測る。画面の幅が変わるたびに測り直し、丸を今のタブに置き直す
const place = () => {
centers = tabs.map((tab) => tab.offsetLeft + tab.offsetWidth / 2);
size = [bar.offsetWidth, bar.offsetHeight];
if (!move?.isActive()) state.x = centers[current];
render();
};
gsap.set(icons, { y: (i) => (i === current ? -lift : 0), color: (i) => color(i === current ? "--on-ink" : "--idle") });
gsap.set(labels, { opacity: (i) => (i === current ? 1 : 0) });
screens.forEach((screen, i) => (screen.hidden = i !== current));
new ResizeObserver(place).observe(bar);
function select(next) {
if (next === current) return;
tabs[current].removeAttribute("aria-current");
tabs[next].setAttribute("aria-current", "page");
current = next;
const others = (list) => list.filter((_, i) => i !== next);
// 続けて押したら、前の動きを止めて今の位置から向かう。ほかのタブはまとめて休みの形へ戻す
move?.kill();
move = gsap
.timeline({ onUpdate: render })
// 丸は滑りながら少し沈み、着くところで弾んで持ち上がる
.to(state, { x: centers[next], duration: 0.5, ease: "power3.inOut" }, 0)
.to(state, { dip: px("--dip"), duration: 0.22, ease: "power2.in" }, 0)
.to(state, { dip: 0, duration: 0.4, ease: "back.out(2.2)" }, 0.22)
.to(others(icons), { y: 0, color: color("--idle"), duration: 0.25, ease: "power2.in" }, 0)
.to(others(labels), { opacity: 0, duration: 0.15, ease: "none" }, 0)
.to(icons[next], { y: -lift, color: color("--on-ink"), duration: 0.45, ease: "back.out(1.8)" }, 0.28)
.fromTo(labels[next], { opacity: 0, y: 6 }, { opacity: 1, y: 0, duration: 0.3, ease: "power2.out", immediateRender: false }, 0.35)
// 画面は fade through: 前の画面が消えきってから次の画面を出す(2 つの画面の文字が重ならない)
.to(screens.filter((screen) => !screen.hidden), { opacity: 0, duration: 0.1, ease: "power1.in" }, 0)
.add(() => screens.forEach((screen, i) => (screen.hidden = i !== next)), 0.1)
.fromTo(screens[next], { opacity: 0 }, { opacity: 1, duration: 0.24, ease: "power1.out", immediateRender: false }, 0.1)
.timeScale(SPEED);
}
tabs.forEach((tab, i) =>
tab.addEventListener("click", (event) => {
// 画面はこのページの中で切り替える(ページを移るサイトなら、この行と画面の切り替えを外す)
event.preventDefault();
select(i);
})
);
});