タブ切替(下線インジケーター付き)

選択タブへ下線インジケーターがスライド移動するタブUI。offsetLeft/Widthでの位置計算により、商品情報や設定画面の切替に使えます。

#css#javascript#animation

ライブデモ

使用例(お題: カフェ MOON BREW)

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

HTML
<!-- MOON BREW:メニューをカテゴリタブで切替(下線インジケーター付き) -->
<div class="cafe">
  <header class="cafe__bar">
    <span class="cafe__logo">☕ MOON BREW</span>
    <span class="cafe__hours">OPEN 8:00–21:00</span>
  </header>

  <h1 class="cafe__h1">本日のメニュー</h1>

  <div class="tabs" role="tablist">
    <button class="tabs__btn is-active" role="tab" data-panel="coffee">コーヒー</button>
    <button class="tabs__btn" role="tab" data-panel="latte">ラテ</button>
    <button class="tabs__btn" role="tab" data-panel="food">フード</button>
    <span class="tabs__ink" aria-hidden="true"></span>
  </div>

  <div class="tabs__body">
    <div class="panel is-active" data-name="coffee">
      <div class="menu"><span class="menu__name">ムーンブレンド</span><span class="menu__price">¥520</span></div>
      <div class="menu"><span class="menu__name">深煎りエスプレッソ</span><span class="menu__price">¥420</span></div>
      <div class="menu"><span class="menu__name">水出しコールドブリュー</span><span class="menu__price">¥580</span></div>
    </div>
    <div class="panel" data-name="latte">
      <div class="menu"><span class="menu__name">琥珀キャラメルラテ</span><span class="menu__price">¥620</span></div>
      <div class="menu"><span class="menu__name">ほうじ茶ラテ</span><span class="menu__price">¥580</span></div>
      <div class="menu"><span class="menu__name">きな粉ソイラテ</span><span class="menu__price">¥600</span></div>
    </div>
    <div class="panel" data-name="food">
      <div class="menu"><span class="menu__name">自家製スコーン</span><span class="menu__price">¥380</span></div>
      <div class="menu"><span class="menu__name">キャロットケーキ</span><span class="menu__price">¥480</span></div>
      <div class="menu"><span class="menu__name">厚切りトースト</span><span class="menu__price">¥420</span></div>
    </div>
  </div>
</div>
CSS
/* MOON BREW カフェ テーマ */
:root{
  --cream:#f5ede1;--brown:#2b1d12;--amber:#c98a3b;
  --line:#e3d6c2;--muted:#7a6450;
}
*{box-sizing:border-box}
body{
  margin:0;min-height:100vh;
  font-family:"Hiragino Mincho ProN","Segoe UI",serif;
  background:var(--cream);color:var(--brown);
}
.cafe{max-width:520px;margin:0 auto;padding:0 18px 20px}
.cafe__bar{
  display:flex;align-items:center;justify-content:space-between;
  padding:14px 4px;border-bottom:1px solid var(--line);
}
.cafe__logo{font-weight:700;letter-spacing:.06em}
.cafe__hours{font-size:.74rem;letter-spacing:.12em;color:var(--amber)}
.cafe__h1{margin:18px 0 12px;font-size:1.4rem;letter-spacing:.04em}
/* タブ:下線インジケーターがスライド */
.tabs{position:relative;display:flex;gap:6px;border-bottom:1px solid var(--line)}
.tabs__btn{
  appearance:none;background:none;border:none;cursor:pointer;
  padding:10px 16px;font:inherit;font-size:.92rem;color:var(--muted);
  transition:color .25s;
}
.tabs__btn.is-active{color:var(--brown);font-weight:700}
.tabs__ink{
  position:absolute;bottom:-1px;left:0;height:3px;width:0;
  background:linear-gradient(90deg,var(--amber),#e0a85a);
  border-radius:3px;transition:left .3s cubic-bezier(.4,0,.2,1),width .3s cubic-bezier(.4,0,.2,1);
}
.tabs__body{position:relative;padding-top:14px}
.panel{display:none;animation:fade .35s ease}
.panel.is-active{display:block}
@keyframes fade{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.menu{
  display:flex;align-items:center;justify-content:space-between;
  padding:11px 4px;border-bottom:1px dashed var(--line);
}
.menu__name{font-size:.95rem}
.menu__price{color:var(--amber);font-weight:700;letter-spacing:.04em}
@media (prefers-reduced-motion:reduce){.tabs__ink,.panel{transition:none;animation:none}}
JavaScript
// タブ切替:下線インジケーターを選択タブの位置へ移動
const btns = Array.from(document.querySelectorAll('.tabs__btn'));
const ink = document.querySelector('.tabs__ink');
const panels = Array.from(document.querySelectorAll('.panel'));

// インジケーターを対象ボタンに合わせる
function moveInk(btn) {
  if (!ink || !btn) return;
  ink.style.left = btn.offsetLeft + 'px';
  ink.style.width = btn.offsetWidth + 'px';
}

function activate(btn) {
  btns.forEach((b) => b.classList.toggle('is-active', b === btn));
  const name = btn.dataset.panel;
  panels.forEach((p) => p.classList.toggle('is-active', p.dataset.name === name));
  moveInk(btn);
}

btns.forEach((btn) => btn.addEventListener('click', () => activate(btn)));

// 初期位置をアクティブタブに合わせる
const initial = document.querySelector('.tabs__btn.is-active');
if (initial) moveInk(initial);
// リサイズ時もずれないよう追従
window.addEventListener('resize', () => {
  const cur = document.querySelector('.tabs__btn.is-active');
  if (cur) moveInk(cur);
});

コード

HTML
<!-- タブ:下線インジケーターが選択タブへ滑らかに移動 -->
<div class="tabs" data-tabs>
  <div class="tabs__list" role="tablist" aria-label="プラン">
    <button class="tabs__tab is-active" role="tab" aria-selected="true" data-tab="t1">概要</button>
    <button class="tabs__tab" role="tab" aria-selected="false" data-tab="t2">仕様</button>
    <button class="tabs__tab" role="tab" aria-selected="false" data-tab="t3">レビュー</button>
    <span class="tabs__ink" aria-hidden="true"></span>
  </div>

  <div class="tabs__panels">
    <div class="tabs__panel is-active" role="tabpanel" id="t1">
      <h3>軽さと強さの両立</h3>
      <p>航空グレードのアルミ削り出しボディに高密度バッテリーを搭載。毎日の持ち運びに最適な一台です。</p>
    </div>
    <div class="tabs__panel" role="tabpanel" id="t2">
      <ul>
        <li>重量 <strong>980g</strong></li>
        <li>ディスプレイ <strong>14インチ 2.8K</strong></li>
        <li>駆動時間 <strong>最大18時間</strong></li>
      </ul>
    </div>
    <div class="tabs__panel" role="tabpanel" id="t3">
      <p>★★★★★ 「想像以上に薄くて驚いた。タイピングも快適。」</p>
      <p>★★★★☆ 「バッテリー持ちが優秀。スピーカーは普通。」</p>
    </div>
  </div>
</div>
CSS
:root{
  --bg:#faf7f2;
  --card:#ffffff;
  --ink:#6d28d9;
  --text:#2b2440;
  --muted:#6b6580;
  --line:#ece6f5;
}
*{box-sizing:border-box}
body{
  margin:0;min-height:100vh;
  display:grid;place-items:center;padding:28px 16px;
  font-family:"Segoe UI",system-ui,sans-serif;color:var(--text);
  background:
    radial-gradient(600px 300px at 85% 0%,#efe7ff,transparent),
    radial-gradient(500px 260px at 0% 100%,#ffe9f3,transparent),
    var(--bg);
}
.tabs{
  width:min(460px,100%);
  background:var(--card);
  border-radius:18px;
  box-shadow:0 18px 40px -20px rgba(80,40,140,.35);
  padding:8px 8px 6px;
}
.tabs__list{
  position:relative;
  display:flex;gap:4px;
  border-bottom:1px solid var(--line);
}
.tabs__tab{
  flex:1;
  padding:14px 8px;
  background:none;border:none;cursor:pointer;
  font:inherit;font-weight:600;color:var(--muted);
  transition:color .25s;
}
.tabs__tab.is-active{color:var(--ink)}
.tabs__tab:focus-visible{outline:2px solid var(--ink);outline-offset:-4px;border-radius:8px}
/* 下線インジケーターは left/width をJSから制御 */
.tabs__ink{
  position:absolute;bottom:-1px;left:0;height:3px;width:0;
  border-radius:3px;background:linear-gradient(90deg,var(--ink),#ec4899);
  transition:left .3s cubic-bezier(.4,0,.2,1),width .3s cubic-bezier(.4,0,.2,1);
}
.tabs__panels{padding:18px 14px 12px}
.tabs__panel{display:none;animation:fade .35s ease}
.tabs__panel.is-active{display:block}
.tabs__panel h3{margin:0 0 8px;font-size:1.05rem}
.tabs__panel p{margin:0 0 8px;color:var(--muted);line-height:1.7;font-size:.92rem}
.tabs__panel ul{margin:0;padding-left:18px;color:var(--muted);line-height:2}
.tabs__panel strong{color:var(--text)}
@keyframes fade{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:reduce){
  .tabs__ink,.tabs__panel{animation:none;transition:none}
}
JavaScript
// タブ切替+下線インジケーター移動
const root = document.querySelector('[data-tabs]');
if (root) {
  const tabs = [...root.querySelectorAll('.tabs__tab')];
  const panels = [...root.querySelectorAll('.tabs__panel')];
  const ink = root.querySelector('.tabs__ink');

  // インジケーターを指定タブの位置へ
  const moveInk = (tab) => {
    if (!ink || !tab) return;
    ink.style.left = tab.offsetLeft + 'px';
    ink.style.width = tab.offsetWidth + 'px';
  };

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

  tabs.forEach((tab) => tab.addEventListener('click', () => activate(tab)));

  // 初期位置(レイアウト確定後)
  const initial = root.querySelector('.tabs__tab.is-active') || tabs[0];
  requestAnimationFrame(() => moveInk(initial));
  // リサイズ追従
  window.addEventListener('resize', () => moveInk(root.querySelector('.tabs__tab.is-active')));
}

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

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

# 追加してほしい効果
タブ切替(下線インジケーター付き)(UIコンポーネント)
選択タブへ下線インジケーターがスライド移動するタブUI。offsetLeft/Widthでの位置計算により、商品情報や設定画面の切替に使えます。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- タブ:下線インジケーターが選択タブへ滑らかに移動 -->
<div class="tabs" data-tabs>
  <div class="tabs__list" role="tablist" aria-label="プラン">
    <button class="tabs__tab is-active" role="tab" aria-selected="true" data-tab="t1">概要</button>
    <button class="tabs__tab" role="tab" aria-selected="false" data-tab="t2">仕様</button>
    <button class="tabs__tab" role="tab" aria-selected="false" data-tab="t3">レビュー</button>
    <span class="tabs__ink" aria-hidden="true"></span>
  </div>

  <div class="tabs__panels">
    <div class="tabs__panel is-active" role="tabpanel" id="t1">
      <h3>軽さと強さの両立</h3>
      <p>航空グレードのアルミ削り出しボディに高密度バッテリーを搭載。毎日の持ち運びに最適な一台です。</p>
    </div>
    <div class="tabs__panel" role="tabpanel" id="t2">
      <ul>
        <li>重量 <strong>980g</strong></li>
        <li>ディスプレイ <strong>14インチ 2.8K</strong></li>
        <li>駆動時間 <strong>最大18時間</strong></li>
      </ul>
    </div>
    <div class="tabs__panel" role="tabpanel" id="t3">
      <p>★★★★★ 「想像以上に薄くて驚いた。タイピングも快適。」</p>
      <p>★★★★☆ 「バッテリー持ちが優秀。スピーカーは普通。」</p>
    </div>
  </div>
</div>

【CSS】
:root{
  --bg:#faf7f2;
  --card:#ffffff;
  --ink:#6d28d9;
  --text:#2b2440;
  --muted:#6b6580;
  --line:#ece6f5;
}
*{box-sizing:border-box}
body{
  margin:0;min-height:100vh;
  display:grid;place-items:center;padding:28px 16px;
  font-family:"Segoe UI",system-ui,sans-serif;color:var(--text);
  background:
    radial-gradient(600px 300px at 85% 0%,#efe7ff,transparent),
    radial-gradient(500px 260px at 0% 100%,#ffe9f3,transparent),
    var(--bg);
}
.tabs{
  width:min(460px,100%);
  background:var(--card);
  border-radius:18px;
  box-shadow:0 18px 40px -20px rgba(80,40,140,.35);
  padding:8px 8px 6px;
}
.tabs__list{
  position:relative;
  display:flex;gap:4px;
  border-bottom:1px solid var(--line);
}
.tabs__tab{
  flex:1;
  padding:14px 8px;
  background:none;border:none;cursor:pointer;
  font:inherit;font-weight:600;color:var(--muted);
  transition:color .25s;
}
.tabs__tab.is-active{color:var(--ink)}
.tabs__tab:focus-visible{outline:2px solid var(--ink);outline-offset:-4px;border-radius:8px}
/* 下線インジケーターは left/width をJSから制御 */
.tabs__ink{
  position:absolute;bottom:-1px;left:0;height:3px;width:0;
  border-radius:3px;background:linear-gradient(90deg,var(--ink),#ec4899);
  transition:left .3s cubic-bezier(.4,0,.2,1),width .3s cubic-bezier(.4,0,.2,1);
}
.tabs__panels{padding:18px 14px 12px}
.tabs__panel{display:none;animation:fade .35s ease}
.tabs__panel.is-active{display:block}
.tabs__panel h3{margin:0 0 8px;font-size:1.05rem}
.tabs__panel p{margin:0 0 8px;color:var(--muted);line-height:1.7;font-size:.92rem}
.tabs__panel ul{margin:0;padding-left:18px;color:var(--muted);line-height:2}
.tabs__panel strong{color:var(--text)}
@keyframes fade{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:reduce){
  .tabs__ink,.tabs__panel{animation:none;transition:none}
}

【JavaScript】
// タブ切替+下線インジケーター移動
const root = document.querySelector('[data-tabs]');
if (root) {
  const tabs = [...root.querySelectorAll('.tabs__tab')];
  const panels = [...root.querySelectorAll('.tabs__panel')];
  const ink = root.querySelector('.tabs__ink');

  // インジケーターを指定タブの位置へ
  const moveInk = (tab) => {
    if (!ink || !tab) return;
    ink.style.left = tab.offsetLeft + 'px';
    ink.style.width = tab.offsetWidth + 'px';
  };

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

  tabs.forEach((tab) => tab.addEventListener('click', () => activate(tab)));

  // 初期位置(レイアウト確定後)
  const initial = root.querySelector('.tabs__tab.is-active') || tabs[0];
  requestAnimationFrame(() => moveInk(initial));
  // リサイズ追従
  window.addEventListener('resize', () => moveInk(root.querySelector('.tabs__tab.is-active')));
}

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

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