本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ナビゲーション

スマホのタブバーで、押したタブへ丸が滑って移り、アイコンが丸の中へ持ち上がる

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
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、アプリの紹介ページに向いています。

コード

HTML
<!-- 画面の中身。今の画面だけを見せ、ほかは 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>

CSS
.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; }

JavaScript
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);
    })
  );
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。