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

css タブ 切り替え ふわっと 9パターン — コードそのまま

css タブ 切り替え ふわっと の9パターンは、それぞれ違う値を計算の起点にしています。高さモーフは2枚のパネルの実測ピクセル差で伸びる時間を決め、スタック型はアクティブタブとのインデックス距離で縮小率を決め、弾性スライドはバネ定数で弾んで超える幅を決める — 同じタブなのに計算値はそれぞれ違う。

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

以下の9つのパネルはどれも、何かを取得したり移動したりしません。パネルの中身はHTMLに直接書き込まれた静的なテキストや短いリストで、Math.random も外部ライブラリも一切使っていません。中身を本物のコンテンツに差し替えても、下の切り替えロジックはそのまま使えます。

9つのタブパネル遷移の解剖 — どこで計算の一層が回るか

9つとも同じきっかけ(タブがクリックされる、または矢印キーで選ばれる)を受け取り、それぞれ違う値を計算します。高さモーフは2枚のパネルの実測高さがどれだけ離れているかを聞き、インデックス系の2つは何個タブを飛ばしたかを聞き、クリック起点の系は タブバーのどこをクリックしたかを聞き、スプリング型はどれだけ弾ませてから止めるかを聞きます。同じスライドの色替えが9回続くのではなく、9つの違う値が同じ切り替えの中で動いている — それがこのページのcss タブ トランジションです。

9つのうち7つ(01・02・03・04・06・07・08)はJavaScript側で一度だけ値を計算し、スタイルかクラスを一度書き換えて、残りはCSSトランジションに任せます。残る2つ(05・09)は requestAnimationFrame の毎フレームで値を計算し直します — 05は進行度からぼかしと不透明度を毎フレーム書き換え、09はバネの方程式を毎フレーム解き直します。07も requestAnimationFrame を回していますが、それはカードの横にある角度表示を再描画するためだけで、めくり自体は transform 一発のCSSトランジションなので、分類上はCSS遷移側に入ります。

この記事は セグメントコントロールUI 9選 — コピペOK の続編ではなく、別の層を扱っています。あちらはラベルの裏で動くスライダー自体の位置を計算する記事で、こちらはタブが選ばれたあとにパネルの中身に何が起きるか——高さ・クリップ形状・奥行き・ぼかし・角度・バネ位置——を計算する記事です。

パターン別コード

以下のコードは、それぞれのパターンが持つ「計算の一層」を担う関数そのものを、index.html からそのまま切り出したものです。結合も改名もしていません。

01パネル実測高さ差が伸ばすタブコンテナ

切り替え直前に出ていくパネルと入るパネルの実測高さを読み、その差(px)を getBoundingClientRect() で求めます — 推測ではありません。この差が大きいほどコンテナの wrap.style.height がゆっくり次のパネルの高さへ補間され、差がほとんどなければ一瞬で終わります。設定画面・プロフィールタブ・FAQセクションのように、パネルごとに高さがばらつく場面向けです。

パネル実測高さの差が伸ばすタブコンテナ
新しいタブで開く

01 · パネル実測高さの差が伸ばすタブコンテナ — 計測 累積面積 42.901% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getBoundingClientRect()wrap.style.height180~620ms
    var fromH = rectHeight(active);
    var toH = rectHeight(idx);
    var diff = Math.abs(toH - fromH); // 計算層: 2枚のパネルの実測高さの差(px)
    var dur = Math.min(MAX_MS, Math.max(MIN_MS, BASE_MS + diff * MS_PER_PX)); // 計算層: 差が大きいほど遷移が長くなる

    wrap.style.setProperty('--tp-dur', dur.toFixed(0) + 'ms');
    wrap.style.height = fromH.toFixed(2) + 'px';
    wrap.getBoundingClientRect(); // 強制リフロー — 開始の高さを確定してから目標の高さをかける

    tabs[active].classList.remove('is-active');
    tabs[active].setAttribute('aria-selected', 'false');
    tabs[active].setAttribute('tabindex', '-1');
    panels[active].setAttribute('hidden', '');

    tabs[idx].classList.add('is-active');
    tabs[idx].setAttribute('aria-selected', 'true');
    tabs[idx].setAttribute('tabindex', '0');
    panels[idx].removeAttribute('hidden');

    requestAnimationFrame(function () {
      wrap.style.height = toH.toFixed(2) + 'px';
    });

    diffEl.textContent = Math.round(diff);

02インデックス差の値が方向を決めるスライドパネル

idx - active の符号がスライドの向きを決め、絶対値を取れば何個のタブを飛ばしたかが分かります。1個を超えて飛ばすほど STEP_MS 分だけ移動時間が伸びるので、タブ1から4へのジャンプは隣同士のクリックより明らかに長く動きます。実際の移動は translateX() が担い、resize リスナーは viewport.clientWidth から出た移動距離のpx値を、画面幅が変わるたびに同じ式で計算し直します。

インデックス差の値が方向を決めるスライドパネル
新しいタブで開く

02 · インデックス差の値が方向を決めるスライドパネル — 計測 累積面積 6.432% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translateX()STEP_MS인덱스 부호
  function select(idx) {
    if (idx === active) return;
    var delta = idx - active; // 計算層: 新しいインデックス - 前のインデックス
    var dir = delta > 0 ? 1 : -1; // 計算層: 符号がスライド方向を決める
    var dist = Math.abs(delta); // 計算層: 飛ばしたタブの数
    var dur = Math.min(MAX_MS, BASE_MS + (dist - 1) * STEP_MS); // 計算層: 飛ばした数が多いほど移動が長くなる
    var w = viewport.clientWidth;

    track.style.setProperty('--sp-dur', dur.toFixed(0) + 'ms');
    track.style.transform = 'translateX(' + (-idx * w).toFixed(2) + 'px)';

    tabs[active].classList.remove('is-active');
    tabs[active].setAttribute('aria-selected', 'false');
    tabs[active].setAttribute('tabindex', '-1');
    panels[active].setAttribute('aria-hidden', 'true');

    tabs[idx].classList.add('is-active');
    tabs[idx].setAttribute('aria-selected', 'true');
    tabs[idx].setAttribute('tabindex', '0');
    panels[idx].setAttribute('aria-hidden', 'false');

    distEl.textContent = String(dist);
    dirEl.textContent = dir > 0 ? '→' : '←';
    active = idx;
  }

03クリック座標比率が拭い取るクリップパスパネル

クリックしたタブのx座標をタブバー全幅に対する比率(左端が0、右端が1)に変換し、その値を clip-path: inset() の起点スリットとして使います。だから新しいパネルは決まった端からではなく、実際にクリックした地点から左右へ拭われるように現れます。製品スペックタブや料金プラン比較パネルのように、クリック位置そのものに意味がある場面に向いています。

クリック座標の比率が拭い取るクリップパスパネル
新しいタブで開く

03 · クリック座標の比率が拭い取るクリップパスパネル — 計測 累積面積 7.335% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

clipPathinset()클릭 x 비율
    var barRect = tabsBar.getBoundingClientRect();
    var x = (typeof originX === 'number') ? originX : (tabs[idx].getBoundingClientRect().left + tabs[idx].getBoundingClientRect().width / 2);
    var r = barRect.width > 0 ? Math.min(1, Math.max(0, (x - barRect.left) / barRect.width)) : 0.5; // 計算層: クリックのx座標 → タブバー幅に対する比率(0〜1)
    var pct = (r * 100).toFixed(1) + '%';

    tabs[active].classList.remove('is-active');
    tabs[active].setAttribute('aria-selected', 'false');
    tabs[active].setAttribute('tabindex', '-1');
    panels[active].classList.remove('is-visible');
    panels[active].setAttribute('hidden', '');

    var next = panels[idx];
    next.style.transition = 'none';
    next.style.clipPath = 'inset(0 calc(100% - ' + pct + ') 0 ' + pct + ')'; // 計算層: その比率の地点で幅0のスリットから始める
    next.removeAttribute('hidden');
    next.getBoundingClientRect();
    next.style.transition = '';
    requestAnimationFrame(function () {
      next.style.clipPath = 'inset(0 0 0 0)'; // 次のフレームで全開示に切り替える → transitionが拭い取る形を描く
      next.classList.add('is-visible');
    });

    tabs[idx].classList.add('is-active');
    tabs[idx].setAttribute('aria-selected', 'true');
    tabs[idx].setAttribute('tabindex', '0');

04インデックス距離の値が奥へ倒すスタックパネル

すべてのパネルに、アクティブなタブとの整数インデックス距離が --d カスタムプロパティとして書き込まれます。アクティブから遠いパネルほど小さく・暗くレンダリングされ、z軸の奥へ押しやられるので、出ていくパネルは消えるのではなく重なったカードのように一枚ずつ奥へ倒れます。画像ギャラリーのキャプションタブやポートフォリオ分類のように、「次のカードへ」という感覚が欲しい場面向けです。

インデックス距離の値が奥へ倒すスタックパネル
新しいタブで開く

04 · インデックス距離の値が奥へ倒すスタックパネル — 計測 累積面積 37.975% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

--dSCALE_STEPtranslateZ()
  function apply() {
    panels.forEach(function (p, i) {
      var d = Math.abs(i - active); // 計算層: アクティブタブとの整数インデックス距離
      p.style.setProperty('--d', String(d));
      p.setAttribute('aria-hidden', d === 0 ? 'false' : 'true');
    });
    var scale = (1 - SCALE_STEP).toFixed(2); // 一番手前の層(距離1)のscale値を代表として表示する
    distEl.textContent = '1';
    scaleEl.textContent = scale;
  }

  function select(idx) {
    if (idx === active) return;
    tabs[active].classList.remove('is-active');
    tabs[active].setAttribute('aria-selected', 'false');
    tabs[active].setAttribute('tabindex', '-1');
    tabs[idx].classList.add('is-active');
    tabs[idx].setAttribute('aria-selected', 'true');
    tabs[idx].setAttribute('tabindex', '0');
    active = idx;
    apply();
  }

05進行度の値がぼかしては晴らすクロスフェードパネル

この記事で唯一、補間を丸ごとCSSに任せない2パターンのひとつです — requestAnimationFrame ループが経過時間を DUR_MS で割って進行度 t(0〜1)を求め、毎フレーム、出ていくパネルのぼかしと不透明度を t に、入ってくるパネルを (1 - t) に比例させて書き換えます。2枚のパネルはピント送りのように重なって流れ、ブログのカテゴリタブやビフォーアフター比較パネルに向いています。

進行度の値がぼかしては晴らすクロスフェードパネル
新しいタブで開く

05 · 進行度の値がぼかしては晴らすクロスフェードパネル — 計測 累積面積 8.026% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

filter blur()BLUR_MAXt·(1-t)
    inEl.removeAttribute('hidden');
    inEl.classList.add('is-front');
    setT(outEl, 0, false);
    setT(inEl, 0, true);

    var start = 0;
    function frame(ts) {
      if (!start) start = ts;
      var t = Math.min(1, (ts - start) / DUR_MS); // 計算層: 実測経過時間 / 総時間 = 進行度t(0〜1)
      setT(outEl, t, false);
      setT(inEl, t, true);
      tEl.textContent = t.toFixed(2);
      if (t < 1) {
        raf = requestAnimationFrame(frame);
      } else {
        raf = 0;
        outEl.setAttribute('hidden', '');
        outEl.classList.remove('is-front');
        active = idx;
      }
    }
    raf = requestAnimationFrame(frame);

06実測高さの値が畳んで開く縦アコーディオンパネル

選ばれていないパネルは max-height を0に畳んだままで、新しく選ばれたパネルだけが el.firstElementChild.scrollHeight から読んだ実測コンテンツ高さまで max-height を上げ、横へスライドする代わりにその場で縦に開きます。transitionend リスナー(onCollapseEnd)が畳み終わるのを待ってから要素を隠し、resize リスナーは画面サイズが変わるたびに同じ高さを測り直します — この高さは今のレイアウトに紐づいたpx値だからです。モバイルFAQタブや狭いサイドバーパネル向けの作りです。

実測高さの値が畳んで開く縦アコーディオンパネル
新しいタブで開く

06 · 実測高さの値が畳んで開く縦アコーディオンパネル — 計測 累積面積 32.296% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

scrollHeightmaxHeighttransitionend
  function measure(el) {
    return el.firstElementChild.scrollHeight; // 計算層: コンテンツの実測高さ(px) — max-heightの目標値
  }

  function openPanel(el, instant) {
    el.removeAttribute('hidden');
    var h = measure(el);
    if (instant) { el.style.transition = 'none'; }
    el.style.maxHeight = h + 'px';
    if (instant) { el.getBoundingClientRect(); el.style.transition = ''; }
    hEl.textContent = String(h);
  }

  function onCollapseEnd(e) {
    if (e.propertyName !== 'max-height') return;
    var el = e.currentTarget;
    if (el.style.maxHeight === '0px') el.setAttribute('hidden', '');
  }

  function closePanel(el, instant) {
    if (instant) { el.style.transition = 'none'; }
    el.style.maxHeight = '0px';
    if (instant) { el.getBoundingClientRect(); el.style.transition = ''; el.setAttribute('hidden', ''); }
  }

07進行度角度の値が裏返す3D回転パネル

見た目のめくりは transform: rotateY() 一発のCSSトランジションで、あとはブラウザに任せています。ただし並行して回る requestAnimationFrame ループが同じ経過時間の比率を読み、カードの横にある角度表示を0から180まで生きて動くように再描画するためだけに使われています。90度を超えた瞬間に出ていく面が backface-visibility で隠れ、入ってくる面が残りの角度を引き継ぎます。カード型統計タブや設定パネル切り替えのように、物理的に「めくれる」手応えが欲しい場面向けです。

進行度の角度値が裏返す3D回転パネル
新しいタブで開く

07 · 進行度の角度値が裏返す3D回転パネル — 計測 累積面積 36.107% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

rotateY()perspectivebackface-visibility
  function tickAngle(start, dir) {
    function step(ts) {
      var t = Math.min(1, (ts - start) / DUR_MS); // 計算層: 実測経過時間 / 総時間 = 進行度t(0〜1)
      var angle = Math.round(t * 180); // 計算層: 進行度を0〜180度のrotateY角度値に変換する
      angleEl.textContent = String(angle);
      if (t < 1 && busy) { raf = requestAnimationFrame(step); } else { raf = 0; }
    }
    raf = requestAnimationFrame(step);
  }

08クリック座標の値が広がる円形マスクパネル

クリックしたタブラベルの中心座標が、入ってくるパネルの clip-path: circle() の中心点になります。半径は0から Math.hypot(width, height) で求めたパネルの対角線長まで広がるので、決まった角からではなく実際にクリックした地点から円形に染み出すように現れます。ランディングページの機能タブやプロモバナーパネルのように、印象的な現れ方を狙う場面のために取ってあります。

クリック座標の値が広がる円形マスクパネル
新しいタブで開く

08 · クリック座標の値が広がる円形マスクパネル — 計測 累積面積 6.911% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

clipPathcircle()getBoundingClientRect()
    var cy = tabRect.top + tabRect.height / 2 - wrapRect.top;
    var r = Math.hypot(wrapRect.width, wrapRect.height); // 計算層: パネルの対角線の長さ = 半径の目標値

    var incoming = panels[idx];
    var outgoing = panels[active];

    incoming.style.transition = 'none';
    incoming.style.clipPath = 'circle(0px at ' + cx.toFixed(1) + 'px ' + cy.toFixed(1) + 'px)';
    incoming.removeAttribute('hidden');
    incoming.classList.add('is-front');
    incoming.getBoundingClientRect();
    incoming.style.transition = '';
    rEl.textContent = '0';

    tabs[active].classList.remove('is-active');
    tabs[active].setAttribute('aria-selected', 'false');
    tabs[active].setAttribute('tabindex', '-1');
    tabs[idx].classList.add('is-active');
    tabs[idx].setAttribute('aria-selected', 'true');
    tabs[idx].setAttribute('tabindex', '0');

    requestAnimationFrame(function () {
      incoming.style.clipPath = 'circle(' + r.toFixed(1) + 'px at ' + cx.toFixed(1) + 'px ' + cy.toFixed(1) + 'px)';
      rEl.textContent = String(Math.round(r));
    });

09バネ定数の値が弾んで超えては戻すスライドパネル

CSSトランジションを完全に使わないもうひとつのパターンです。requestAnimationFrame ループが毎フレーム force = err * STIFFNESS - vel * DAMPING を解き直し、それを速度と位置に積分して translateX() へ直接書き込みます。だからこのパネルは本当に目標を通り過ぎてから、その超過分だけ引き戻されて止まります — バネそのものの動きです。resize リスナーは画面幅が変わるたびに移動先の位置を計算し直し、ゲーム風ランディングタブやインタラクティブデモパネル向けの一枚です。

バネ定数の値が弾んで超えては戻すスライドパネル
新しいタブで開く

09 · バネ定数の値が弾んで超えては戻すスライドパネル — 計測 累積面積 8.585% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STIFFNESSDAMPINGrequestAnimationFrame()
  function frame(ts) {
    if (!lastTs) lastTs = ts;
    var dt = Math.min(0.032, (ts - lastTs) / 1000);
    lastTs = ts;
    var err = target - pos;
    if (!crossed && Math.sign(err) !== Math.sign(startErr) && startErr !== 0) crossed = true; // 計算層: 目標を超えた瞬間から超過分の追跡を開始する
    if (crossed) overshootPeak = Math.max(overshootPeak, Math.abs(pos - target));
    var force = err * STIFFNESS - vel * DAMPING; // 計算層: バネの力 = 変位×剛性 - 速度×減衰
    vel += force * dt;
    pos += vel * dt;
    track.style.transform = 'translateX(' + pos.toFixed(2) + 'px)';
    overEl.textContent = overshootPeak.toFixed(1);
    if (Math.abs(target - pos) < 0.4 && Math.abs(vel) < 2) {
      pos = target; vel = 0; lastTs = 0;
      track.style.transform = 'translateX(' + pos.toFixed(2) + 'px)';
      raf = 0;
      return;
    }
    raf = requestAnimationFrame(frame);
  }

値がパネルを切り替える場所 — 高さの差・インデックス距離・進行度

パターン 計算している値 再計算の場所 resizeで測り直す 動いたフレーム(23枚中)
01 実測高さの差(px) height のCSSトランジション — 9
02 インデックス距離による移動幅 transform のCSSトランジション あり 13
03 クリックx比率(0〜1) clip-path のCSSトランジション — 12
04 整数インデックス距離 --d 経由のCSSトランジション — 11
05 進行度 t(0〜1) requestAnimationFrame — 14
06 実測コンテンツ高さ(px) max-height のCSSトランジション あり 7
07 進行度角度(0〜180度) transform のCSSトランジション — 13
08 クリック座標+対角線半径 clip-path のCSSトランジション — 8
09 バネ位置 requestAnimationFrame あり 13

JSが一度書いてCSSトランジションに渡す場所 — 01・02・03・04・06・07・08

7つのパターンは値をひとつ計算し、一度だけスタイルかカスタムプロパティに書き込んで、あとはブラウザのトランジションエンジンに補間を任せています。上のコードの大半が測定と計算のセットアップに見えるのはそのためです。

毎フレーム値を計算し直す場所 — 05・09

05のぼかし・不透明度と09のバネ位置だけは、CSSトランジションに一切頼らず requestAnimationFrame のコールバックの中で毎フレーム手書きされています。09が本当に目標を通り過ぎられるのも、05がブラウザの決めたイージング曲線ではなく途中の中途半端なぼかし値を保持できるのも、この毎フレーム計算があるからです。

resizeが測り直しを強制する場所 — 02・06・09

resize リスナーを持つのは9つ中3つだけで、この3つには共通点があります — 計算している値がパーセントではなくpxの距離だということです。02の移動幅は -idx * viewport.clientWidth、06の展開高さは実測した scrollHeight、09のバネ目標もpxのオフセットで、どれも画面幅が変わると古くなるので、最初の切り替えと同じ式を resize のたびに解き直します。

どこで壊れるか — アクセシビリティ・reduced-motion・矢印キー操作

9つとも、それぞれのトランジションの下に同じARIAタブの配線を持っています。タブバーに role="tablist"、各ボタンに role="tab"、各パネルに role="tabpanel"、切り替わる2つの aria-selected の反転、そしてアクティブなタブをそのパネルへ結びつける aria-controls です。roving tabindexも同じで、上のコードでは active が変わるたびに tabindex が '0' と '-1' のあいだを行き来しているのが見えます——これが矢印キー(そしてHome・Endで先頭・末尾へ飛ぶ操作)だけでフォーカスを動かせる理由です。

prefers-reduced-motion が消すもの

9つとも reduced-motion を確認し、補間せずに終了状態へ直接進みます——高さモーフも、スライドも、拭い取りも、バネのオーバーシュートも起きません。もっとも明示的なのは09で、select() 関数はスプリングのループを始める前に reduce で分岐し、pos を target へ直接スナップさせて requestAnimationFrame そのものを走らせません。CSSトランジションの秒数をゼロ近くに縮めるだけの実装とは違います。

タブパネルのcss タブ コンポーネントが実際に壊れる場所

本当の落とし穴は遷移そのものではなく、パネルの高さや形が、すでに測り終えたあとで変わってしまう場合です。01と06はどちらも遷移が始まる前に一度だけ高さを測っています。もし遷移の途中で中身が伸びる(画像の読み込みが遅れて終わるなど)と、遷移は間違ったサイズで終わってしまいます。これはどのパターン固有の欠陥というより、パネルの中身を同期的に保つべき理由です。

以下のzipには9つの index.html と3つのバリエーションすべてが入っていて、パスワードは na4hmpde です。答えも一緒に映っているページからコピーするのではなく、画面を見ながら手入力してください——それでは入力させる意味がなくなってしまいます。

定数を上げ下げしても、アクセシビリティの配線は揺るがない

バリエーション 項目 定数 基準 → 変更後 変わること
01 高さモーフのタブコンテナ MS_PER_PX 1.1 → 1.8 高さの差が大きいほど遷移が長く伸び、パネルがよりゆっくり収まる方向に近づく
04 スタック深度のパネル SCALE_STEP 0.08 → 0.12 アクティブから遠いパネルほど一段ごとに小さくなり、奥行きが増す方向に近づく
09 弾性スライドのパネル DAMPING 20 → 32 オーバーシュートの幅が減り、目標をわずかに行き過ぎてから収まる方向に近づく

3つとも、ARIA属性・roving tabindex・reduced-motionの分岐には一切触れません。定数を上げ下げしても値のスケールが変わるだけで、キーボードフォーカスや prefers-reduced-motion が守られるかどうかは変わりません。

どのパネルに使うか、実際の画面で選ぶなら

パネルの高さが本当にバラバラな場面——設定画面・プロフィールカード・FAQブロック——なら、まず01です。実測した高さの差をそのまま時間に変換する唯一のパターンで、すべてのパネルが同じ高さだと仮定していません。モバイルのように縦幅そのものが制約になる場面では、06のアコーディオンが同じ実測の仕事を横へのスライドなしで1カラムのまま片付けてくれます。

ダッシュボードの順序付きサブタブや、番号付きのオンボーディングでは、進行を実際に伝えるcss タブ デザインは02です。スライドの向きと距離がどちらもインデックス差から来るので、ステップ1から4へのジャンプは1から2への一歩よりはっきり長く動きます。この方向性の手がかりは、関連のない商品カテゴリのように順序のないタブでは意味を持ちません。

ランディングページやプロモは、クリック起点の現れ方が生きる数少ない場所です。08の円は訪問者が実際にタップした地点から広がるので、ただのスライドより反応している感じが出ます。長い設定リストではなく、ヒーロー的な1組のタブセットのために取っておくのがちょうどいい使い方です。

css タブ 切り替え アニメーション の実例はどこで見られる?

上のパターンひとつひとつが実例そのものです。各見出しの下のコードは、そのパターンの index.html から簡略化せずそのまま切り出したもので、隣のデモボックスもHTML・CSS・JSのタブをその場で開けるので、ページを離れずに全文を読めます。css タブ 作り方 を探しているなら、このデモボックスがまさにその答えです — 動く結果と、その裏にある全部のコードが並んでいます。

切り替える前に浮かぶ、よくある疑問

パターン06と08は、なぜ23枚中こんなに少ないフレームしか動いていないのですか

どちらも常時動き続けるアニメーションではなく一回きりの切り替えなので、動きが存在するのは1回の遷移のあいだだけです——これは想定どおりで、不具合ではありません。06は23枚中7枚しか動いていませんが累積面積は32.3あり、短い間に画面の広い範囲を一気に変えていることを示しています。08は23枚中8枚で累積面積も6.9と小さいのは、円形の広がりがほぼ半径0から始まるためで、最初の数フレームは円がまだ小さく、ほとんどのピクセルに触れません。毎フレーム値を書き換える05の23枚中14枚と比べると、フレームごとの駆動方式による違いがよく分かります。

JavaScriptを一切使わない、css タブ デザイン おしゃれ な作りはありますか

いいえ——9つとも、実測した高さやインデックス距離、クリック比率、バネの力といった何かを、切り替えの前後でJavaScriptが計算することを前提にしています。CSSだけの :checked や :target の切り替えでも瞬時にパネルを変えることはできますが、実際に測った高さや実際のクリック座標を読み取ることはできません。それこそがここでの「計算の一層」の意味です。

9つの中に、本物のコンテンツを取得しているものはありますか

いいえ。パネルの中身はどれもHTMLに最初から入っている静的なテキストや短いリストで、fetch も画像の読み込みも外部ライブラリも使っていません。中身を自分のコンテンツに差し替えても、上の遷移ロジックはそのまま使えます。

Sources: MDN — Element.getBoundingClientRect() · MDN — clip-path · MDN — transform · MDN — Using the tab role · MDN — prefers-reduced-motion

Related reading: セグメントコントロールUI 9選 — コピペOK · ボトムナビゲーション 9選 — 状態値で変わる9つの転換パターン · css グリッチ エフェクト 9種の切り替え演出 — 1つ目はこの記事が扱う一段上、セレクター自体の位置を計算しており、2つ目はパターン02がスライド方向を計算するのと同じ考え方をボトムナビに適用しており、3つ目も毎フレーム計算した値で動く点は同じですが、向かう先が高さやクリップ形状やバネ位置ではなくグリッチのずれです。

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

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