デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
画面に収まらない数のタブを、窓の中に並べた帯ごと横へ送ります。両端の矢印で送り、タブを押すと下線がそこへ滑って、同時にそのタブが帯の中央へ寄ります。端まで送ると、その側の矢印と、途切れを示すグラデーションが薄くなります。下線が滑るだけのタブとの違いは、収まらない帯を送るところにあります。
送りも下線も 0.5 秒の power3.inOut で、長さと曲線をそろえてあります。ひと続きの動きに見えるので、押したタブを目で追ったまま帯の移動についていけます。矢印 1 回で送るのは窓の幅の 55% で、送り先が端を越えるときは端で止めます。タブの文字の色は、下線がその文字にどれだけ重なっているかで決めるので、送っている途中も濃さが自然につながります。
位置と幅は、書体が読み込まれてから測ります。代わりの書体で表示されているうちに測ると、下線がタブの文字からずれたまま残ります。窓のはみ出しを隠すのは clip にしてください。hidden のままだと、窓の外のタブへ Tab キーで移ったときに、ブラウザが窓ごと横へ動かして帯の位置が狂います。
タブの列は tablist、それぞれのタブは aria-selected で、選んでいることを伝えます。Tab キーで入るのは選んでいるタブだけにし、隣のタブへは ← → キーで移って、移った先をすぐに選びます。キーボードで端のタブへ移ったときも中央へ寄せるので、焦点が窓の外に残りません。選んでいない中身は要素ごと見えなくして、読み上げと Tab の移動からも外します。
送る量、中央へ寄せるか端で止めるか、矢印を出すかどうかが主なつまみです。指で触る画面では矢印を隠して、横のスワイプに任せることもできます。章の多いドキュメント、記事の絞り込み、スマートフォンの横に長いタブの列に向いています。
コード
<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>
.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; }
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);
});