CSS タブ 切り替え アニメーション9選 — JS最小・CSSだけ
css タブ 切り替え アニメーションは、ラジオボタンの:checked状態やrole="tablist"ボタンだけでインジケーターが滑り、パネルの中身が入れ替わる仕組みです。9個のうち7個はCSSだけで動きますが、矢印キー操作が要る2個だけは実際にJavaScriptを使います。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
9個は人気順ではなく、タブが何を担うかで選ぶ順に並べています。インジケーターだけが動くもの(01〜02)から、パネルの中身まで一緒に変わるもの(03〜04)へ進み、そのあとはアクセシビリティツリーを自分で組む実務向けUI(05)とタブが横にはみ出す場合・縦に積み重なる場合(06〜07)、最後は装飾が要るもの(08〜09)という並びです。
01下線インジケーターのスライド
タブ3つを<input type="radio">で作り、ラベル下の下線spanを各ラジオの:checked ~兄弟結合子で動かします。ネイティブのラジオグループはブラウザが標準で矢印キー移動に対応しているため、JSなしでもキーボードだけで切り替えられます。
.us__indicator {
position: absolute; left: 0; bottom: 0;
width: calc(100% / 3); height: 3px;
transform: translateX(0);
transition: transform $duration $easing;
}
#us-1:checked ~ .us__nav .us__indicator { transform: translateX(0%); }
#us-2:checked ~ .us__nav .us__indicator { transform: translateX(100%); }
#us-3:checked ~ .us__nav .us__indicator { transform: translateX(200%); }
02ピル背景の移動
タブの背後にある錠剤形の背景が、選んだタブの位置までtranslateXとscaleXで移動・変形します。ラベルの文字数がそれぞれ違うため、幅はほかの項目と違って固定の定数で先に決めてあります。
.pi__pill {
position: absolute; top: $sp-1; left: $sp-1; bottom: $sp-1;
width: $w1;
transform: translateX(0) scaleX(1);
transform-origin: left center;
transition: transform $duration $easing;
}
// scaleXの比率はコンパイル時点の数値で先に計算しておく
// (calc()の中で長さ÷長さは仕様上できず、実行時には計算できない)
#pi-2:checked ~ .pi__nav .pi__pill {
transform: translateX(calc(#{$w1} + #{$sp-2})) scaleX(1.357);
}
03パネルフェード
パネル3枚を同じgridセルに重ねておき、選んだタブの:checked状態だけでopacityを0から1へ切り替えます。実装そのものにJSは要らず、デモのループ用アニメーションを止める3行だけが別に付いています。
.fp__panel {
grid-area: 1 / 1;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing;
}
#fp-1:checked ~ .fp__panels .fp__panel--1,
#fp-2:checked ~ .fp__panels .fp__panel--2,
#fp-3:checked ~ .fp__panels .fp__panel--3 { opacity: 1; pointer-events: auto; }
04パネルスライド
パネル3枚を横に並べたトラックごとtranslateX(-33.333%×n)で押し出し、選んだタブのパネルだけを表示枠に収めます。タブ下の下線インジケーターも同じ:checkedで動くうえ、デモループでは同じ@keyframesの拍子をトラックと共有していて、自動再生中もずれないようにしてあります。
.sp__track {
display: flex;
width: 300%;
transform: translateX(0%);
transition: transform $duration $easing;
}
#sp-1:checked ~ .sp__viewport .sp__track { transform: translateX(0%); }
#sp-2:checked ~ .sp__viewport .sp__track { transform: translateX(-33.333%); }
#sp-3:checked ~ .sp__viewport .sp__track { transform: translateX(-66.667%); }
05アイコンタブ
ここからはラジオではなく、本物のrole="tablist"とrole="tab"のボタンです。クリックと左右矢印キーが同じselect()関数を呼び、aria-selectedとフォーカス、tabindexのロービングを一緒に動かします。選ばれたアイコンだけがscale(1.18)でわずかに拡大します。
var root = document.querySelector('.it'), tabs = [].slice.call(root.querySelectorAll('[role=tab]'));
function select(i) {
tabs.forEach(function (t, j) { var on = j === i; t.setAttribute('aria-selected', on); t.tabIndex = on ? 0 : -1; });
tabs[i].focus(); root.classList.remove('is-demo');
}
tabs.forEach(function (t, i) {
t.addEventListener('click', function () { select(i); });
t.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') select((i + 1) % tabs.length);
else if (e.key === 'ArrowLeft') select((i - 1 + tabs.length) % tabs.length);
});
});
06横スナップタブ
タブ5個を同じ固定幅(88px)にしてoverflow-x:autoとscroll-snap-type:x mandatoryを掛け、各タブにscroll-snap-align:startを付けています。下線インジケーターは:checkedごとにtranslateX(88px×n)で追いかけ、文字色と太さは固定したまま下線だけで選択状態を伝えます。
.st__scroller {
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
}
.st__tab {
flex: 0 0 $tab-w;
scroll-snap-align: start;
}
@for $i from 1 through 5 {
#st-#{$i}:checked ~ .st__scroller .st__indicator { transform: translateX(#{$tab-w * ($i - 1)}); }
}
07縦タブ
タブが縦に積まれたサイドメニューで、選んだタブの左にある幅2pxの棒インジケーターがtranslateYで上下に滑り、今開いている項目を示します。横版(01)の軸を変えただけに見えますが、縦の一覧は項目数が増えやすいためインジケーターの高さを行の高さに合わせて計算してあります。
.vt__indicator {
position: absolute; left: -2px; top: 0;
width: 2px; height: $row-h;
transform: translateY(0);
transition: transform $duration $easing;
}
#vt-1:checked ~ .vt__nav .vt__indicator { transform: translateY(0); }
#vt-2:checked ~ .vt__nav .vt__indicator { transform: translateY(#{$row-h}); }
#vt-3:checked ~ .vt__nav .vt__indicator { transform: translateY(#{$row-h * 2}); }
08タブバウンス
インジケーターが目標地点をスプリングイージングで少し行き過ぎてから戻り、選んだタブの文字も@keyframesで短く弾みます。文字色は常に同じインクの色で固定してあり、活性タブはインジケーターの位置と弾みだけで伝えます。
.bt__indicator {
position: absolute; top: $sp-1; bottom: $sp-1; left: $sp-1;
width: calc((100% - #{$sp-1} * 2) / 3);
transform: translateX(0);
transition: transform $duration $easing;
}
#bt-2:checked ~ .bt__nav .bt__indicator { transform: translateX(100%); }
#bt-2:checked ~ .bt__nav label[for="bt-2"] .bt__text { animation: bt-pop 400ms $easing; }
@keyframes bt-pop { 55% { transform: scale(1.18); } }
09タブ件数バッジ
受信トレイのように数字バッジが付いたタブ3つを、05と同じrole="tablist"構造・同じselect()関数で動かします。新しく開いたタブのバッジだけがscaleで短く跳ね上がり、今どの箱が開いているかを目立たせます。
.cb__badge {
transform: scale(1);
transition: transform $duration $easing;
}
.cb__tab[aria-selected="true"] .cb__badge {
animation: cb-pop $duration $easing;
}
@keyframes cb-pop {
0% { transform: scale(1); }
55% { transform: scale(1.35); }
100% { transform: scale(1); }
}
どこで崩れるか — 落とし穴
9つとも動かしているのはtransformとopacityだけですが、02のピル背景を作っているとき、タブの幅の比率をscaleX(calc(76px / 56px))のように実行時のcalc()の中で割ろうとしたら、値がまるごと無効になってピルが少しも動きませんでした。CSSのcalc()は長さを長さで割ることを仕様上許していないため、比率はSCSSのコンパイル時点で数値として先に計算し埋め込む必要があります。
もっと大きく踏んだ落とし穴は、自動再生のループと本当の:checked状態が別々に動いてしまうことでした。04のパネルスライドで、パネルの入ったトラックとタブ下の下線インジケーターを別々の@keyframesで作ったところ、自動再生中にトラックは2段階目の内容を見せているのに下線は1段階目に残ったままという場面が実際に撮れました。08のタブバウンスでも選んだタブの文字を明るい色に変えていたら、インジケーターが自動再生で別のタブへ行っている間にその明るい文字が白い舞台の上に残り、ほとんど読めないコントラスト不足が実測で出ました。この圧縮ファイルの32dvwmreは、両方の問題を同じ@keyframesの拍子にまとめ、文字色を固定して直した後の9個のファイルを開きます。
アクセシビリティ
01〜04と06〜08はネイティブの<input type="radio">グループなので、追加のコードなしでTabキーでグループに入り、←→(縦タブの07だけ↑↓)で項目を移動できます。05と09だけはrole="tablist"・role="tab"・aria-selectedを直接書いた場所で、クリックと矢印キーの両方でフォーカスとaria-selectedを一緒に動かすselect()関数が必須です。prefers-reduced-motion: reduceでは9つ全部でtransitionとanimationが止まり、インジケーター・パネル・バッジは今のタブの最終状態のまま止まって表示されます。05・09が従っているキーボード操作の基準はW3C ARIA Authoring PracticesのタブUIパターンです。
タブメニュー以外のCSSアニメーションをまとめて見るならCSSのカテゴリー一覧へ、サイトの成り立ちを知りたいときは紹介ページへどうぞ。
FAQ
9つとも本当にJavaScriptなしで動きますか?
いいえ、正確には9個中7個(01〜04・06〜08)だけがJSなしで動きます。ネイティブのラジオグループの:checkedだけでインジケーターとパネルが切り替わるため、実装にJSファイルは要りません。05と09だけはマークアップがボタンでネイティブのラジオグループの助けを借りられないため、role="tablist"を使い、クリックと矢印キーを処理する9行のJSが必須です。
Reactではラジオの代わりに何を使いますか?
useStateで活性タブのインデックスを1つだけ持ち、インジケーターの位置はそのインデックスをCSS変数--iとして渡してtransform: translateX(calc(var(--i) * 100%))のように計算します。変数duration・easing・colorの3つもpropsで受け取り、同じCSS変数の仕組みで渡します。
縦タブ(07)の矢印キーは横タブと同じ向きで動きますか?
いいえ、01・02・04・06・08のような横並びのラジオグループは←→キーで項目を移動しますが、07は縦に積まれているためブラウザの標準動作で↑↓キーが使われます。05・09のrole="tablist"は横並びのボタンなので←→キーをJS側で明示的に処理しています。