下線アニメーションでタブ切替実装
アクティブなタブの直下に細い下線(インジケーター)がスッと移動するUIです。クリック/キーボード操作(左右・Home/End)に対応し、横スクロール可能なタブバーでも位置計算がズレないように実装しています。
コードについて
本記事のコードは AI(ChatGPT)による生成をベースに作成・調整しています。ご利用の環境でテストの上ご使用ください。
免責
本コードの利用に伴う不具合・損害について、当サイトは責任を負いません。自己責任にてご利用ください。
デモ
選択中タブの幅・位置を実測し、下線をアニメーションで移動させます。
- ARIAロール対応(
tablist / tab / tabpanel
)
- キーボード:← → / Home / End
- タブバー横スクロール対応
- リサイズ時に再計算
- タブ(
button.tab
)の data-panel
に対応パネルID
- 必要数だけタブ・パネルを追加可能
- 複数設置時はIDの重複に注意
Q. タブ幅がバラバラでも大丈夫?
A. 幅を実測して下線の幅に反映するのでOKです。
コードをコピーして使おう!
/* =========================================================
下線アニメーション — CSS(スコープ&可視性対策)
このブロックでは、下線インジケーターが確実に「見える」ように
レイアウトと重なり順を制御します。テーマの上書きを避けるため、
すべてのセレクタを #tabs-underline-06 でスコープしています。
========================================================= */
/* ルート要素:外枠の見た目とはみ出し制御 */
#tabs-underline-06{
background:#fff; /* 背景を白に */
border:1px solid var(--border); /* 外枠のボーダー */
border-radius:12px; /* 角丸でカード風に */
box-shadow:0 6px 18px rgba(2,8,23,.08); /* うっすら影 */
overflow:hidden; /* 角丸外へはみ出す子要素を隠す */
}
/* タブバー(インジケーターの基準) */
#tabs-underline-06 .tablist{
position:relative; /* ← インジケーターの絶対配置基準にする */
display:flex; /* タブを横並びに */
gap:4px; /* タブ間の余白 */
padding:8px; /* タブバーの内側余白 */
border-bottom:1px solid var(--border); /* 下側にボーダー(下線がここを跨ぐ) */
overflow-x:auto; /* タブ数が多い時は横スクロール */
scrollbar-width:thin; /* Firefox用のスクロールバー細め */
background:#fff; /* 背景白(影響受けにくくする) */
}
/* 下線インジケーター(実体の線) */
#tabs-underline-06 .indicator{
position:absolute; /* タブバー内で絶対配置 */
left:0; /* 左基準を0に */
bottom:-1px; /* ← 下ボーダーに重ねるため1px下げる */
height:3px; /* 視認性を上げるため3px */
width:0; /* 初期幅は0。JSで選択タブ幅に更新 */
background:var(--accent, #0b6bff); /* 強調色(アクセント)。フォールバック付 */
border-radius:2px 2px 0 0; /* 角を少し丸める */
transform:translateX(0); /* X方向の移動はJSでtranslateXを更新 */
transition:transform .28s ease, width .28s ease; /* スムーズに移動&伸縮 */
will-change:transform, width;/* ブラウザに最適化ヒント */
pointer-events:none; /* クリック等を無効化(操作対象外) */
z-index:10; /* ← タブバーやボーダーより前面に出す */
}
/* タブボタン(共通) */
#tabs-underline-06 .tab{
appearance:none; /* ブラウザ標準のボタン外観を無効化 */
border:none; /* 枠線なし */
background:transparent; /* 透明背景(選択中のみ背景色) */
color:var(--muted); /* 文字色やや淡く */
padding:10px 14px; /* クリックしやすいパディング */
border-radius:8px; /* 角丸 */
font-size:14px; /* 本文相当のサイズ */
white-space:nowrap; /* 折り返さない(横スクロール対応) */
cursor:pointer; /* クリックできる見た目に */
line-height:1; /* 高さを詰めてコンパクトに */
}
/* 選択中タブの見た目(軽いハイライト) */
#tabs-underline-06 .tab[aria-selected="true"]{
color:var(--ink); /* 文字色を濃く */
background:#f8fafc; /* 背景を薄い面色に */
}
/* フォーカスリング(アクセシビリティ) */
#tabs-underline-06 .tab:focus-visible{
outline:none; /* 既定のアウトラインは無効化 */
box-shadow:0 0 0 3px rgba(11,107,255,.25); /* 青系のフォーカスリング */
}
/* コンテンツパネル領域 */
#tabs-underline-06 .panels{
padding:16px; /* 中身の余白 */
background:#fff; /* 背景白 */
}
/* 各パネルの表示/アニメーション */
#tabs-underline-06 .panel{
display:none; /* まず非表示 */
animation:tu06Fade .24s ease;/* 表示時にふわっと */
}
#tabs-underline-06 .panel[aria-hidden="false"]{ display:block; } /* 選択時のみ表示 */
/* パネルのフェードイン */
@keyframes tu06Fade{
from{opacity:0; transform:translateY(4px)} /* うっすら下から */
to {opacity:1; transform:translateY(0)} /* 通常位置に */
}
/* モバイル時の微調整 */
@media (max-width:520px){
#tabs-underline-06 .tab{ padding:10px 12px; font-size:13px; } /* タブを少し小さく */
#tabs-underline-06 .panels{ padding:14px; } /* 余白も微調整 */
}
<!-- ======================================================
下線アニメーション — HTML 構造(そのまま貼り付けOK)
重要ポイント:
- .indicator は .tablist の「最後の子要素」に置く(JSがそこを前提に制御)
- WAI-ARIAのロール(tablist / tab / tabpanel)と対応属性を維持
====================================================== -->
<section class="tabs-underline" id="tabs-underline-06"> <!-- コンポーネント全体のスコープID -->
<!-- タブリスト:タブ群をまとめ、キーボード操作の対象にする -->
<div class="tablist" role="tablist" aria-label="コンテンツの種類">
<!-- タブ:各ボタンは role="tab"。aria-controls で対応パネルIDを指す -->
<button class="tab" role="tab" id="tu06-overview"
aria-controls="tu06-panel-overview" aria-selected="true"
data-panel="tu06-panel-overview">概要</button>
<button class="tab" role="tab" id="tu06-details"
aria-controls="tu06-panel-details" aria-selected="false"
data-panel="tu06-panel-details">特徴</button>
<button class="tab" role="tab" id="tu06-usage"
aria-controls="tu06-panel-usage" aria-selected="false"
data-panel="tu06-panel-usage">使い方</button>
<button class="tab" role="tab" id="tu06-faq"
aria-controls="tu06-panel-faq" aria-selected="false"
data-panel="tu06-panel-faq">FAQ</button>
<!-- 下線インジケーター:幅・位置はJSで更新。必ず .tablist の直下末尾に配置 -->
<div class="indicator" aria-hidden="true"></div>
</div>
<!-- パネル群:選択されたタブに対応するコンテンツだけを表示 -->
<div class="panels">
<div class="panel" id="tu06-panel-overview" role="tabpanel"
aria-labelledby="tu06-overview" aria-hidden="false">
<p class="muted">選択中タブの幅・位置を実測し、下線をアニメーションで移動。</p>
</div>
<div class="panel" id="tu06-panel-details" role="tabpanel"
aria-labelledby="tu06-details" aria-hidden="true">
<ul>
<li>WAI-ARIA roles/attributes(支援技術に配慮)</li>
<li>キーボード:← → / Home / End</li>
<li>横スクロール可能なタブバーに対応</li>
<li>リサイズやスクロール時にインジケーターを再計算</li>
</ul>
</div>
<div class="panel" id="tu06-panel-usage" role="tabpanel"
aria-labelledby="tu06-usage" aria-hidden="true">
<ol>
<li>各タブの data-panel に対応するパネルIDを設定</li>
<li>タブ/パネルの追加・削除は自由</li>
<li>複数設置時は ID の重複に注意</li>
</ol>
</div>
<div class="panel" id="tu06-panel-faq" role="tabpanel"
aria-labelledby="tu06-faq" aria-hidden="true">
<p><strong>Q.</strong> タブ幅が異なっても大丈夫?<br>
<strong>A.</strong> 幅を実測して下線の幅に反映します。</p>
</div>
</div>
</section>
/* =========================================================
下線アニメーション — JS(堅牢&確実に見える)
このスクリプトはタブの選択状態と対応パネルの表示を制御しつつ、
下線インジケーターを「選択中のタブの直下」にスムーズに移動させます。
・offsetLeft / offsetWidth を使い、テーマやスクロールの影響に強い
・初期選択、リサイズ、横スクロールで都度再計算
・初回ロード後に rAF で数フレーム補正(フォント再流し対策)
・キーボード操作(← → / Home / End)対応
========================================================= */
(function initTabsUnderline06(){
const root = document.getElementById('tabs-underline-06'); /* ルート要素取得 */
if(!root) return; /* 要素がなければ処理しない(安全策) */
const tablist = root.querySelector('.tablist'); /* タブリスト(スクロール親) */
const tabs = Array.from(root.querySelectorAll('.tablist .tab')); /* すべてのタブ */
const panels = Array.from(root.querySelectorAll('.panels .panel')); /* すべてのパネル */
const indicator = root.querySelector('.indicator'); /* 下線インジケーター */
/* 現在選択されているタブ(無ければ先頭)を返す関数 */
const getSelectedTab = () =>
tabs.find(t => t.getAttribute('aria-selected') === 'true') || tabs[0];
/* 指定タブ真下へインジケーターを移動(offset系で安定) */
function moveIndicator(toTab){
if(!toTab) return; /* 安全ガード */
const left = toTab.offsetLeft; /* タブの左位置(親の .tablist 基準) */
const width = toTab.offsetWidth; /* タブの幅 */
indicator.style.width = width + 'px'; /* 下線の幅を更新 */
indicator.style.transform = `translateX(${left}px)`; /* 下線のX位置を更新 */
}
/* タブ選択処理:ARIA更新/パネル表示切替/下線移動/視認性スクロール */
function selectTab(tab, {focus=true, ensureVisible=true} = {}){
const targetId = tab.dataset.panel; /* 紐づくパネルID */
const targetPanel = root.querySelector('#' + targetId); /* 対応パネル取得 */
tabs.forEach(t => t.setAttribute('aria-selected', String(t === tab))); /* 選択更新 */
panels.forEach(p => p.setAttribute('aria-hidden', String(p !== targetPanel))); /* 表示更新 */
moveIndicator(tab); /* 下線を移動 */
if(focus) tab.focus({preventScroll:true}); /* フォーカス(スクロール抑制) */
if(ensureVisible){
/* タブが見切れていれば中央付近にスクロール */
const tRect = tab.getBoundingClientRect();
const tlRect = tablist.getBoundingClientRect();
if(tRect.left < tlRect.left || tRect.right > tlRect.right){
tab.scrollIntoView({block:'nearest', inline:'center', behavior:'smooth'});
}
}
}
/* クリックで選択切替 */
tabs.forEach(tab => tab.addEventListener('click', () => selectTab(tab)));
/* キーボード操作:左右矢印/Home/End */
tablist.addEventListener('keydown', (e) => {
const current = document.activeElement.closest('.tab'); /* 現在のフォーカスタブ */
if(!current) return;
let i = tabs.indexOf(current); /* インデックス取得 */
if(e.key === 'ArrowRight'){ i = (i+1) % tabs.length; selectTab(tabs[i]); e.preventDefault(); }
else if(e.key === 'ArrowLeft'){ i = (i-1+tabs.length) % tabs.length; selectTab(tabs[i]); e.preventDefault(); }
else if(e.key === 'Home'){ selectTab(tabs[0]); e.preventDefault(); }
else if(e.key === 'End'){ selectTab(tabs[tabs.length-1]); e.preventDefault(); }
});
/* 初期化:あらかじめ選ばれているタブ(なければ先頭)へ下線を合わせる */
selectTab(getSelectedTab(), {focus:false, ensureVisible:false});
/* リサイズ・横スクロールで都度再計算(フォントや幅の変化に追随) */
const update = () => moveIndicator(getSelectedTab());
window.addEventListener('resize', update, {passive:true}); /* 画面幅変更 */
tablist.addEventListener('scroll', update, {passive:true});/* 横スクロール */
/* 初回ロード後、フォント計測の遅延でズレる場合に備え数フレーム補正 */
window.addEventListener('load', () => {
let n = 0;
const fix = () => { update(); if(++n < 6) requestAnimationFrame(fix); }; /* 6フレーム程度 */
fix();
}, {once:true});
})();
コメント