タブ下線スライド遷移

アクティブタブの実寸を計測し、下線インジケーターを transform で滑走させるタブ切替。内容のフェードと同期し、文字数が違っても正確に追従します。

#css#javascript#tabs#animation

ライブデモ

使用例(お題: SaaS FlowDesk)

この技法を「SaaS FlowDesk」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- FlowDesk: 料金/機能タブを下線インジケーターのスライド+フェードで切替 -->
<div class="ft-stage">
  <div class="ft-card">
    <div class="ft-head">
      <span class="ft-logo">▰ FlowDesk</span>
      <span class="ft-head-sub">チームのための業務ハブ</span>
    </div>

    <div class="ft-tabs" role="tablist" aria-label="プラン情報">
      <button class="ft-tab is-active" role="tab" aria-selected="true" data-panel="t1">概要</button>
      <button class="ft-tab" role="tab" aria-selected="false" data-panel="t2">料金プラン</button>
      <button class="ft-tab" role="tab" aria-selected="false" data-panel="t3">主な機能</button>
      <button class="ft-tab" role="tab" aria-selected="false" data-panel="t4">よくある質問</button>
      <span class="ft-ink" aria-hidden="true"></span>
    </div>

    <div class="ft-panels">
      <section class="ft-panel is-active" id="t1" role="tabpanel">
        <h3>すべての業務を、ひとつの画面に。</h3>
        <p>タスク・カレンダー・チャットを統合。チームの「今やること」が一目でわかり、ツールの切替に費やす時間をゼロにします。</p>
      </section>
      <section class="ft-panel" id="t2" role="tabpanel" hidden>
        <h3>明朗な月額プラン</h3>
        <p>無料は3名まで。Proは1ユーザー月額980円で人数無制限。年額は2ヶ月分お得。いつでもアップグレード・解約が可能です。</p>
      </section>
      <section class="ft-panel" id="t3" role="tabpanel" hidden>
        <h3>仕事を加速する機能群</h3>
        <p>カンバン、ガントチャート、自動化ルール、外部API連携。テンプレートから30秒でワークスペースを立ち上げられます。</p>
      </section>
      <section class="ft-panel" id="t4" role="tabpanel" hidden>
        <h3>導入前のよくある質問</h3>
        <p>データはすべて暗号化して保管。SSO・監査ログにも対応。既存ツールからのインポートも数クリックで完了します。</p>
      </section>
    </div>
  </div>
</div>
CSS
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 400px;
  display: grid;
  place-items: center;
  font-family: "Hiragino Kaku Gothic ProN", "Segoe UI", system-ui, sans-serif;
  color: #e8edff;
  background:
    radial-gradient(700px 360px at 85% -10%, #1c2c54 0%, transparent 60%),
    #0f1b34;
}

.ft-stage { width: min(560px, 94vw); }

.ft-card {
  background: #16244a;
  border: 1px solid rgba(79, 124, 255, .18);
  border-radius: 18px;
  padding: 22px 24px 26px;
  box-shadow: 0 22px 50px rgba(0, 0, 0, .4);
}

.ft-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 18px; }
.ft-logo { font-size: 16px; font-weight: 800; color: #4f7cff; }
.ft-head-sub { font-size: 12px; color: #9db0d8; }

.ft-tabs {
  position: relative;
  display: flex;
  gap: 6px;
  border-bottom: 1px solid rgba(255, 255, 255, .1);
}
.ft-tab {
  position: relative;
  border: none;
  background: transparent;
  color: #9db0d8;
  padding: 10px 14px;
  font-size: 14px;
  cursor: pointer;
  white-space: nowrap;
  transition: color .25s ease;
}
.ft-tab:hover { color: #cdd9ff; }
.ft-tab.is-active { color: #fff; font-weight: 700; }
.ft-tab:focus-visible { outline: 2px solid #4f7cff; outline-offset: -2px; border-radius: 6px; }

/* スライドする下線インジケーター */
.ft-ink {
  position: absolute;
  left: 0;
  bottom: -1px;
  height: 3px;
  width: var(--ink-w, 0);
  border-radius: 3px;
  background: linear-gradient(90deg, #4f7cff, #7aa0ff);
  transform: translateX(var(--ink-x, 0));
  transition: transform .35s cubic-bezier(.6, .04, .2, 1), width .35s cubic-bezier(.6, .04, .2, 1);
}

.ft-panels { position: relative; margin-top: 18px; min-height: 96px; }
.ft-panel { animation: ftFade .35s ease both; }
.ft-panel[hidden] { display: none; }
.ft-panel h3 { margin: 0 0 8px; font-size: 16px; color: #eaf0ff; }
.ft-panel p { margin: 0; font-size: 13px; line-height: 1.85; color: #aebcdd; }

@keyframes ftFade {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  .ft-ink { transition: none; }
  .ft-panel { animation: none; }
}
JavaScript
// FlowDesk タブ下線スライド: アクティブタブの実寸を測り、インジケーターを滑走
(() => {
  const tabsWrap = document.querySelector('.ft-tabs');
  const ink = document.querySelector('.ft-ink');
  const tabs = [...document.querySelectorAll('.ft-tab')];
  const panels = [...document.querySelectorAll('.ft-panel')];
  if (!tabsWrap || !ink || tabs.length === 0) return; // null安全

  // 下線をアクティブタブの幅・位置へ(offsetベースで正確に)
  const moveInk = (tab) => {
    if (!tab) return;
    ink.style.setProperty('--ink-w', `${tab.offsetWidth}px`);
    ink.style.setProperty('--ink-x', `${tab.offsetLeft}px`);
  };

  const activate = (tab) => {
    if (!tab) return;
    tabs.forEach((t) => {
      const on = t === tab;
      t.classList.toggle('is-active', on);
      t.setAttribute('aria-selected', String(on));
    });
    const id = tab.dataset.panel;
    panels.forEach((p) => { p.hidden = p.id !== id; });
    moveInk(tab);
  };

  // クリックで切替
  tabsWrap.addEventListener('click', (e) => {
    const tab = e.target.closest('.ft-tab');
    if (tab) activate(tab);
  });

  // 矢印キーで移動
  tabsWrap.addEventListener('keydown', (e) => {
    const idx = tabs.indexOf(document.activeElement);
    if (idx === -1) return;
    let next = -1;
    if (e.key === 'ArrowRight') next = (idx + 1) % tabs.length;
    if (e.key === 'ArrowLeft') next = (idx - 1 + tabs.length) % tabs.length;
    if (next > -1) {
      e.preventDefault();
      tabs[next].focus();
      activate(tabs[next]);
    }
  });

  // 初期配置+レイアウト確定後/リサイズに追従
  const init = () => moveInk(tabs.find((t) => t.classList.contains('is-active')) || tabs[0]);
  if (document.readyState === 'complete') init();
  else window.addEventListener('load', init);
  requestAnimationFrame(init);
  window.addEventListener('resize', init);
})();

コード

HTML
<!-- タブ下線スライド: アクティブタブへインジケーターが滑走し、内容もフェード切替 -->
<div class="tab-stage">
  <div class="tab-card">
    <div class="tabs" role="tablist" aria-label="プラン">
      <button class="tab is-active" role="tab" aria-selected="true" data-panel="p1">Overview</button>
      <button class="tab" role="tab" aria-selected="false" data-panel="p2">Pricing</button>
      <button class="tab" role="tab" aria-selected="false" data-panel="p3">Reviews</button>
      <button class="tab" role="tab" aria-selected="false" data-panel="p4">FAQ</button>
      <!-- スライドする下線インジケーター -->
      <span class="tab-ink" aria-hidden="true"></span>
    </div>

    <div class="tab-panels">
      <section class="tab-panel is-active" id="p1" role="tabpanel">
        <h3>Overview</h3>
        <p>下線インジケーターは選択タブの幅と位置へ transform で滑走。レイアウト計測ベースなので文字数が違っても正確に追従します。</p>
      </section>
      <section class="tab-panel" id="p2" role="tabpanel" hidden>
        <h3>Pricing</h3>
        <p>月額・年額をシンプルに。インジケーターのスライドと中身のフェードを同期させると上質な印象になります。</p>
      </section>
      <section class="tab-panel" id="p3" role="tabpanel" hidden>
        <h3>Reviews</h3>
        <p>★★★★★ 「切替が気持ちいい」。マイクロインタラクションは体験の質を底上げします。</p>
      </section>
      <section class="tab-panel" id="p4" role="tabpanel" hidden>
        <h3>FAQ</h3>
        <p>キーボードの ← → でもタブ移動可能。アクセシビリティと装飾を両立しています。</p>
      </section>
    </div>
  </div>
</div>
CSS
* { box-sizing: border-box; }

:root {
  --bg: #0d1117;
  --card: #161b26;
  --text: #e8ecf6;
  --muted: #9aa3b8;
  --accent: #5b8cff;
  --accent2: #7c5cff;
  --ease: cubic-bezier(.65, 0, .35, 1);
}

body {
  margin: 0;
  min-height: 360px;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  color: var(--text);
  background:
    radial-gradient(700px 320px at 100% 0%, #1a2140 0%, transparent 60%),
    var(--bg);
}

.tab-stage { width: min(620px, 92vw); }

.tab-card {
  background: var(--card);
  border: 1px solid #232a3a;
  border-radius: 18px;
  padding: 8px 8px 24px;
  box-shadow: 0 20px 50px rgba(0,0,0,.45);
}

.tabs {
  position: relative;
  display: flex;
  gap: 4px;
  padding: 6px;
  border-bottom: 1px solid #232a3a;
}

.tab {
  position: relative;
  z-index: 1;
  border: none;
  background: transparent;
  color: var(--muted);
  padding: 12px 18px;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  border-radius: 10px;
  transition: color .25s ease, background .25s ease;
}
.tab:hover { color: var(--text); background: rgba(255,255,255,.04); }
.tab.is-active { color: var(--text); }
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* スライドする下線。JSが width とtranslateX を設定 */
.tab-ink {
  position: absolute;
  left: 0;
  bottom: -1px;
  height: 3px;
  width: var(--ink-w, 0px);
  border-radius: 3px;
  background: linear-gradient(90deg, var(--accent), var(--accent2));
  transform: translateX(var(--ink-x, 0px));
  transition: transform .42s var(--ease), width .42s var(--ease);
  box-shadow: 0 0 12px rgba(91,140,255,.6);
}

.tab-panels { position: relative; padding: 22px 18px 0; }

.tab-panel {
  animation: tabFade .42s var(--ease);
}
.tab-panel[hidden] { display: none; }
.tab-panel h3 { margin: 0 0 10px; font-size: 20px; }
.tab-panel p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.75; }

@keyframes tabFade {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  .tab-ink { transition: none; }
  .tab-panel { animation: none; }
}

@media (max-width: 520px) {
  .tab { padding: 11px 12px; font-size: 13px; }
}
JavaScript
// タブ下線スライド: アクティブタブの実寸を測り、インジケーターを移動
(() => {
  const tabsWrap = document.querySelector('.tabs');
  const ink = document.querySelector('.tab-ink');
  const tabs = [...document.querySelectorAll('.tab')];
  const panels = [...document.querySelectorAll('.tab-panel')];
  if (!tabsWrap || !ink || tabs.length === 0) return; // null安全

  // 下線をアクティブタブの幅・位置に合わせる(offsetベースで正確に)
  const moveInk = (tab) => {
    if (!tab) return;
    ink.style.setProperty('--ink-w', `${tab.offsetWidth}px`);
    ink.style.setProperty('--ink-x', `${tab.offsetLeft}px`);
  };

  const activate = (tab) => {
    if (!tab) return;
    tabs.forEach((t) => {
      const on = t === tab;
      t.classList.toggle('is-active', on);
      t.setAttribute('aria-selected', String(on));
    });
    // 対応パネルだけ表示
    const id = tab.dataset.panel;
    panels.forEach((p) => { p.hidden = p.id !== id; });
    moveInk(tab);
  };

  // クリックで切替
  tabsWrap.addEventListener('click', (e) => {
    const tab = e.target.closest('.tab');
    if (tab) activate(tab);
  });

  // 矢印キーで移動
  tabsWrap.addEventListener('keydown', (e) => {
    const idx = tabs.indexOf(document.activeElement);
    if (idx === -1) return;
    let next = -1;
    if (e.key === 'ArrowRight') next = (idx + 1) % tabs.length;
    if (e.key === 'ArrowLeft') next = (idx - 1 + tabs.length) % tabs.length;
    if (next > -1) {
      e.preventDefault();
      tabs[next].focus();
      activate(tabs[next]);
    }
  });

  // 初期配置+リサイズ追従
  const init = () => moveInk(tabs.find((t) => t.classList.contains('is-active')) || tabs[0]);
  // フォント読込やレイアウト確定後にも測り直す
  if (document.readyState === 'complete') init();
  else window.addEventListener('load', init);
  requestAnimationFrame(init);
  window.addEventListener('resize', init);
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「タブ下線スライド遷移」の効果を追加してください。

# 追加してほしい効果
タブ下線スライド遷移(ページ遷移 / View Transitions)
アクティブタブの実寸を計測し、下線インジケーターを transform で滑走させるタブ切替。内容のフェードと同期し、文字数が違っても正確に追従します。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- タブ下線スライド: アクティブタブへインジケーターが滑走し、内容もフェード切替 -->
<div class="tab-stage">
  <div class="tab-card">
    <div class="tabs" role="tablist" aria-label="プラン">
      <button class="tab is-active" role="tab" aria-selected="true" data-panel="p1">Overview</button>
      <button class="tab" role="tab" aria-selected="false" data-panel="p2">Pricing</button>
      <button class="tab" role="tab" aria-selected="false" data-panel="p3">Reviews</button>
      <button class="tab" role="tab" aria-selected="false" data-panel="p4">FAQ</button>
      <!-- スライドする下線インジケーター -->
      <span class="tab-ink" aria-hidden="true"></span>
    </div>

    <div class="tab-panels">
      <section class="tab-panel is-active" id="p1" role="tabpanel">
        <h3>Overview</h3>
        <p>下線インジケーターは選択タブの幅と位置へ transform で滑走。レイアウト計測ベースなので文字数が違っても正確に追従します。</p>
      </section>
      <section class="tab-panel" id="p2" role="tabpanel" hidden>
        <h3>Pricing</h3>
        <p>月額・年額をシンプルに。インジケーターのスライドと中身のフェードを同期させると上質な印象になります。</p>
      </section>
      <section class="tab-panel" id="p3" role="tabpanel" hidden>
        <h3>Reviews</h3>
        <p>★★★★★ 「切替が気持ちいい」。マイクロインタラクションは体験の質を底上げします。</p>
      </section>
      <section class="tab-panel" id="p4" role="tabpanel" hidden>
        <h3>FAQ</h3>
        <p>キーボードの ← → でもタブ移動可能。アクセシビリティと装飾を両立しています。</p>
      </section>
    </div>
  </div>
</div>

【CSS】
* { box-sizing: border-box; }

:root {
  --bg: #0d1117;
  --card: #161b26;
  --text: #e8ecf6;
  --muted: #9aa3b8;
  --accent: #5b8cff;
  --accent2: #7c5cff;
  --ease: cubic-bezier(.65, 0, .35, 1);
}

body {
  margin: 0;
  min-height: 360px;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  color: var(--text);
  background:
    radial-gradient(700px 320px at 100% 0%, #1a2140 0%, transparent 60%),
    var(--bg);
}

.tab-stage { width: min(620px, 92vw); }

.tab-card {
  background: var(--card);
  border: 1px solid #232a3a;
  border-radius: 18px;
  padding: 8px 8px 24px;
  box-shadow: 0 20px 50px rgba(0,0,0,.45);
}

.tabs {
  position: relative;
  display: flex;
  gap: 4px;
  padding: 6px;
  border-bottom: 1px solid #232a3a;
}

.tab {
  position: relative;
  z-index: 1;
  border: none;
  background: transparent;
  color: var(--muted);
  padding: 12px 18px;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  border-radius: 10px;
  transition: color .25s ease, background .25s ease;
}
.tab:hover { color: var(--text); background: rgba(255,255,255,.04); }
.tab.is-active { color: var(--text); }
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* スライドする下線。JSが width とtranslateX を設定 */
.tab-ink {
  position: absolute;
  left: 0;
  bottom: -1px;
  height: 3px;
  width: var(--ink-w, 0px);
  border-radius: 3px;
  background: linear-gradient(90deg, var(--accent), var(--accent2));
  transform: translateX(var(--ink-x, 0px));
  transition: transform .42s var(--ease), width .42s var(--ease);
  box-shadow: 0 0 12px rgba(91,140,255,.6);
}

.tab-panels { position: relative; padding: 22px 18px 0; }

.tab-panel {
  animation: tabFade .42s var(--ease);
}
.tab-panel[hidden] { display: none; }
.tab-panel h3 { margin: 0 0 10px; font-size: 20px; }
.tab-panel p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.75; }

@keyframes tabFade {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  .tab-ink { transition: none; }
  .tab-panel { animation: none; }
}

@media (max-width: 520px) {
  .tab { padding: 11px 12px; font-size: 13px; }
}

【JavaScript】
// タブ下線スライド: アクティブタブの実寸を測り、インジケーターを移動
(() => {
  const tabsWrap = document.querySelector('.tabs');
  const ink = document.querySelector('.tab-ink');
  const tabs = [...document.querySelectorAll('.tab')];
  const panels = [...document.querySelectorAll('.tab-panel')];
  if (!tabsWrap || !ink || tabs.length === 0) return; // null安全

  // 下線をアクティブタブの幅・位置に合わせる(offsetベースで正確に)
  const moveInk = (tab) => {
    if (!tab) return;
    ink.style.setProperty('--ink-w', `${tab.offsetWidth}px`);
    ink.style.setProperty('--ink-x', `${tab.offsetLeft}px`);
  };

  const activate = (tab) => {
    if (!tab) return;
    tabs.forEach((t) => {
      const on = t === tab;
      t.classList.toggle('is-active', on);
      t.setAttribute('aria-selected', String(on));
    });
    // 対応パネルだけ表示
    const id = tab.dataset.panel;
    panels.forEach((p) => { p.hidden = p.id !== id; });
    moveInk(tab);
  };

  // クリックで切替
  tabsWrap.addEventListener('click', (e) => {
    const tab = e.target.closest('.tab');
    if (tab) activate(tab);
  });

  // 矢印キーで移動
  tabsWrap.addEventListener('keydown', (e) => {
    const idx = tabs.indexOf(document.activeElement);
    if (idx === -1) return;
    let next = -1;
    if (e.key === 'ArrowRight') next = (idx + 1) % tabs.length;
    if (e.key === 'ArrowLeft') next = (idx - 1 + tabs.length) % tabs.length;
    if (next > -1) {
      e.preventDefault();
      tabs[next].focus();
      activate(tabs[next]);
    }
  });

  // 初期配置+リサイズ追従
  const init = () => moveInk(tabs.find((t) => t.classList.contains('is-active')) || tabs[0]);
  // フォント読込やレイアウト確定後にも測り直す
  if (document.readyState === 'complete') init();
  else window.addEventListener('load', init);
  requestAnimationFrame(init);
  window.addEventListener('resize', init);
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。