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

横に溢れるタブが矢印で送られ、選んだタブが帯の中央に寄る

デモ

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

仕様

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

しくみと調整

画面に収まらない数のタブを、窓の中に並べた帯ごと横へ送ります。両端の矢印で送り、タブを押すと下線がそこへ滑って、同時にそのタブが帯の中央へ寄ります。端まで送ると、その側の矢印と、途切れを示すグラデーションが薄くなります。下線が滑るだけのタブとの違いは、収まらない帯を送るところにあります。

送りも下線も 0.5 秒の power3.inOut で、長さと曲線をそろえてあります。ひと続きの動きに見えるので、押したタブを目で追ったまま帯の移動についていけます。矢印 1 回で送るのは窓の幅の 55% で、送り先が端を越えるときは端で止めます。タブの文字の色は、下線がその文字にどれだけ重なっているかで決めるので、送っている途中も濃さが自然につながります。

位置と幅は、書体が読み込まれてから測ります。代わりの書体で表示されているうちに測ると、下線がタブの文字からずれたまま残ります。窓のはみ出しを隠すのは clip にしてください。hidden のままだと、窓の外のタブへ Tab キーで移ったときに、ブラウザが窓ごと横へ動かして帯の位置が狂います。

タブの列は tablist、それぞれのタブは aria-selected で、選んでいることを伝えます。Tab キーで入るのは選んでいるタブだけにし、隣のタブへは ← → キーで移って、移った先をすぐに選びます。キーボードで端のタブへ移ったときも中央へ寄せるので、焦点が窓の外に残りません。選んでいない中身は要素ごと見えなくして、読み上げと Tab の移動からも外します。

送る量、中央へ寄せるか端で止めるか、矢印を出すかどうかが主なつまみです。指で触る画面では矢印を隠して、横のスワイプに任せることもできます。章の多いドキュメント、記事の絞り込み、スマートフォンの横に長いタブの列に向いています。

コード

HTML
<section class="docs">
  <p class="docs__label">GUIDES</p>
  <h2 class="docs__title">ドキュメント</h2>

  <!-- 帯は窓の中に置き、窓ごと切る。矢印は両端に重ねる -->
  <div class="tabbar">
    <button class="tabbar__arrow tabbar__arrow--prev" type="button" data-step="-1" aria-label="タブを左へ送る"></button>
    <div class="tabbar__port">
      <div class="tabbar__track" role="tablist" aria-label="ドキュメントの章">
        <button class="tabbar__tab" type="button" role="tab" id="t-overview" aria-controls="p-overview" aria-selected="true">このガイドについて</button>
        <button class="tabbar__tab" type="button" role="tab" id="t-quickstart" aria-controls="p-quickstart" aria-selected="false" tabindex="-1">クイックスタート</button>
        <button class="tabbar__tab" type="button" role="tab" id="t-components" aria-controls="p-components" aria-selected="false" tabindex="-1">コンポーネント</button>
        <button class="tabbar__tab" type="button" role="tab" id="t-theming" aria-controls="p-theming" aria-selected="false" tabindex="-1">テーマの切り替え</button>
        <button class="tabbar__tab" type="button" role="tab" id="t-layout" aria-controls="p-layout" aria-selected="false" tabindex="-1">レイアウトと余白</button>
        <button class="tabbar__tab" type="button" role="tab" id="t-forms" aria-controls="p-forms" aria-selected="false" tabindex="-1">フォーム</button>
        <button class="tabbar__tab" type="button" role="tab" id="t-charts" aria-controls="p-charts" aria-selected="false" tabindex="-1">グラフの選び方</button>
        <button class="tabbar__tab" type="button" role="tab" id="t-motion" aria-controls="p-motion" aria-selected="false" tabindex="-1">アニメーション</button>
        <button class="tabbar__tab" type="button" role="tab" id="t-a11y" aria-controls="p-a11y" aria-selected="false" tabindex="-1">アクセシビリティ</button>
        <button class="tabbar__tab" type="button" role="tab" id="t-api" aria-controls="p-api" aria-selected="false" tabindex="-1">APIリファレンス</button>
        <button class="tabbar__tab" type="button" role="tab" id="t-changelog" aria-controls="p-changelog" aria-selected="false" tabindex="-1">更新履歴</button>
        <button class="tabbar__tab" type="button" role="tab" id="t-faq" aria-controls="p-faq" aria-selected="false" tabindex="-1">よくある質問</button>
        <span class="tabbar__mark" aria-hidden="true"></span>
      </div>
    </div>
    <button class="tabbar__arrow tabbar__arrow--next" type="button" data-step="1" aria-label="タブを右へ送る"></button>
  </div>

  <div class="docs__panels">
    <div class="docs__panel" id="p-overview" role="tabpanel" aria-labelledby="t-overview" tabindex="0" style="--tone: #93b2c9"><div><h3>このガイドについて</h3><p>ここに説明文が入ります。</p></div><span class="docs__art" aria-hidden="true"></span></div>
    <div class="docs__panel" id="p-quickstart" role="tabpanel" aria-labelledby="t-quickstart" tabindex="0" style="--tone: #a2c49f" hidden><div><h3>クイックスタート</h3><p>ここに説明文が入ります。</p></div><span class="docs__art" aria-hidden="true"></span></div>
    <div class="docs__panel" id="p-components" role="tabpanel" aria-labelledby="t-components" tabindex="0" style="--tone: #d9ba92" hidden><div><h3>コンポーネント</h3><p>ここに説明文が入ります。</p></div><span class="docs__art" aria-hidden="true"></span></div>
    <div class="docs__panel" id="p-theming" role="tabpanel" aria-labelledby="t-theming" tabindex="0" style="--tone: #aea0cf" hidden><div><h3>テーマの切り替え</h3><p>ここに説明文が入ります。</p></div><span class="docs__art" aria-hidden="true"></span></div>
    <div class="docs__panel" id="p-layout" role="tabpanel" aria-labelledby="t-layout" tabindex="0" style="--tone: #62839d" hidden><div><h3>レイアウトと余白</h3><p>ここに説明文が入ります。</p></div><span class="docs__art" aria-hidden="true"></span></div>
    <div class="docs__panel" id="p-forms" role="tabpanel" aria-labelledby="t-forms" tabindex="0" style="--tone: #d99590" hidden><div><h3>フォーム</h3><p>ここに説明文が入ります。</p></div><span class="docs__art" aria-hidden="true"></span></div>
    <div class="docs__panel" id="p-charts" role="tabpanel" aria-labelledby="t-charts" tabindex="0" style="--tone: #6f9770" hidden><div><h3>グラフの選び方</h3><p>ここに説明文が入ります。</p></div><span class="docs__art" aria-hidden="true"></span></div>
    <div class="docs__panel" id="p-motion" role="tabpanel" aria-labelledby="t-motion" tabindex="0" style="--tone: #a98860" hidden><div><h3>アニメーション</h3><p>ここに説明文が入ります。</p></div><span class="docs__art" aria-hidden="true"></span></div>
    <div class="docs__panel" id="p-a11y" role="tabpanel" aria-labelledby="t-a11y" tabindex="0" style="--tone: #475c6a" hidden><div><h3>アクセシビリティ</h3><p>ここに説明文が入ります。</p></div><span class="docs__art" aria-hidden="true"></span></div>
    <div class="docs__panel" id="p-api" role="tabpanel" aria-labelledby="t-api" tabindex="0" style="--tone: #857ba5" hidden><div><h3>APIリファレンス</h3><p>ここに説明文が入ります。</p></div><span class="docs__art" aria-hidden="true"></span></div>
    <div class="docs__panel" id="p-changelog" role="tabpanel" aria-labelledby="t-changelog" tabindex="0" style="--tone: #9a914b" hidden><div><h3>更新履歴</h3><p>ここに説明文が入ります。</p></div><span class="docs__art" aria-hidden="true"></span></div>
    <div class="docs__panel" id="p-faq" role="tabpanel" aria-labelledby="t-faq" tabindex="0" style="--tone: #ba7069" hidden><div><h3>よくある質問</h3><p>ここに説明文が入ります。</p></div><span class="docs__art" aria-hidden="true"></span></div>
  </div>
</section>

CSS
.docs {
  --ink: #262626; /* 見出しと、選んでいるタブ・下線 */
  --idle: #6b6b6b; /* 選んでいないタブ */
  --ground: #f2f1ed; /* ページの地。端のグラデーションもこの色 */
  --surface: #ffffff; /* 矢印の面 */
  --line: rgba(38, 38, 38, 0.12); /* 罫線 */
  --edge: 44px; /* 帯の両端に空ける、矢印のぶんの幅 */
  --left: 0; /* 左へ送れるか(JS が 0〜1 で入れる。端のグラデーションの濃さ) */
  --right: 1;
  box-sizing: border-box;
  padding: 64px clamp(24px, 7vw, 96px);
  background-color: var(--ground);
  color: var(--ink);
  font: 400 15px/1.5 system-ui, sans-serif;
}

.docs__label { margin: 0; color: var(--idle); font-size: 12px; font-weight: 700; letter-spacing: 0.14em; }
.docs__title { margin: 24px 0 40px; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }

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

/* 窓は clip で切る。hidden だと、外のタブへ Tab で移ったときにブラウザが窓ごと横へ動かす */
.tabbar__port { position: relative; margin: 0 var(--edge); overflow: clip; }
.tabbar__track { position: relative; display: flex; width: max-content; }
.tabbar__tab { flex: none; height: 56px; padding: 0 24px; border: 0; background: none; color: var(--idle); font: inherit; font-weight: 600; font-size: 15px; line-height: 1; letter-spacing: 0.04em; white-space: nowrap; cursor: pointer; }
.tabbar__tab[aria-selected="true"] { color: var(--ink); }
.tabbar__mark { position: absolute; left: 0; bottom: 0; height: 2px; background-color: var(--ink); }

/* 端のグラデーションは、送れる先があることを示す。下のタブを押せるよう、当たりは通す */
.tabbar__port::before,
.tabbar__port::after { content: ""; position: absolute; top: 0; bottom: 0; width: 40px; pointer-events: none; }
.tabbar__port::before { left: 0; opacity: var(--left); background: linear-gradient(90deg, var(--ground), rgba(242, 241, 237, 0)); }
.tabbar__port::after { right: 0; opacity: var(--right); background: linear-gradient(270deg, var(--ground), rgba(242, 241, 237, 0)); }

.tabbar__arrow { position: absolute; top: 6px; z-index: 1; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 1px solid var(--line); border-radius: 50%; background-color: var(--surface); cursor: pointer; }
.tabbar__arrow--prev { left: 0; }
.tabbar__arrow--next { right: 0; }
.tabbar__arrow:disabled { opacity: 0.3; cursor: default; }
/* 山形は線 2 本で描く(文字で置くと、書体ごとに大きさと位置が変わる) */
.tabbar__arrow::before { content: ""; width: 8px; height: 8px; border-top: 2px solid var(--ink); border-right: 2px solid var(--ink); }
.tabbar__arrow--prev::before { transform: rotate(-135deg) translate(-1px, -1px); }
.tabbar__arrow--next::before { transform: rotate(45deg) translate(-1px, 1px); }

.docs__panels { position: relative; margin-top: 56px; }
.docs__panel { display: flex; align-items: center; justify-content: space-between; gap: 48px; }
.docs__panel[hidden] { display: none; }
.docs__panel h3 { margin: 0 0 16px; font-size: clamp(24px, 4vw, 32px); font-weight: 800; line-height: 1.2; letter-spacing: -0.02em; }
.docs__panel p { max-width: 32em; margin: 0; font-size: 16px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }

/* 画像の代わりの色面。中の 3 本の帯は背景の層で描く(要素を足さずに済む) */
.docs__art {
  flex: none;
  width: min(280px, 34vw);
  height: 180px;
  border-radius: 4px;
  background-color: var(--tone);
  background-image: linear-gradient(rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.9)),
    linear-gradient(rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.55)),
    linear-gradient(rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.72));
  background-size: 68% 10px, 42% 10px, 54% 10px;
  background-position: 32px 62px, 32px 86px, 32px 110px;
  background-repeat: no-repeat;
}

@media (max-width: 640px) {
  .docs__panel { flex-direction: column; align-items: stretch; gap: 24px; }
  .docs__art { width: 100%; }
}

.docs :focus-visible { outline: 2px solid var(--ink); outline-offset: -3px; }

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const PAGE = 0.55; // 矢印 1 回で送る量(窓の幅に対する比)

document.querySelectorAll(".tabbar").forEach((bar) => {
  const port = bar.querySelector(".tabbar__port");
  const track = bar.querySelector(".tabbar__track");
  const mark = bar.querySelector(".tabbar__mark");
  const arrows = [...bar.querySelectorAll(".tabbar__arrow")];
  const tabs = [...bar.querySelectorAll('[role="tab"]')];
  const panels = tabs.map((tab) => document.getElementById(tab.getAttribute("aria-controls")));
  const state = { offset: 0 }; // 帯を左へ送った量(px)
  let current = Math.max(0, tabs.findIndex((tab) => tab.getAttribute("aria-selected") === "true"));
  let slide = null;

  const limit = () => Math.max(0, track.scrollWidth - port.clientWidth);
  const place = () => {
    track.style.transform = `translateX(${-Math.round(state.offset)}px)`;
    // 端では、送れない側の矢印とグラデーションを薄くする
    const room = [state.offset, limit() - state.offset].map((v) => gsap.utils.clamp(0, 1, v / 24));
    room.forEach((k, i) => {
      arrows[i].disabled = k < 0.02;
      bar.style.setProperty(i === 0 ? "--left" : "--right", k);
    });
  };
  const slideTo = (to) => {
    slide?.kill();
    const offset = gsap.utils.clamp(0, limit(), to);
    slide = gsap.to(state, { offset, duration: 0.5 / SPEED, ease: "power3.inOut", onUpdate: place });
  };

  // 下線は、選んだタブの位置と幅へ。offsetLeft と offsetWidth は帯の transform に影響されない
  const markTo = (tab, animate) =>
    gsap[animate ? "to" : "set"](mark, {
      x: tab.offsetLeft,
      width: tab.offsetWidth,
      duration: 0.5 / SPEED,
      ease: "power3.inOut",
      overwrite: "auto",
    });

  function select(next) {
    if (next === current) return;
    const from = panels[current];
    const to = panels[next];
    tabs.forEach((tab, i) => {
      tab.setAttribute("aria-selected", String(i === next));
      tab.tabIndex = i === next ? 0 : -1;
    });
    current = next;
    markTo(tabs[next], true);
    // 押したタブを窓の中央へ寄せる(端まで来たら、それ以上は送らない)
    slideTo(tabs[next].offsetLeft + tabs[next].offsetWidth / 2 - port.clientWidth / 2);
    // 中身は、前の札が消えきってから次の札を出す(重ねると 2 つの文が混ざって読めない)
    gsap
      .timeline()
      .to(from, { opacity: 0, duration: 0.2 / SPEED, ease: "power2.in" })
      .add(() => {
        from.hidden = true;
        to.hidden = false;
      })
      .fromTo(to, { opacity: 0 }, { opacity: 1, duration: 0.28 / SPEED, ease: "power2.out" });
  }

  tabs.forEach((tab, i) => tab.addEventListener("click", () => select(i)));
  arrows.forEach((arrow) =>
    arrow.addEventListener("click", () => slideTo(state.offset + port.clientWidth * PAGE * Number(arrow.dataset.step)))
  );

  // ← → で隣のタブへ(端では反対の端へ回る)。Home と End で先頭と末尾へ。移った先をすぐに選ぶ
  track.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({ preventScroll: true });
  });

  // 書体が読み込まれると、タブの幅が変わる。読み込みの後と、枠の幅が変わるたびに置き直す
  const layout = () => {
    markTo(tabs[current], false);
    state.offset = gsap.utils.clamp(0, limit(), state.offset);
    place();
  };
  document.fonts.ready.then(layout);
  new ResizeObserver(layout).observe(bar);
});

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

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