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

タブの下線が滑って移り、中身が向きに合わせて切り替わる

デモ

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

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

下線は 1 本の要素で、押したタブの位置と幅へ滑らせます。行き先は、タブの offsetLeft と offsetWidth を測って決めます。中身は、不透明な地のパネルを同じ場所に重ねておき、押されるたびに次のパネルをいちばん上に重ねて、横へずらしながらフェードインします。出ていくパネルまで透かすと途中で後ろの地がのぞくので、前のパネルは下に残したまま、覆い終わってから隠します。

下線は 0.5 秒の power3.inOut で、動き出しと着く所をゆっくりにし、タブからタブへ吸い付くように見せます。中身は 40px ほど横から 0.45 秒の power3.out で入り、右のタブへ進むときは右から、左へ戻るときは左から入ります。向きがそろうので、横に並んだ中身を送っているように見えます。タブの文字の色は 0.3 秒で入れ替えます。

位置と幅は、書体が読み込まれてから測ります。代わりの書体で表示されているうちに測ると、字の幅が違うので、下線がタブの文字からずれたまま残ります。fonts.ready を待ってから置き直し、画面の幅が変わったときも測り直します。

タブの列は tablist、それぞれのタブは aria-selected で、選んでいることを伝えます。Tab キーで入るのは選んでいるタブだけにし、隣のタブへは ← → キーで移って、移った先をすぐに選びます。選んでいないパネルは、透明にするだけでなく要素ごと見えなくしておくと、読み上げと Tab の移動からも外れます。

押すたびにトゥイーンを作り直し、前のものは止めるので、続けて押しても下線は今の位置から次のタブへ向かいます。進む向きを見せたくないときは、横のずれを 0 にしてフェードだけで入れ替えます。下線を文字の幅だけにしたいときは、タブの中の文字を包む要素を測ります。料金表・機能の紹介・設定画面の切り替えに向いています。

コード

HTML
<div class="tabs">
  <!-- Tab キーで入るのは選んでいるタブだけ(tabindex="-1" のタブへは ← → キーで移る) -->
  <div class="tabs__list" role="tablist" aria-label="製品の紹介">
    <button class="tabs__tab" type="button" role="tab" id="tab-overview"
            aria-selected="true" aria-controls="panel-overview">概要</button>
    <button class="tabs__tab" type="button" role="tab" id="tab-features"
            aria-selected="false" aria-controls="panel-features" tabindex="-1">機能</button>
    <button class="tabs__tab" type="button" role="tab" id="tab-pricing"
            aria-selected="false" aria-controls="panel-pricing" tabindex="-1">料金</button>
    <button class="tabs__tab" type="button" role="tab" id="tab-reviews"
            aria-selected="false" aria-controls="panel-reviews" tabindex="-1">お客様の声</button>
    <!-- 下線は 1 本だけ置き、選んだタブの位置と幅へ動かす -->
    <span class="tabs__indicator" aria-hidden="true"></span>
  </div>

  <!-- パネルは同じ升に重ねる。選んでいないパネルは visibility: hidden で、読み上げと Tab の移動からも外れる -->
  <div class="tabs__panels">
    <div class="tabs__panel" id="panel-overview" role="tabpanel" aria-labelledby="tab-overview" tabindex="0">
      <h3 class="tabs__title">タスクも予定も、<br>ひとつの画面に</h3>
      <p class="tabs__text">ここに説明文が入ります。</p>
    </div>
    <div class="tabs__panel" id="panel-features" role="tabpanel" aria-labelledby="tab-features" tabindex="0">
      <h3 class="tabs__title">チームの使い方に<br>合わせられる</h3>
      <p class="tabs__text">ここに説明文が入ります。</p>
    </div>
    <div class="tabs__panel" id="panel-pricing" role="tabpanel" aria-labelledby="tab-pricing" tabindex="0">
      <h3 class="tabs__title">わかりやすい<br>料金プラン</h3>
      <p class="tabs__text">ここに説明文が入ります。</p>
    </div>
    <div class="tabs__panel" id="panel-reviews" role="tabpanel" aria-labelledby="tab-reviews" tabindex="0">
      <h3 class="tabs__title">2万社が<br>導入しています</h3>
      <p class="tabs__text">ここに説明文が入ります。</p>
    </div>
  </div>
</div>

CSS
.tabs {
  --ink: #262626; /* 選んでいるタブ・下線・本文 */
  --idle: #6b6b6b; /* 選んでいないタブ */
  --line: rgba(38, 38, 38, 0.12); /* タブの列の下の罫線 */
  --ground: #f2f1ed; /* パネルの地。重ねたパネルが透けないよう、ページの地と同じ色で塗る */
  color: var(--ink);
}

.tabs__list {
  position: relative;
  display: flex;
  border-bottom: 1px solid var(--line);
}

.tabs__tab {
  height: 48px;
  padding: 0 24px;
  border: 0;
  background: none;
  color: var(--idle);
  font: inherit;
  font-size: 15px; /* font: inherit の後に書く(先だと打ち消される) */
  font-weight: 600;
  letter-spacing: 0.04em;
  white-space: nowrap;
  cursor: pointer;
  transition: color 0.3s linear;
}

.tabs__tab[aria-selected="true"] { color: var(--ink); }
.tabs__tab:focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; }

/* 下線。左端に置いておき、x と width を JS が選んだタブに合わせる。bottom: -1px で罫線に重ねる */
.tabs__indicator {
  position: absolute;
  left: 0;
  bottom: -1px;
  width: 0;
  height: 2px;
  background-color: var(--ink);
}

/* パネルを同じ升に重ねる。枠の高さは、いちばん長いパネルに合う */
.tabs__panels {
  display: grid;
  margin-top: 48px;
}

.tabs__panel {
  grid-area: 1 / 1;
  background-color: var(--ground);
}

/* JS が動く前は、最初のパネルだけを見せる */
.tabs__panel:not(:first-child) { visibility: hidden; }
.tabs__panel:focus-visible { outline: 2px solid var(--ink); outline-offset: 8px; }

.tabs__title {
  margin: 0 0 24px;
  font-size: 44px;
  font-weight: 800;
  line-height: 1.1;
  letter-spacing: -0.02em;
}

.tabs__text {
  margin: 0;
  font-size: 18px;
  line-height: 1.8;
  letter-spacing: 0.04em;
  word-break: auto-phrase; /* 和文を文節で折り返す */
}

@media (prefers-reduced-motion: reduce) {
  .tabs__tab { transition: none; }
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const SHIFT = 40; // 中身が横へずれる量(px)

document.querySelectorAll(".tabs").forEach((root) => {
  const tabs = [...root.querySelectorAll('[role="tab"]')];
  const panels = tabs.map((tab) => document.getElementById(tab.getAttribute("aria-controls")));
  const indicator = root.querySelector(".tabs__indicator");
  let current = Math.max(0, tabs.findIndex((tab) => tab.getAttribute("aria-selected") === "true"));
  let top = 1; // いちばん上に重ねたパネルの z-index

  // 下線を選んでいるタブに合わせる。offsetLeft と offsetWidth は、transform の影響を受けない
  const place = () => gsap.set(indicator, { x: tabs[current].offsetLeft, width: tabs[current].offsetWidth });
  panels.forEach((panel, i) => gsap.set(panel, { autoAlpha: i === current ? 1 : 0 }));

  function select(next) {
    if (next === current) return;
    const dir = next > current ? 1 : -1; // 右のタブへ進むなら右から、左へ戻るなら左から入る
    const panel = panels[next];
    current = next;
    tabs.forEach((tab, i) => {
      tab.setAttribute("aria-selected", String(i === next));
      tab.tabIndex = i === next ? 0 : -1;
    });

    // 連打しても、今の位置から次のタブへ向かう(overwrite で前のトゥイーンを止める)
    gsap.to(indicator, {
      x: tabs[next].offsetLeft,
      width: tabs[next].offsetWidth,
      duration: 0.5 / SPEED,
      ease: "power3.inOut",
      overwrite: "auto",
    });

    // 次のパネルを一番上に重ねてフェードインする。出ていくパネルまで透かすと、途中で後ろの地が透けるので、
    // 前のパネルは下に残し、覆い終わってから隠す
    gsap.set(panel, { zIndex: ++top });
    gsap.fromTo(
      panel,
      { autoAlpha: 0, x: SHIFT * dir },
      {
        autoAlpha: 1,
        x: 0,
        duration: 0.45 / SPEED,
        ease: "power3.out",
        overwrite: "auto",
        onComplete: () => {
          if (panel !== panels[current]) return; // 途中で次のタブが押されたら、そちらに任せる
          panels.forEach((other) => other !== panel && gsap.set(other, { autoAlpha: 0 }));
        },
      }
    );
  }

  tabs.forEach((tab, i) => tab.addEventListener("click", () => select(i)));

  // ← → で隣のタブへ(端では反対の端へ回る)。Home と End で先頭と末尾へ。移った先をすぐに選ぶ
  root.querySelector('[role="tablist"]').addEventListener("keydown", (event) => {
    const step = { ArrowRight: current + 1, ArrowLeft: current - 1, Home: 0, End: tabs.length - 1 }[event.key];
    if (step === undefined) return;
    event.preventDefault();
    select((step + tabs.length) % tabs.length);
    tabs[current].focus();
  });

  // 書体が読み込まれると、タブの幅が変わる。読み込みの後と、枠の幅が変わるたびに測り直す
  document.fonts.ready.then(place);
  new ResizeObserver(place).observe(root);
});

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

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