タブ下線スライド遷移
アクティブタブの実寸を計測し、下線インジケーターを transform で滑走させるタブ切替。内容のフェードと同期し、文字数が違っても正確に追従します。
ライブデモ
使用例(お題: 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。