GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

CSS タブ 切り替え アニメーション9選 — JS最小・CSSだけ

css タブ 切り替え アニメーションは、ラジオボタンの:checked状態やrole="tablist"ボタンだけでインジケーターが滑り、パネルの中身が入れ替わる仕組みです。9個のうち7個はCSSだけで動きますが、矢印キー操作が要る2個だけは実際にJavaScriptを使います。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

9個の「css タブ アニメーション」は人気順ではなく、タブが何を担うかで選ぶ順に並べています。インジケーターだけが動くもの(01〜02)から、パネルの中身まで一緒に変わるもの(03〜04)へ進み、そのあとはアクセシビリティツリーを自分で組む実務向けUI(05)とタブが横にはみ出す場合・縦に積み重なる場合(06〜07)、最後は装飾が要るもの(08〜09)という並びです。同じ切り替えを画面の横へ押し出せばサイドバードロワーになり、ページごと入れ替える規模まで広げればCSS画面切り替えアニメーションの領分になります。

01下線インジケーターのスライド

:checked兄弟結合子で動かす

タブ3つを<input type="radio">で作り、ラベル下の下線spanを各ラジオの:checked ~兄弟結合子で動かします。ネイティブのラジオグループはブラウザが標準で矢印キー移動に対応しているため、JSなしでもキーボードだけで切り替えられます。下線の後ろには同じ@keyframesを120ms遅れて走る::afterの残像を重ね、目標を少し行き過ぎてから戻る慣性まで付けてあります。

下線インジケーターのスライド
新しいタブで開く

01 · 下線インジケーターのスライド — 計測 累積面積 2.301% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

:checkedtranslateX300ms

矢印キーが標準で効く理由

.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ピル背景の移動

錠剤形の背景が移動・変形する

「css タブ 切り替え デザイン」でよく見る形で、タブの背後にある錠剤形の背景が、選んだタブの位置までtranslateXとscaleXで移動・変形します。ラベルの文字数がそれぞれ違うため、幅はほかの項目と違って固定の定数で先に決めてあります。ピルの後ろにも01と同じ120ms遅延の::after残像が敷かれ、枠が入れ替わる瞬間に2枚が重なって見えるトレイルが生まれます。

ピル背景の移動
新しいタブで開く

02 · ピル背景の移動 — 計測 累積面積 3.272% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translateXscaleX:checked

幅を定数で先に決めた理由

.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パネルフェード

「タブ 切り替え css のみ」で足りる場面

パネル3枚を同じgridセルに重ねておき、選んだタブの:checked状態だけでopacityを0から1へ切り替えます。実装そのものにJSは要らず、デモのループ用アニメーションを止める3行だけが別に付いています。デモループでは現れるパネルが8px下から浮上して2px行き過ぎてから定位置に収まる慣性曲線を描き、フェードにも進行方向が残ります。

パネルフェード
新しいタブで開く

03 · パネルフェード — 計測 累積面積 2.14% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

opacitygrid 겹침220ms

デモループを止める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の拍子をトラックと共有していて、自動再生中もずれないようにしてあります。トラックは原本のまま残し、下線にだけ遅延残像を重ねたため、重いトラックに対して軽い下線がわずかに遅れて追いつく慣性の差が出ています。

パネルスライド
新しいタブで開く

04 · パネルスライド — 計測 累積面積 2.853% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translateXoverflow:hidden300ms

同じ拍子を共有する下線

.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="tablist"とrole="tab"のボタンです。クリックと左右矢印キーが同じselect()関数を呼び、aria-selectedとフォーカス、tabindexのロービングを一緒に動かします。選ばれたアイコンだけがscale(1.18)でわずかに拡大します。アイコンが弾む瞬間、後ろで円形の残像リングが広がりながら消えるため、キーボード移動だけでも視覚的な拍子が残ります。

アイコンタブ
新しいタブで開く

05 · アイコンタブ — 計測 累積面積 0.301% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

role=tablistaria-selectedscale

クリックと矢印キーを同じ関数で

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)で追いかけ、文字色と太さは固定したまま下線だけで選択状態を伝えます。イージングは目標を12.6%行き過ぎてから定まるlerp曲線に置き換え、後ろにトレイルを引かせてあるため、スクロールが硬く割り切れる隣でもインジケーターは慣性で流れます。

横スクロールスナップタブ
新しいタブで開く

06 · 横スクロールスナップタブ — 計測 累積面積 2.21% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

scroll-snap-typeoverflow-xtranslateX

下線だけで選択状態を伝える

.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)の軸を変えただけに見えますが、縦の一覧は項目数が増えやすいためインジケーターの高さを行の高さに合わせて計算してあります。棒の後ろには06と同じlerpトレイルが縦に敷かれ、一覧が長くなっても移動方向が一目で読めます。

縦タブ
新しいタブで開く

07 · 縦タブ — 計測 累積面積 1.549% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translateY:checked300ms

行の高さに合わせた高さ

.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で短く弾みます。文字色は常に同じインクの色で固定してあり、活性タブはインジケーターの位置と弾みだけで伝えます。インジケーターが通り過ぎた場所にトレイルがしばらく残り、どの枠からどの枠へ飛んだかがそのまま見えます。

タブバウンス
新しいタブで開く

08 · タブバウンス — 計測 累積面積 7.166% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

cubic-bezier 스프링scaletranslateX

文字色を固定する理由

.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タブ件数バッジ

05と同じ構造で動かす

受信トレイのように数字バッジが付いたタブ3つを、05と同じrole="tablist"構造・同じselect()関数で動かします。新しく開いたタブのバッジだけがscaleで短く跳ね上がり、今どの箱が開いているかを目立たせます。バッジの後ろに滲む残光の層が弾みの痕を残すため、数字が頻繁に変わる画面でも最後の変化がどの枠だったかを追えます。

タブ件数バッジ
新しいタブで開く

09 · タブ件数バッジ — 計測 累積面積 2.66% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

role=tablistaria-selectedscale

開いた箱だけ跳ねるバッジ

.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); }
}

どこで崩れるか — 落とし穴

calcは長さを長さで割れない

9つとも動かしているのはtransformとopacityだけですが、02のピル背景を作っているとき、タブの幅の比率をscaleX(calc(76px / 56px))のように実行時のcalc()の中で割ろうとしたら、値がまるごと無効になってピルが少しも動きませんでした。CSSのcalc()は長さを長さで割ることを仕様上許していないため、比率はSCSSのコンパイル時点で数値として先に計算し埋め込む必要があります。

自動再生と:checkedがずれる

タブの列を画面上部へ貼り付けて追従させるなら、CSSスティッキーヘッダーの追加9選がその固定層で生じる重なりを先に整理しています。

もっと大きく踏んだ落とし穴は、自動再生のループと本当の:checked状態が別々に動いてしまうことでした。04のパネルスライドで、パネルの入ったトラックとタブ下の下線インジケーターを別々の@keyframesで作ったところ、自動再生中にトラックは2段階目の内容を見せているのに下線は1段階目に残ったままという場面が実際に撮れました。08のタブバウンスでも選んだタブの文字を明るい色に変えていたら、インジケーターが自動再生で別のタブへ行っている間にその明るい文字が白い舞台の上に残り、ほとんど読めないコントラスト不足が実測で出ました。この圧縮ファイルの32dvwmreは、両方の問題を同じ@keyframesの拍子にまとめ、文字色を固定して直した後の9個のファイルを開きます。

アクセシビリティ

ラジオグループとtablistの違い

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

「css だけ で タブ 切り替え」は9つとも本当にできますか?

いいえ、正確には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側で明示的に処理しています。タブが十を超えて分類そのものが要る段階からは、メガメニューのUIのほうが適した器になります。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。