iOS アプリや Android アプリにあるようなタブ コンポーネントを作成する方法の基本的な概要。
この記事では、レスポンシブで、複数のデバイス入力に対応し、ブラウザ間で動作するウェブ用のタブ コンポーネントを構築する際の考え方を紹介します。デモをお試しください。
動画でご覧になりたい場合は、こちらの YouTube 版をご覧ください。
概要
タブはデザイン システムの一般的なコンポーネントですが、さまざまな形状や形式があります。最初は <frame>
要素で構築されたパソコン版のタブがありましたが、今では物理プロパティに基づいてコンテンツをアニメーション化する、スムーズなモバイル コンポーネントがあります。これらはすべて、容量を節約することを目的としています。
現在、タブのユーザー エクスペリエンスの基本は、表示フレーム内のコンテンツの表示 / 非表示を切り替えるボタン ナビゲーション領域です。さまざまなコンテンツ エリアが同じスペースを共有していますが、ナビゲーションで選択されたボタンに基づいて条件付きで表示されます。

ウェブ戦術
全体として、このコンポーネントはいくつかの重要なウェブ プラットフォーム機能のおかげで、かなり簡単に構築できました。
- 適切なスクロール停止位置でのエレガントなスワイプとキーボード操作のための
scroll-snap-points
- ブラウザで処理されるページ内スクロール アンカーと共有のサポートのための URL ハッシュによるディープリンク
<a>
要素とid="#hash"
要素のマークアップによるスクリーン リーダーのサポートprefers-reduced-motion
: クロスフェード トランジションとページ内スクロールを有効にします。- 選択したタブの動的な下線と色の変更のためのドラフト
@scroll-timeline
ウェブ機能
HTML
基本的な UX は、リンクをクリックすると、URL がネストされたページの状態を表し、ブラウザが一致する要素までスクロールすると、コンテンツ領域が更新されるというものです。
リンクや :target
など、構造コンテンツ メンバーがいくつかあります。リンクのリスト(<nav>
が最適)と <article>
要素のリスト(<section>
が最適)が必要です。各リンクのハッシュはセクションと一致し、ブラウザはアンカーを使用してスクロールできます。
たとえば、Chrome 89 では、リンクをクリックすると :target
記事に自動的にフォーカスが移動します。JS は不要です。ユーザーは、これまでどおり入力デバイスで記事のコンテンツをスクロールできます。マークアップで示されているように、これは無料のコンテンツです。
次のマークアップを使用してタブを整理しました。
<snap-tabs>
<header>
<nav>
<a></a>
<a></a>
<a></a>
<a></a>
</nav>
</header>
<section>
<article></article>
<article></article>
<article></article>
<article></article>
</section>
</snap-tabs>
<a>
要素と <article>
要素の接続は、次のように href
プロパティと id
プロパティで確立できます。
<snap-tabs>
<header>
<nav>
<a href="#responsive"></a>
<a href="#accessible"></a>
<a href="#overscroll"></a>
<a href="#more"></a>
</nav>
</header>
<section>
<article id="responsive"></article>
<article id="accessible"></article>
<article id="overscroll"></article>
<article id="more"></article>
</section>
</snap-tabs>
次に、記事にさまざまな量の lorem を入力し、リンクにさまざまな長さのタイトルと画像のセットを入力しました。コンテンツが用意できたら、レイアウトを開始できます。
スクロール可能なレイアウト
このコンポーネントには、次の 3 種類のスクロール領域があります。
- ナビゲーション (ピンク)は水平方向にスクロール可能
- コンテンツ領域 (青)は水平方向にスクロール可能
- 各記事アイテム (緑色)は縦方向にスクロールできます。

スクロールには 2 種類の要素が関係しています。
- ウィンドウ
overflow
プロパティ スタイルを持つ、定義されたサイズのボックス。 - 大きすぎるサーフェス
このレイアウトでは、リスト コンテナ(ナビゲーション リンク、セクション記事、記事コンテンツ)が該当します。
レイアウト: <snap-tabs>
選択した最上位レイアウトは flex(Flexbox)です。方向を column
に設定したため、ヘッダーとセクションは縦方向に並べられます。これは最初のスクロール ウィンドウで、overflow: hidden を使用してすべてを非表示にしています。ヘッダーとセクションは、個別のゾーンとしてまもなくオーバースクロールを採用します。
<snap-tabs> <header></header> <section></section> </snap-tabs>
snap-tabs { display: flex; flex-direction: column; /* establish primary containing box */ overflow: hidden; position: relative; & > section { /* be pushy about consuming all space */ block-size: 100%; } & > header { /* defend againstneeding 100% */ flex-shrink: 0; /* fixes cross browser quarks */ min-block-size: fit-content; } }
カラフルな 3 スクロール図に戻ると、
- これで、
<header>
は(ピンク)のスクロール コンテナになる準備が整いました。 <section>
は、(青色)のスクロール コンテナになる準備ができています。
以下の VisBug でハイライト表示したフレームは、スクロール コンテナが作成したウィンドウを確認するのに役立ちます。

タブ <header>
レイアウト
次のレイアウトもほぼ同じです。flex を使用して縦方向の順序付けを行っています。
<snap-tabs> <header> <nav></nav> <span class="snap-indicator"></span> </header> <section></section> </snap-tabs>
header { display: flex; flex-direction: column; }
.snap-indicator
はリンクのグループとともに水平方向に移動する必要があります。このヘッダー レイアウトは、そのための準備に役立ちます。絶対位置にある要素はありません。

次に、スクロール スタイルです。2 つの水平スクロール領域(ヘッダーとセクション)間でスクロール スタイルを共有できることがわかったので、ユーティリティ クラス .scroll-snap-x
を作成しました。
.scroll-snap-x {
/* browser decide if x is ok to scroll and show bars on, y hidden */
overflow: auto hidden;
/* prevent scroll chaining on x scroll */
overscroll-behavior-x: contain;
/* scrolling should snap children on x */
scroll-snap-type: x mandatory;
@media (hover: none) {
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
}
それぞれ、x 軸のオーバーフロー、オーバースクロールをトラップするスクロール コンテインメント、タッチデバイスの非表示のスクロールバー、コンテンツの表示領域をロックするスクロール スナップが必要です。キーボードのタブ順序はアクセス可能で、操作によってフォーカスが自然に移動します。スクロール スナップ コンテナは、キーボードからカルーセル スタイルのインタラクションも取得します。
タブのヘッダー <nav>
のレイアウト
ナビゲーション リンクは、改行なしで 1 行に配置し、垂直方向の中央に配置する必要があります。また、各リンク アイテムはスクロール スナップ コンテナにスナップする必要があります。2021 年の CSS の迅速な作業
<nav> <a></a> <a></a> <a></a> <a></a> </nav>
nav { display: flex; & a { scroll-snap-align: start; display: inline-flex; align-items: center; white-space: nowrap; } }
各リンクはスタイルとサイズを独自に設定するため、ナビゲーション レイアウトでは方向とフローのみを指定する必要があります。ナビゲーション アイテムの幅がそれぞれ異なるため、インジケーターが新しいターゲットに合わせて幅を調整する際に、タブ間の切り替えが楽しくなります。ここに含まれる要素の数に応じて、ブラウザはスクロールバーを表示したり、表示しなかったりします。

タブ <section>
レイアウト
このセクションはフレキシブル アイテムであり、スペースの主なコンシューマーである必要があります。また、記事を配置する列を作成する必要もあります。CSS 2021 の迅速な対応に感謝いたします。block-size: 100%
は、この要素を可能な限り親に合わせて拡大し、独自のレイアウトでは、親の幅の 100%
の列を連続して作成します。ここでは、親に強い制約を記述しているため、パーセンテージがうまく機能します。
<section> <article></article> <article></article> <article></article> <article></article> </section>
section { block-size: 100%; display: grid; grid-auto-flow: column; grid-auto-columns: 100%; }
これは、「可能な限り縦方向に拡張する」という指示を出すようなものです(flex-shrink: 0
に設定したヘッダーを思い出してください。これは、この拡張のプッシュに対する防御です)。これにより、一連の全高列の行の高さが設定されます。auto-flow
スタイルは、子を常に水平方向に配置し、折り返さずに親ウィンドウをオーバーフローさせるようにグリッドに指示します。これはまさに私たちが望む動作です。

これらの概念を理解するのが難しいと感じることもあります。このセクション要素はボックスに収まっていますが、ボックスのセットも作成しています。このビジュアルと説明がお役に立てば幸いです。
タブ <article>
レイアウト
ユーザーは記事のコンテンツをスクロールできる必要があります。スクロールバーは、オーバーフローがある場合にのみ表示されるようにします。これらの記事要素は、すっきりとした位置に配置されています。同時にスクロールの親とスクロールの子になります。ブラウザは、タッチ、マウス、キーボードの複雑な操作を処理しています。
<article> <h2></h2> <p></p> <p></p> <h2></h2> <p></p> <p></p> ... </article>
article { scroll-snap-align: start; overflow-y: auto; overscroll-behavior-y: contain; }
記事を親スクローラー内にスナップするようにしました。ナビゲーション リンク アイテムと記事要素が、それぞれのスクロール コンテナのインライン開始位置にスナップするのが気に入っています。調和のとれた関係に見えます。

記事はグリッドの子であり、サイズはスクロール UX を提供するビューポート領域に事前に決定されています。つまり、ここでは高さや幅のスタイルは必要なく、オーバーフローの処理方法を定義するだけでよいのです。overflow-y を auto に設定し、便利な overscroll-behavior プロパティでスクロール操作をトラップします。
3 つのスクロール領域のまとめ
下の例では、システム設定で [常にスクロールバーを表示する] を選択しています。レイアウトとスクロール オーケストレーションを確認するだけでなく、この設定をオンにした状態でレイアウトが機能することも重要だと思います。

このコンポーネントにスクロールバー ガターを表示することで、スクロール領域、サポートされている方向、相互のやり取りを明確に示せると思います。これらのスクロール ウィンドウ フレームがレイアウトのフレックスまたはグリッドの親でもあることを考慮してください。
DevTools を使用すると、これを可視化できます。

スクロール レイアウトは、スナップ、ディープリンク可能、キーボード アクセシビリティに対応しています。UX の強化、スタイル、楽しさのための強固な基盤。
注目の機能
スクロールでスナップされた子は、サイズ変更中もロックされた位置を維持します。つまり、デバイスの回転やブラウザのサイズ変更時に、JavaScript で何かを表示する必要がなくなります。Chromium DevTools のデバイスモードで、[レスポンシブ] 以外のモードを選択し、デバイス フレームのサイズを変更して試してみてください。要素がビュー内に留まり、コンテンツとともにロックされていることに注目してください。これは、Chromium が仕様に合わせて実装を更新したときから利用可能になっています。詳しくは、こちらのブログ投稿をご覧ください。
アニメーション
ここでのアニメーションの目的は、インタラクションと UI フィードバックを明確にリンクさせることです。これにより、ユーザーがすべてのコンテンツをシームレスに発見できるよう、ガイドやアシストを行うことができます。目的を持って、条件付きでモーションを追加します。ユーザーはオペレーティング システムでモーション設定を指定できるようになりました。私はインターフェースでユーザーの設定に応えるのがとても楽しいです。
タブの下線を記事のスクロール位置にリンクします。スナップは、単に美しい配置だけでなく、アニメーションの開始と終了を固定するものでもあります。これにより、ミニマップのように機能する <nav>
がコンテンツに接続されたままになります。CSS と JS の両方からユーザーのモーション設定を確認します。配慮すべき場所はいくつかあります。
スクロールの動作
:target
と element.scrollIntoView()
の両方のモーション動作を強化する機会があります。デフォルトでは、即時です。ブラウザはスクロール位置を設定するだけです。では、その位置で点滅するのではなく、そのスクロール位置に移行したい場合はどうすればよいでしょうか?
@media (prefers-reduced-motion: no-preference) {
.scroll-snap-x {
scroll-behavior: smooth;
}
}
ここではモーション(スクロールなど、ユーザーが制御しないモーション)を導入するため、ユーザーがオペレーティング システムでモーションの低減に関する設定をしていない場合にのみ、このスタイルを適用します。これにより、スクロール モーションを導入するのは、それを望むユーザーのみとなります。
タブ インジケーター
このアニメーションの目的は、インジケーターとコンテンツの状態を関連付けることです。動きを抑えた表示を好むユーザーには border-bottom
スタイルの色をクロスフェードさせ、動きを気にしないユーザーにはスクロールに連動したスライドと色のフェード アニメーションを表示することにしました。
Chromium Devtools で、設定を切り替えて 2 つの異なるトランジション スタイルをデモできます。この構築はとても楽しかったです。
@media (prefers-reduced-motion: reduce) {
snap-tabs > header a {
border-block-end: var(--indicator-size) solid hsl(var(--accent) / 0%);
transition: color .7s ease, border-color .5s ease;
&:is(:target,:active,[active]) {
color: var(--text-active-color);
border-block-end-color: hsl(var(--accent));
}
}
snap-tabs .snap-indicator {
visibility: hidden;
}
}
ユーザーがモーションの低減を希望している場合は、.snap-indicator
を非表示にします。これは、もう必要ないためです。次に、border-block-end
スタイルと transition
に置き換えます。また、タブのインタラクションでは、アクティブなナビゲーション アイテムにブランドの下線ハイライトだけでなく、テキストの色も濃くなっていることに注目してください。アクティブな要素は、テキストの色コントラストが高く、明るいアンダーライト アクセントがあります。
CSS を数行追加するだけで、ユーザーは(モーション設定を尊重しているという意味で)配慮されていると感じます。気に入ったわ
@scroll-timeline
前のセクションでは、モーションの低減のクロスフェード スタイルを処理する方法を紹介しました。このセクションでは、インジケーターとスクロール領域をリンクする方法を紹介します。次は、楽しい試験運用中の機能をご紹介します。皆様も私と同じくらいワクワクしていることを願っています。
const { matches:motionOK } = window.matchMedia(
'(prefers-reduced-motion: no-preference)'
);
まず、JavaScript からユーザーのモーション設定を確認します。この結果が false
の場合、つまりユーザーがモーションの低減を希望している場合は、スクロール リンク モーション効果は実行されません。
if (motionOK) {
// motion based animation code
}
この記事の執筆時点では、@scroll-timeline
のブラウザ サポートはありません。これは、試験的な実装のみを含むドラフト仕様です。ただし、このデモで使用するポリフィルがあります。
ScrollTimeline
CSS と JavaScript のどちらでもスクロール タイムラインを作成できますが、アニメーションでライブ要素の測定値を使用できるように、JavaScript を選択しました。
const sectionScrollTimeline = new ScrollTimeline({
scrollSource: tabsection, // snap-tabs > section
orientation: 'inline', // scroll in the direction letters flow
fill: 'both', // bi-directional linking
});
1 つの要素が別の要素のスクロール位置に追従するようにしたいので、ScrollTimeline
を作成してスクロール リンクのドライバである scrollSource
を定義します。通常、ウェブ上のアニメーションはグローバル タイムフレームの刻みに沿って実行されますが、メモリ内のカスタム sectionScrollTimeline
を使用すると、そのすべてを変更できます。
tabindicator.animate({
transform: ...,
width: ...,
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
アニメーションのキーフレームに入る前に、スクロールのフォロワーである tabindicator
が、カスタム タイムライン(セクションのスクロール)に基づいてアニメーション化されることを指摘しておくことが重要だと思います。これでリンクは完了しますが、最終的な要素である、アニメーションのキーフレームとなるステートフル ポイントがありません。
動的キーフレーム
@scroll-timeline
を使用してアニメーション化する強力な純粋な宣言型 CSS の方法がありますが、私が選択したアニメーションは動的すぎました。auto
の幅を切り替える方法はありません。また、子要素の長さに応じてキーフレームの数を動的に作成する方法もありません。
ただし、JavaScript はその情報を取得する方法を認識しているため、子を自分で反復処理し、実行時に計算された値を取得します。
tabindicator.animate({
transform: [...tabnavitems].map(({offsetLeft}) =>
`translateX(${offsetLeft}px)`),
width: [...tabnavitems].map(({offsetWidth}) =>
`${offsetWidth}px`)
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
各 tabnavitem
について、offsetLeft
の位置を分割代入し、それを translateX
値として使用する文字列を返します。これにより、アニメーションの 4 つの変換キーフレームが作成されます。幅についても同様の処理が行われます。各要素に動的な幅が問い合わせられ、キーフレーム値として使用されます。
フォントとブラウザの設定に基づく出力例を次に示します。
TranslateX キーフレーム:
[...tabnavitems].map(({offsetLeft}) =>
`translateX(${offsetLeft}px)`)
// results in 4 array items, which represent 4 keyframe states
// ["translateX(0px)", "translateX(121px)", "translateX(238px)", "translateX(464px)"]
幅のキーフレーム:
[...tabnavitems].map(({offsetWidth}) =>
`${offsetWidth}px`)
// results in 4 array items, which represent 4 keyframe states
// ["121px", "117px", "226px", "67px"]
この戦略をまとめると、タブ インジケーターは、セクション スクロールのスクロール スナップ位置に応じて、4 つのキーフレーム間をアニメーションで移動するようになります。スナップポイントはキーフレーム間の境界を明確にし、アニメーションの同期感を高めます。

ユーザーの操作によってアニメーションが駆動され、インジケーターの幅と位置がセクションごとに変化し、スクロールと完全に同期します。
お気づきでないかもしれませんが、ハイライト表示されたナビゲーション アイテムが選択されたときの色の変化は、私がとても誇りに思っている部分です。
ハイライト表示されたアイテムのコントラストが高いほど、選択されていない薄いグレーがさらに奥に引っ込んだように見えます。テキストの色を切り替えるのは、ホバー時や選択時などによく行われますが、スクロール時に下線インジケーターと同期して色を切り替えるのは、さらに高度な手法です。
手順は次のとおりです。
tabnavitems.forEach(navitem => {
navitem.animate({
color: [...tabnavitems].map(item =>
item === navitem
? `var(--text-active-color)`
: `var(--text-color)`)
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
});
各タブナビリンクには、下線インジケーターと同じスクロール タイムラインを追跡するこの新しいカラー アニメーションが必要です。タイムラインは以前と同じものを使用します。スクロール時にティックを発行する役割があるため、そのティックを任意のタイプのアニメーションで使用できます。前と同様に、ループ内に 4 つのキーフレームを作成し、色を返します。
[...tabnavitems].map(item =>
item === navitem
? `var(--text-active-color)`
: `var(--text-color)`)
// results in 4 array items, which represent 4 keyframe states
// [
"var(--text-active-color)",
"var(--text-color)",
"var(--text-color)",
"var(--text-color)",
]
色が var(--text-active-color)
のキーフレームはリンクをハイライト表示し、それ以外の場合は標準のテキストの色になります。ネストされたループがあるため、比較的簡単です。外側のループは各ナビゲーション アイテムで、内側のループは各ナビゲーション アイテムの個人用キーフレームです。外側のループ要素が内側のループ要素と同じかどうかを確認し、選択されたタイミングを把握するために使用します。
この文章を書くのはとても楽しかったです。大好きさ
JavaScript のさらなる強化
ここで紹介するものの核となる部分は、JavaScript なしで動作することを覚えておいてください。それでは、JS が利用可能な場合にどのように拡張できるかを見てみましょう。
ディープリンク
ディープリンクはモバイル用語ですが、タブのコンテンツに直接 URL を共有できるという点で、タブはディープリンクの意図を満たしていると思います。ブラウザは、URL ハッシュで一致した ID にページ内移動します。この onload
ハンドラを使用すると、プラットフォーム間で効果が得られることがわかりました。
window.onload = () => {
if (location.hash) {
tabsection.scrollLeft = document
.querySelector(location.hash)
.offsetLeft;
}
}
スクロール終了の同期
ユーザーは常にクリックしたりキーボードを使用したりしているわけではありません。自由にスクロールしていることもあります。セクション スクロールが停止したときに、その位置が上部のナビゲーション バーで一致している必要があります。
スクロールの終了を待つ方法は次のとおりです。
js
tabsection.addEventListener('scroll', () => {
clearTimeout(tabsection.scrollEndTimer);
tabsection.scrollEndTimer = setTimeout(determineActiveTabSection, 100);
});
セクションがスクロールされるたびに、セクションのタイムアウトがあればクリアして、新しいタイムアウトを開始します。セクションのスクロールが停止したら、タイムアウトをクリアせず、停止してから 100 ミリ秒後に発火します。イベントが発生したら、ユーザーがどこで停止したかを特定する関数を呼び出します。
const determineActiveTabSection = () => {
const i = tabsection.scrollLeft / tabsection.clientWidth;
const matchingNavItem = tabnavitems[i];
matchingNavItem && setActiveTab(matchingNavItem);
};
スクロールがスナップされたと仮定すると、現在のスクロール位置をスクロール領域の幅で割った結果は、整数になり、小数にはなりません。次に、この計算されたインデックスを使用してキャッシュから navitem を取得しようとします。一致するものが見つかった場合は、一致するものを送信してアクティブに設定します。
const setActiveTab = tabbtn => {
tabnav
.querySelector(':scope a[active]')
.removeAttribute('active');
tabbtn.setAttribute('active', '');
tabbtn.scrollIntoView();
};
アクティブなタブを設定するには、まず現在アクティブなタブをクリアしてから、新しいナビゲーション アイテムにアクティブ状態の属性を付与します。scrollIntoView()
の呼び出しには、CSS との興味深いやり取りがあります。
.scroll-snap-x {
overflow: auto hidden;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
@media (prefers-reduced-motion: no-preference) {
scroll-behavior: smooth;
}
}
水平スクロール スナップ ユーティリティ CSS では、ユーザーがモーション トレラントの場合に smooth
スクロールを適用するメディアクエリをネストしています。JavaScript はスクロール要素をビューに呼び出すことができ、CSS は UX を宣言的に管理できます。ときには、とても素敵な組み合わせになります。
まとめ
私がどのようにして達成したかをご理解いただけたかと思います。では、あなたならどうしますか?これにより、コンポーネント アーキテクチャが楽しくなります。お気に入りのフレームワークでスロット付きの最初のバージョンを作成するのは誰ですか?🙂
アプローチを多様化し、ウェブで構築するさまざまな方法を学びましょう。Glitch を作成して、そのバージョンを ツイートしてください。下のコミュニティ リミックス セクションに追加します。
コミュニティ リミックス
- @devnook、@rob_dodson、@DasSurma によるウェブ コンポーネントに関する記事。
- @jhvanderschee(ボタン付き): Codepen。