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

アコーディオン テーブル 9選 — 行が開閉する9つの方式

このアコーディオン テーブル特集では、9つのうち1つは行の展開値が変わるとその下の詳細領域の高さ値が一緒に伸び、1つは行の存在値が消える瞬間その行の高さ値が0まで縮んで下の行の位置値が空いた場所を埋め、もう1つはスクロール位置値が基準を超えると見出しの影の値が濃くなって固定されます。

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

以下の9つのデモはどれもサーバーと通信せず、localStorage に何も保存せず、乱数も使いません。画面上の変化はすべて1つの状態値の切り替わりと、補間速度・stagger 間隔・ばねの剛性と減衰といった少数の定数だけで決まります。この制約のおかげで、どの値が変わったかさえ追えれば、9つのうちどのパターンを見ているのかが分かるようになっています。

9つのパターンの解剖 — どの値がいつ変わるか

9つのうち8つは毎フレーム動く requestAnimationFrame ループを使い、状態から導いた目標値へ現在値を少しずつ補間します(展開の高さ・選択強調のばね・挿入位置・削除の高さ・インライン編集のモーフ・ページ送りのスライド・空状態のフェード・固定見出しの影)。残る1つ(グループ折りたたみ)だけは普通の CSS transition に近い形で、apply() が height・opacity・transform の目標値と transitionDelay をイベントごとに1回だけ書き換え、実際の動きはその遅延時刻以降 style.scss の CSS transition が進めます。テーブルの行は横に細長い帯なので、この9つはどれも高さ・縦位置・影や色の値にだけ動きの予算を使い、回転したり1を超えて拡大したり紙吹雪を飛ばしたりはしません。

パターン別コード

01行の展開値切替 — 項目の展開値が変わると、その下の詳細領域の高さ値も一緒に伸び縮みする方式

行をクリックすると openIdx フラグが切り替わり、retarget() がその行の実際の scrollHeight を測り直すので、目標の高さは推測値ではありません。毎フレーム LERP の0.22で現在の高さをその目標へ引き寄せるため、パネルは瞬間的に開くのではなく、じわりと開きます。

行の展開値切替 — 項目の展開値が変わると、その下の詳細領域の高さ値も一緒に伸び縮みする方式
新しいタブで開く

01 · 行の展開値切替 — 項目の展開値が変わると、その下の詳細領域の高さ値も一緒に伸び縮みする方式 — 計測 累積面積 19.903% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

scrollHeightLERP 0.22.tre__detail
  // 定数: 詳細領域の進入上限(px、余裕を持たせ実測 scrollHeight で置き換える) · 補間速度 · 自動ループ間隔
  var MAX_H = 80;
  var LERP = 0.22;
  var AUTO_TOGGLE_MS = 1100;

  var openIdx = [false, false, false];
  var h = [0, 0, 0]; // 各詳細領域の現在(補間中)の高さ値
  var target = [0, 0, 0];

  // 計算層: 開閉値が変わると、目標の高さをその行の実測 scrollHeight で測り直す
  function retarget(i) {
    if (openIdx[i]) {
      details[i].style.height = 'auto';
      var natural = Math.min(MAX_H, details[i].scrollHeight);
      details[i].style.height = h[i].toFixed(1) + 'px';
      target[i] = natural;
    } else {
      target[i] = 0;
    }
  }

数字が語ること

01番は平均で画面の19.9%を占め、実測23フレーム中6フレームで動きます。高さの補間が遅く、ほとんどの時間は静止した値に落ち着いていることを示す数字です。

02グループ折りたたみ値切替 — グループ見出しの折りたたみ値が変わると、その下の複数行の表示値が一斉に変わる方式

子行はすべて同じ真偽値を共有していますが、apply() は i * STAGGER_MS で計算した異なる transitionDelay を各行に与えるので、1つの折りたたみ値が段差のあるカスケードへと広がり、全行が同時には動きません。

グループ折りたたみ値切替 — グループ見出しの折りたたみ値が変わると、その下の複数行の表示値が一斉に変わる方式
新しいタブで開く

02 · グループ折りたたみ値切替 — グループ見出しの折りたたみ値が変わると、その下の複数行の表示値が一斉に変わる方式 — 計測 累積面積 17.353% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STAGGER_MS 70transitionDelay.tgc__child
  // 定数: 子行の高さ(px) · 子行同士の stagger 間隔(ms) · 自動ループ間隔
  var CHILD_H = 30;
  var STAGGER_MS = 70;
  var AUTO_TOGGLE_MS = 1300;

  var collapsed = false;

  // 計算層: 折りたたみ値がオンになると、子行数 x stagger で各行の表示切替時刻が変わる
  function apply() {
    children.forEach(function (el, i) {
      var delay = i * STAGGER_MS;
      el.style.transitionDelay = delay + 'ms';
      if (collapsed) {
        el.style.height = '0px';
        el.style.opacity = '0';
        el.style.transform = 'translateY(-' + (CHILD_H * 0.3).toFixed(1) + 'px)';
      } else {
        el.style.height = CHILD_H + 'px';
        el.style.opacity = '1';
        el.style.transform = 'translateY(0px)';
      }
    });
    group.classList.toggle('tgc__group--collapsed', collapsed);
  }

数字が語ること

02番は累積面積17.4%・平均強度77.3・実測23フレーム中7フレーム動きます。01番より動く時間は短いものの、複数行が同時に動く分1フレームあたりの変化は強めです。

03選択値の強調切替 — 行の選択値がオンになるとその行の強調色値が濃くなり、オフになると元の値に戻る方式

tick() は色を単純にクロスフェードするのではなく、毎フレームばね方程式(acc = -K*(pos-target) - D*vel)を積分します。そのため強調色の背景は最終的な色をわずかに超えてから戻ってきます — これは普通の CSS transition では作れない動きです。

選択値の強調切替 — 行の選択値がオンになるとその行の強調色値が濃くなり、オフになると元の値に戻る方式
新しいタブで開く

03 · 選択値の強調切替 — 行の選択値がオンになるとその行の強調色値が濃くなり、オフになると元の値に戻る方式 — 計測 累積面積 21.536% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

K 0.26 / D 0.62vel.tsh__row
  // 計算層: 毎フレーム、目標値(0または1)へ向けてばね方程式を積分する(acc = -k*(pos-target) - d*vel)
  // これにより1をわずかに超えてから戻るオーバーシュートが生まれる — 単純なトゥイーンではない
  function tick() {
    for (var i = 0; i < 4; i++) {
      var target = selected[i] ? 1 : 0;
      var acc = -K * (pos[i] - target) - D * vel[i];
      vel[i] += acc * DT;
      pos[i] += vel[i] * DT;
      var clampedForColor = Math.max(0, Math.min(1, pos[i]));
      var over = Math.max(0, pos[i] - 1);
      var b = baseColor(), h = hiColor();
      var r = b[0] + (h[0] - b[0]) * clampedForColor;
      var g = b[1] + (h[1] - b[1]) * clampedForColor;
      var bl = b[2] + (h[2] - b[2]) * clampedForColor;
      rows[i].style.background = 'rgb(' + r.toFixed(0) + ',' + g.toFixed(0) + ',' + bl.toFixed(0) + ')';
      rows[i].style.boxShadow = over > 0.001 ? ('inset 0 0 0 ' + (over * 6).toFixed(1) + 'px rgba(255,255,255,' + Math.min(0.6, over).toFixed(2) + ')') : 'none';
    }
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);

数字が語ること

03番は9つの中で最も密度が高く、累積面積21.5%・平均強度117.9・23フレーム中8フレーム動きます。ばねのオーバーシュートは、クリックが終わった後も色と影を計算し続けます。

04行追加値と位置値の同時変化 — 新しい行が加わるとその行の位置値が場所に収まる間、下の行の位置値も一緒に押し出される方式

各行は自分の order インデックスしか知りません。tick() が各行の y を order * ROW_H へ補間するので、先頭に行を挿入するのは実際には1つの order 値が変わるだけの出来事であり、他の行の位置値もそれぞれの目標値が動いたことで追随します。

行追加値と位置値の同時変化 — 新しい行が加わるとその行の位置値が場所に収まる間、下の行の位置値も一緒に押し出される方式
新しいタブで開く

04 · 行追加値と位置値の同時変化 — 新しい行が加わるとその行の位置値が場所に収まる間、下の行の位置値も一緒に押し出される方式 — 計測 累積面積 11.482% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SLOTS 4order.tis__row
  function tick() {
    rows.forEach(function (r) {
      var target = r.order * ROW_H;
      r.y += (target - r.y) * LERP;
      var opacity = r.order >= SLOTS ? Math.max(0, 1 - (r.y - (SLOTS - 1) * ROW_H) / ROW_H) : 1;
      r.el.style.transform = 'translateY(' + r.y.toFixed(1) + 'px)';
      r.el.style.opacity = opacity.toFixed(2);
    });
    for (var i = rows.length - 1; i >= 0; i--) {
      var r = rows[i];
      if (r.order >= SLOTS && Math.abs(r.order * ROW_H - r.y) < 1) {
        r.el.remove();
        rows.splice(i, 1);
      }
    }
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);

数字が語ること

04番は23フレーム中10フレームで動き、9つの中で最も動く回数が多いパターンです。累積面積11.5%・平均強度103.8で、行の位置が挿入のたびに数フレームかけて落ち着き直すためです。

05行削除値と高さ値の同時変化 — 行の存在値が消えるとその行の高さ値が0まで縮み、下の行の位置値がその空いた場所を埋める方式

行を削除してもすぐには消えません。r.removing が true になり、r.h が H_LERP で0へ補間され、その高さ値が0.5を下回った時点で初めて要素が実際に DOM から取り除かれます。だからこそ下の行が飛び跳ねずに空いた場所へ滑り込むように見えます。

行削除値と高さ値の同時変化 — 行の存在値が消えるとその行の高さ値が0まで縮み、下の行の位置値がその空いた場所を埋める方式
新しいタブで開く

05 · 行削除値と高さ値の同時変化 — 行の存在値が消えるとその行の高さ値が0まで縮み、下の行の位置値がその空いた場所を埋める方式 — 計測 累積面積 5.46% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

POS_LERP 0.22H_LERP 0.26.tdc__inner
  function tick() {
    for (var i = rows.length - 1; i >= 0; i--) {
      var r = rows[i];
      var targetY = r.order * ROW_H;
      r.y += (targetY - r.y) * POS_LERP;
      var targetH = r.removing ? 0 : ROW_H;
      r.h += (targetH - r.h) * H_LERP;
      r.el.style.transform = 'translateY(' + r.y.toFixed(1) + 'px)';
      r.inner.style.height = Math.max(0, r.h).toFixed(1) + 'px';
      r.inner.style.opacity = (r.h / ROW_H).toFixed(2);
      if (r.removing && r.h < 0.5) {
        r.el.remove();
        rows.splice(i, 1);
      }
    }
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);

数字が語ること

05番は累積面積5.5%・平均強度75.5・23フレーム中6フレーム動きます。1行の高さだけが縮む処理なので、全行が位置を再計算する04番より軽い数字です。

06編集状態値の切替 — セルの編集状態値が変わると文言値の場所が入力欄値に切り替わる方式

セルをクリックすると c.editing が true になり、tick() が毎フレーム measure() で文言要素と入力欄要素それぞれの位置・サイズを getBoundingClientRect() で実測し、その2つの矩形を LERP(0.25) で補間したボックスを描きます。入力欄からフォーカスが外れる(blur)と c.editing が false に戻って同じ tick() が逆向きに補間し、入力欄内で Enter を押すと blur() が呼ばれて同じ経路をたどります。01番から05番と同じ rAF ループを共有しつつ、動く範囲が1セル分の矩形だけに閉じているのがこの06番の違いです。

編集状態値の切替 — セルの編集状態値が変わると文言値の場所が入力欄値に切り替わる方式
新しいタブで開く

06 · 編集状態値の切替 — セルの編集状態値が変わると文言値の場所が入力欄値に切り替わる方式 — 計測 累積面積 1.251% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getBoundingClientRectLERP 0.25.tie__box
  cells.forEach(function (c) {
    c.cell.addEventListener('click', function (e) {
      if (c.editing) return;
      root.classList.remove('is-demo');
      c.editing = true;
    });
    c.input.addEventListener('blur', function () { c.editing = false; });
    c.input.addEventListener('keydown', function (e) {
      if (e.key === 'Enter') c.input.blur();
    });
  });

数字が語ること

06番は累積面積わずか1.3%・平均強度68.3・23フレーム中8フレームです。tick() が動かすのは1セル分の文言・入力欄の矩形だけなので、rAF ループを使いながらも面積は9つの中で最小の数字になっています。

07ページ値切替と行位置値の移動 — ページ値が変わると前の行の位置値が抜け、新しい行の位置値が入ってくる方式

表示中の各行は targetX と delayUntil というタイムスタンプを持ちます。ページを変えると新しい目標位置が設定され、行ごとに delayUntil がずらされるので、tick() は自分の遅延時刻が来るまでその行を完全に無視します。これによりページ全体が1枚の板のように同時にスライドすることを防いでいます。

ページ値切替と行位置値の移動 — ページ値が変わると前の行の位置値が抜け、新しい行の位置値が入ってくる方式
新しいタブで開く

07 · ページ値切替と行位置値の移動 — ページ値が変わると前の行の位置値が抜け、新しい行の位置値が入ってくる方式 — 計測 累積面積 2.208% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STAGGER_MS 60delayUntil.tps__row
  function tick() {
    var now = performance.now();
    live.forEach(function (r) {
      if (now < r.delayUntil) return;
      r.x += (r.targetX - r.x) * LERP;
      r.el.style.transform = 'translateX(' + r.x.toFixed(1) + 'px)';
      r.el.style.opacity = String(Math.max(0, 1 - Math.abs(r.x) / W));
    });
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);

  renderInitial();
  viewport.style.height = (4 * ROW_H) + 'px';

  document.getElementById('tpsPrev').addEventListener('click', function () {
    viewport.classList.remove('is-demo');
    goTo(pageIndex - 1);
  });

数字が語ること

07番は累積面積2.2%・平均強度91.9・23フレーム中6フレームで、9つの中で稼働時間が最も短いパターンです。行ごとの遅延によって、自分の番が来るまでほとんどの行が静止しているためです。

08空リスト状態値の切替 — 表示する行が1つもない状態値がオンになると空状態文言の出現値が徐々に上がる方式

tick() は毎フレーム rows.length === 0 を調べ、emptyProg という1つの値を1または0へ補間します。空状態のメッセージはただ現れるのではなく、透明度とわずかな上方向のスライドの両方が、この1つの補間値をなぞっています。

空リスト状態値の切替 — 表示する行が1つもない状態値がオンになると空状態文言の出現値が徐々に上がる方式
新しいタブで開く

08 · 空リスト状態値の切替 — 表示する行が1つもない状態値がオンになると空状態文言の出現値が徐々に上がる方式 — 計測 累積面積 17.008% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

emptyProgLERP 0.2.tef__empty
  // 計算層: 行が1つもない状態値がオンになると、空メッセージの出現値(emptyProg)が
  // 毎フレーム残り距離の20%ずつ0から上がる — index.html の tick() を参照
  function tick() {
    for (var i = rows.length - 1; i >= 0; i--) {
      var r = rows[i];
      var targetH = r.removing ? 0 : ROW_H;
      r.h += (targetH - r.h) * LERP;
      r.el.style.height = Math.max(0, r.h).toFixed(1) + 'px';
      r.el.style.opacity = (r.h / ROW_H).toFixed(2);
      if (r.removing && r.h < 0.5) {
        r.el.remove();
        rows.splice(i, 1);
      }
    }
    var noRows = rows.length === 0;
    var target = noRows ? 1 : 0;
    emptyProg += (target - emptyProg) * LERP;
    empty.style.opacity = emptyProg.toFixed(2);
    empty.style.transform = 'translateY(' + ((1 - emptyProg) * 10).toFixed(1) + 'px)';
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);

数字が語ること

08番は累積面積17.0%・平均強度33.6・23フレーム中9フレームです。1行だけが跳ねるのではなく空状態のブロック全体がフェードするため、広いけれど穏やかな動きになっています。

09スクロール位置と影値の連動 — スクロール位置値が基準を超えると見出しが固定され、影の値が濃くなる方式

measure() は生のスクロール位置を0から1の ratio に変換しますが、pinTarget に反映されるのはその中の0.18から0.5の帯だけです。0.18未満では見出しに影は一切なく、0.5を超えると影値はすでに最大なので、実際に変化して見える区間はスクロール範囲そのものより狭くなっています。

スクロール位置値と影値の連動 — スクロール位置値が基準を超えると見出しが固定され、影の値が濃くなる方式
新しいタブで開く

09 · スクロール位置値と影値の連動 — スクロール位置値が基準を超えると見出しが固定され、影の値が濃くなる方式 — 計測 累積面積 22.126% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

scrollTop / maxScrollLERP 0.18.tss__head
  // 計算層: スクロール位置値(scrollTop / maxScroll)のうち0.18〜0.5の帯だけを取り出して
  // 見出し固定値(pin)を作り、その値で影のぼかし・下方向を毎フレーム残り距離の18%ずつ補間する
  function tick() {
    measure();
    var ratio = viewport.scrollTop / maxScroll;
    var pinTarget = ratio < 0.18 ? 0 : ratio > 0.5 ? 1 : (ratio - 0.18) / 0.32;
    shadow += (pinTarget - shadow) * LERP;
    var blur = (shadow * 14).toFixed(1);
    var drop = (shadow * 6).toFixed(1);
    var alpha = (shadow * 0.5).toFixed(2);
    head.style.boxShadow = '0 ' + drop + 'px ' + blur + 'px rgba(10, 15, 30, ' + alpha + ')';
    head.style.backgroundColor = 'rgba(35, 31, 40, ' + (0.94 + shadow * 0.06).toFixed(2) + ')';
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);

数字が語ること

09番はこの中で最大の累積面積22.1%を記録し、実測23フレームすべてで動きます。ふつうにスクロールしているだけで ratio が0.18〜0.5の帯の中で揺れ続けるため、見出しの影が完全に静止する瞬間がありません。

値がパターンを変える基準 — 展開の高さ・削除の持続時間・スクロールのしきい値

行の展開値切替の LERP を0.22から0.08へ下げると、01番の開く動きはクリック後もしばらく詳細領域が伸び続けるほど引き延ばされます。短い要約より長文の詳細を抱える行に向いた、重みのある落ち着いた印象になります。

グループ折りたたみ値切替の STAGGER_MS を70から220へ上げると、02番のカスケードは滝のような表情に近づきます。各子行が持つ transitionDelay の差が十分に開くため、5行のグループが一斉にではなく一段ずつ折りたたまれていきます。

選択値の強調切替の D(減衰)を0.62から0.18へ下げると、03番のばねはエネルギーを多く残したまま動くため、強調色は目標値を大きく超えてから2〜3回弾むように揺れます。滑らかな一発フェードではなく、跳ねる印象に変わります。

この3つの定数はどれも行の見た目を直接いじるものではなく、値の変化が畳み込まれる速さだけを変えます。同じ考え方で K や H_LERP の値を書き換えれば、ばねの弾み方や削除の縮み方も同じ土台の上で調整できます。この定数こそが、単に背景色をクロスフェードするだけの実装と、実際の物理を必要とする実装を分けるものです — ばねを省いても背景色の切り替えだけなら成立しますが、03番はクリックイベントが終わった後も数フレームにわたって pos と vel を計算し続けます。

この1本を離れて見ると、テーブル UI デザイン 9選 — 列幅ドラッグと範囲選択まで(257番)は同じテーブルという土俵で別の軸を扱っています — 行の位置は展開や削除の値ではなく並べ替え・幅変更・列の並び替えで動くので、そこでのクリックは高さではなく列の境界を動かします。ダッシュボード コンポーネント9選 — 画面ごとじゃなくこの部品だけ(226番)はウィジェットの軸を扱い、08番がここで空リスト状態を切り替えるのと同じ発想で、カードの読み込み・空・更新済みの状態を切り替えますが、対象は1行ではなくダッシュボード全体のパネルです。ドラッグアンドドロップ 並び替えリスト — 挿入先インデックス計算を重ねた9種(336番)は04番の挿入と位置シフトの計算に最も近い親戚ですが、位置値が変わる理由がポインタでドラッグされたからか、新しい行が0番目に挿入されたからかという点だけが異なり、order へ向けた補間の式そのものは同じです。3本とも行そのものの見た目ではなく、行を取り巻く値の計算方法そのものを変える方向の特集なので、同じテーブルの上でどの値が・いつ・どんな定数で変わるかを比べて読みたいときは、この362と並べて開くのが一番の近道になります。ピボットテーブル 行 展開 のような実装で order や target の値が実際にどう割り当てられているかを読むのは、zip を開いた後のもう一段小さな一歩で、その一歩を開けるのが r3p62vhd です。

どこで壊れるか

空リストに変わる瞬間

05番の自動ループは is-demo クラスが付いている間だけ行を補充します。人が実際に削除ボタンをクリックした瞬間にそのクラスは外れるので、手動ですべての行を消すとテーブルは永久に空のままになり、行を呼び戻すボタンも残りません。08番はまさにこの瞬間のために作られていて、クリックのたびに rows.length を確認し、0になった時点で refill() を呼びます。05番を閉じ込めるのと同じ「テーブルに何も残っていない」状態こそが、08番が存在する理由です。

狭い画面

9つのデモすべての SCSS に @media (max-width: 340px) の規則があり、どのパターンも動きが高さ・縦位置・影のいずれかに収まっているため、横方向の transform を使うものがなく、狭いスマホ幅で別の動きの経路を用意する必要がありません。同じ tick() や transition がそのまま動き続け、変わるのは行自体の幅と余白だけです。

モーション削減

9つすべてが CSS の prefers-reduced-motion: reduce の下で transition: none を設定する(またはアニメーション対象のプロパティを外す)作りになっています。03番だけはさらに一歩進んで JavaScript 側でも matchMedia('(prefers-reduced-motion: reduce)') を確認し、CSS transition を短くするのではなくばねの積分そのものを丸ごとスキップします。モーション削減で動く関数自体が変わるのは、この9つの中で03番だけです。

3つのバリエーションとアクセシビリティ

バリエーション 変わるもの 変わらないもの
行の展開値切替、ゆっくり開く(LERP 0.22 → 0.08) 01番の開く速度だけ retarget() の scrollHeight 測定、01番のマークアップ
グループ折りたたみ値切替、滝のような段差(STAGGER_MS 70 → 220) 02番の子行ごとの遅延間隔だけ apply() の高さ・透明度・transform の値、グループのマークアップ
選択値の強調、弾む強調(D 0.62 → 0.18) 03番のばねの減衰だけ K の剛性、baseColor()/hiColor()、行のマークアップ

9つ全体を見ると、ARIA 属性は本当に状態を知らせる必要がある04番・05番・07番にだけ付いていて、disabled や tabindex を使うものは1つもなく、keydown を聞いているのは06番の入力欄だけです。9つのどれもデータを取得せず、ストレージの読み書きも Math.random の呼び出しもしないので、これらの遷移を動かしている値はすべてマークアップとその上の定数にそのまま見えている状態そのものです。

FAQ

アコーディオン テーブル html は本物の CSS transition なのか、それとも JavaScript アニメーションなのか

このセットには両方あります。02番だけは apply() が高さ・不透明度・transform の目標値と子行ごとの transitionDelay を一度書き換えたあと、実際の動きを普通の CSS transition に任せる純粋な CSS 駆動です。残る8つ(01番の詳細領域の高さもここに含みます)はばねやスクロール比率で動くテーブル アコーディオン css の例も含め、毎フレームの requestAnimationFrame ループで値を計算します — 01番だけは行が開くときにセル背景がふわっと明るくなる装飾にもう1本、別の CSS transition を重ねています。1本の CSS transition だけではばね方程式を積分したり、スクロール比率を1つの帯だけに絞り込んだりできないためです。

html テーブル 開閉 の詳細領域が毎回違う高さまで開くのはなぜか

retarget() が定数ではなく各行の実際の scrollHeight を読んでいるためです。詳細テキストが3行ある行と1行しかない行では開く高さの値が異なり、アニメーションは常に推測値ではなく実測した目標値へ補間しています。

ピボットテーブル 行 展開 のようなばねのオーバーシュートに物理演算ライブラリは必要か

必要ありません。tick() は acc = -K*(pos-target) - D*vel を2つの定数と2つの累積変数だけで毎フレーム手計算しており、それがばね表現のすべてです。オーバーシュートを作るために物理やアニメーションのライブラリを呼び出している箇所はどこにもありません。

Sources: MDN — Window: requestAnimationFrame() method · MDN — prefers-reduced-motion · MDN — transition-delay

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

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