デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
画面の下のタブバーでは、選んでいるタブに濃いスレートカラーの丸が付いています。別のタブを押すと、丸はそこへ滑りながら少し下がり、弾むように持ち上がって収まります。押したタブのアイコンは丸の中まで持ち上がって白くなり、その下に名前が現れます。
バーの上端には、丸を受け止めるくぼみがあります。バーは SVG の path で描き、くぼみは丸と同じ中心の弧に、小さな肩の曲線をつないだ形です。丸とくぼみを別々にトゥイーンすると途中で位置がずれ、隙間が片側に偏ります。丸の位置と下がり具合をひとつのオブジェクトの値としてトゥイーンし、onUpdate で丸とくぼみを同じ値から描き直す形を選びました。動いている間も、くぼみはいつも同じ隙間で丸を包みます。
横への移動は0.5秒の power3.inOut で、そこに6px下げてから back.out(2.2) で戻す縦の動きを重ねています。back.out は目標を一度行き過ぎてから戻るので、丸が少し上へ抜けてから収まり、そこが弾みに見えます。アイコンは丸より少し遅れて持ち上げ、前のタブのアイコンは元の高さへ戻してグレーにします。丸の色や大きさ、隙間、下がる量は CSS 変数で変えられます。
画面の中身は fade through で切り替え、前の画面を消しきってから次を表示するので、前後の画面の文字が重なりません。今の画面のリンクにだけ aria-current を付け、タブの名前は opacity だけで隠しているので、選んでいないタブの名前も読み上げに届きます。リンクを押すたびにページを読み直すサイトでは、この動きは見えません。PJAX のように読み直さずに移る作りと組み合わせ、画面を切り替える処理は外して、リンクの既定の動作を止めずにバーの動きだけを使ってください。
コード
<!-- 画面の中身。今の画面だけを見せ、ほかは 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);
})
);
});