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

コメント欄 css 実装9パターン — 深さから削除後の詰めまで計算するリスト

コメント欄 css は、返信の深さ・行の高さ・経過時間を計算して動かすコメントリストの実装です。消えたコメントが占めていたピクセル値がそのまま下のコメントが上がる距離になり、返信のインデントは深さの段数から、固定コメントの光は固定されてからの経過時間からそれぞれ数値を受け取る。

九つのコメントリストをこの場で動かして確かめる

下のグリッドを開くと、9つのコメントリストはもう自分のペースで動いています。返信を開く・いいねを押す・並び替える・削除するといった操作も、それぞれの枠の中でそのまま試せます。

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

コメント欄 css 実装9パターン — 深さ・高さ・時間を計算で動かすコメントリスト

9つは役割が分かれています。01 は返信の深さからインデント幅を決め、02 はその返信の束を開閉し、03 は並び順を入れ替え、04 は一行の中の数字を転がします。05 は読み込み中の行を、06 は固定コメントの光を、07 は削除後の詰めを、08 は新着の区切り線を受け持ち、09 はそのうち三つを一枚のパネルに集めた完成場面です。

コメント 一覧 デザイン を決めるとき、見た目の色や角丸より先に迷うのは「深さ・高さ・時間のどれを数値にして動きへ渡すか」です。ここでは各項目が受け取る数値を一つに絞っているので、自分のページの文言と色に差し替えるだけで済みます。同じ「並びが変わる一覧」の考え方はランキング デザイン css 9パターン — 順位表 デザインも同じ仕組みでも扱っています。返信が一つずつ増えていく吹き出しの動きはチャット 吹き出し css アニメーション9選 — 一行でコピペOKが先に取り上げています。

パターン別コード

9項目それぞれ、深さ・時間・高さを計算する中心の部分だけを抜き出しています。残りのマークアップとスタイルは、下の zip の中にそのまま入っています。投稿者名と本文はすべてデモ用のダミーで、実際のサーバーとは通信しません。

01深さの段数から幅を計算する返信インデント

返信が何段下にあるかを data-depth から読み、REPLY_INDENT_STEP_PX を掛けた値を CSS 変数 --depth-indent に入れてインデント幅にします。展開のたびに縦のガイド線が上から下へ引かれ、親コメントとつながります。

深さの段数から幅を計算する返信インデント
新しいタブで開く

01 · 深さの段数から幅を計算する返信インデント — 計測 累積面積 5.21% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

REPLY_INDENT_STEP_PXcmt__guideopenGroup
  var REPLY_INDENT_STEP_PX = 24;

  var root = document.getElementById('cmtRoot');
  var list = document.getElementById('cmtList');
  var status = document.getElementById('cmtStatus');

  var items = list.querySelectorAll('.cmt__item[data-depth]');
  for (var i = 0; i < items.length; i += 1) {
    var depth = parseInt(items[i].getAttribute('data-depth'), 10) || 0;
    items[i].style.setProperty('--depth-indent', (depth * REPLY_INDENT_STEP_PX) + 'px');
  }

  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

項目01は累積面積5.2%・平均強度65.5、11/23フレームで動きが測定されました。

02実測した高さまでスプリングで伸びる返信の折りたたみ

setOpen() が measure() で返信群の実際の高さを測り、その値を目標にパネルの高さを動かします。高さが大きいほど伸びる時間も320〜680msの範囲で長くなり、動作を減らす設定のときは高さを直接置いて終わります。

実測した高さまでスプリングで伸びる返信の折りたたみ
新しいタブで開く

02 · 実測した高さまでスプリングで伸びる返信の折りたたみ — 計測 累積面積 74.878% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SPRING_SETTLE_EASINGch__panelsetOpen
  function setOpen(next, animate, delayMs) {
    open = next;
    toggle.setAttribute('aria-expanded', String(open));
    label.textContent = open ? '返信を隠す' : '返信3件を見る';
    status.textContent = open ? '返信3件が展開されました。' : '返信が折りたたまれました。';
    if (panelAnim) panelAnim.cancel();
    if (innerAnim) innerAnim.cancel();
    if (iconAnim) iconAnim.cancel();
    if (bgAnim) bgAnim.cancel();

    var target = measure();
    var from = open ? 0 : target;
    var to = open ? target : 0;
    panel.style.height = from + 'px';
    inner.style.opacity = open ? '0' : '1';

    if (!animate || reduce) {
      panel.style.height = to + 'px';
      inner.style.opacity = open ? '1' : '0';
      icon.style.transform = 'rotate(' + (open ? 180 : 0) + 'deg)';
      return;
    }
    // 計算: パネルの実測したピクセル高さが大きいほど、伸びるのにかかる時間も少し長く取る。
    var dur = Math.max(320, Math.min(680, 300 + target * 0.9));
    panelAnim = panel.animate(

項目02は累積面積74.9%・平均強度26.6・8/23フレームで、ルート背景が脈打つぶんも含めて測っているため面積が大きく出ています。

03以前の位置との距離を計算して滑る並び替え遷移

applySort() が並び替え前後の位置を getBoundingClientRect() で比べて縦の差 dy を求め、その距離から元の位置へ戻る向きにアニメーションさせます。途中で sideX ぶん横へずれるので、すれ違うコメント同士は重なりません。

以前の位置との距離を計算して滑る並び替え遷移
新しいタブで開く

03 · 以前の位置との距離を計算して滑る並び替え遷移 — 計測 累積面積 21.874% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SORT_FLIP_DURATION_MSsrt__itemapplySort
    if (!animate || reduce) return;

    for (var m = 0; m < order.length; m += 1) {
      var el = byId[order[m]];
      var after = el.getBoundingClientRect();
      var b = before[order[m]];
      var dy = b.top - after.top;
      if (Math.abs(dy) < 0.5) continue;
      var sideX = (m % 2 === 0 ? 1 : -1) * Math.min(14, Math.abs(dy) * 0.12);
      el.style.zIndex = String(10 - m);
      el.animate(
        [
          { transform: 'translate(0px,' + dy + 'px)' },
          { transform: 'translate(' + sideX + 'px,' + (dy * 0.4) + 'px)' },
          { transform: 'translate(0px,0px)' }
        ],
        { duration: SORT_FLIP_DURATION_MS, delay: delayMs || 0, easing: 'cubic-bezier(.2,.7,.2,1)', fill: 'both' }
      );
    }
  }

項目03は累積面積21.9%・平均強度82.1・7/23フレームで動きが測定されました。

04以前の値との差を繰り上げるいいねカウントポップ

rollDigit() が古い桁と新しい桁の差 diff の符号で上下どちらへ転がすかを決め、新しい桁の span を作って古い桁と同時に動かします。転がる時間は260msに差の絶対値×80ms(上限200ms)を足した値なので、差が大きいほど長くなります。

以前の値との差を繰り上げるいいねカウントポップ
新しいタブで開く

04 · 以前の値との差を繰り上げるいいねカウントポップ — 計測 累積面積 24.637% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

lk__digitTracklk__digitColrollDigit
  function rollDigit(col, newCh, diff, animate, delayMs) {
    var track = col.querySelector('.lk__digitTrack');
    var cur = track.querySelector('.lk__digit');
    if (cur.textContent === newCh) return;
    var next = document.createElement('span');
    next.className = 'lk__digit';
    next.textContent = newCh;
    next.style.transform = 'translateY(' + (diff > 0 ? '100%' : '-100%') + ')';
    track.appendChild(next);

    if (!animate || reduce) {
      cur.remove();
      next.style.transform = 'translateY(0)';
      return;
    }
    var dur = 260 + Math.min(200, Math.abs(diff) * 80);
    cur.animate(
      [{ transform: 'translateY(0)' }, { transform: 'translateY(' + (diff > 0 ? '-100%' : '100%') + ')' }],
      { duration: dur, delay: delayMs || 0, easing: 'cubic-bezier(.3,.7,.2,1)', fill: 'forwards' }
    );
    var anim = next.animate(
      [{ transform: 'translateY(' + (diff > 0 ? '100%' : '-100%') + ')' }, { transform: 'translateY(0)' }],
      { duration: dur, delay: delayMs || 0, easing: 'cubic-bezier(.3,.7,.2,1)', fill: 'forwards' }
    );

項目04は累積面積24.6%・平均強度38.4・8/23フレームで動きが測定されました。

05行の順序から遅延を計算して流れるスケルトンローディング

runSequence() が行を順に回り、SHIMMER_BASE_DELAY_MS + i * SHIMMER_STAGGER_STEP_MS を各行の遅延にして光の帯 .skel__glare を流します。光は1300msの無限ループで、動作を減らす設定では全行をすぐ本物のコメントに切り替えます。

行の順序から遅延を計算して流れるスケルトンローディング
新しいタブで開く

05 · 行の順序から遅延を計算して流れるスケルトンローディング — 計測 累積面積 12.333% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SHIMMER_STAGGER_STEP_MSskel__glarerowsInOrder
    anim.onfinish = function () { item.classList.add('is-real'); };
  }

  function runSequence(animate) {
    var rows = rowsInOrder();
    for (var i = 0; i < rows.length; i += 1) {
      resetRow(rows[i]);
      if (!animate || reduce) {
        rows[i].classList.add('is-real');
        continue;
      }
      // glare の無限スイープ — 行の順序で stagger する。
      var glareDelay = SHIMMER_BASE_DELAY_MS + i * SHIMMER_STAGGER_STEP_MS;
      rows[i].querySelectorAll('.skel__glare').forEach(function (glare) {
        glare.animate(
          [{ transform: 'translateX(-220%)' }, { transform: 'translateX(340%)' }],
          { duration: 1300, delay: glareDelay, iterations: Infinity, easing: 'linear' }
        );
      });

項目05は累積面積12.3%・平均強度36.6・14/23フレームで動きが測定されました。

06固定後の経過時間ぶん薄くなる固定コメント強調

decayAt() が経過時間から強調の明るさを返し、DECAY_DURATION_MS を過ぎると SETTLED_GLOW に固定されて変わらなくなります。時計は Date を使わず、requestAnimationFrame が渡す時刻に VIRTUAL_CLOCK_SCALE を掛けた仮想時計で進めます。

固定後の経過時間ぶん薄くなる固定コメント強調
新しいタブで開く

06 · 固定後の経過時間ぶん薄くなる固定コメント強調 — 計測 累積面積 19.131% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DECAY_DURATION_MSpin__decayFilldecayAt
  function decayAt(elapsedMs) {
    if (elapsedMs <= 0) return PEAK_GLOW;
    if (elapsedMs >= DECAY_DURATION_MS) return SETTLED_GLOW;
    var t = elapsedMs / DECAY_DURATION_MS;
    // 減衰曲線: 速く始まりゆっくり収束する(指数減衰を 0..1 の区間に正規化)。
    var eased = 1 - Math.exp(-4.2 * t) / (1 - Math.exp(-4.2));
    return PEAK_GLOW + (SETTLED_GLOW - PEAK_GLOW) * eased;
  }

  function applyDecay(glow, progress) {
    item.style.setProperty('--pin-glow', String(glow));
    fill.style.transform = 'scaleX(' + Math.max(0, progress) + ')';
  }

  var rafId = 0;
  var start = 0;

  function step(now) {
    if (!start) start = now;
    var elapsedVirtual = (now - start) * VIRTUAL_CLOCK_SCALE;
    var elapsed = elapsedVirtual - START_DELAY_MS;
    if (elapsed < 0) elapsed = 0;
    applyDecay(decayAt(elapsed), elapsed / DECAY_DURATION_MS);

項目06は累積面積19.1%・平均強度15.8・11/23フレームで動きが測定されました。

07消えた行の高さぶん下のコメントが上がる削除シフト

shiftDuration() が DELETE_SHIFT_SPRING_STIFFNESS と消えた高さ gapPx から詰める時間を決め、itemsAfter() が削除した行より下の見えている行だけを集めます。剛性が高いほど時間は短くなり、距離が大きくても延びるのは最大240msぶんだけです。

消えた行の高さぶん下のコメントが上がる削除シフト
新しいタブで開く

07 · 消えた行の高さぶん下のコメントが上がる削除シフト — 計測 累積面積 31.415% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DELETE_SHIFT_SPRING_STIFFNESSshiftDurationremoveRow
  function shiftDuration(gapPx) {
    // 剛性が高いほど早く止まる。移動距離が大きくても時間はわずかに延びるだけ。
    return Math.round(60 + 100000 / DELETE_SHIFT_SPRING_STIFFNESS + Math.min(240, gapPx * 0.55));
  }

  function itemsAfter(id) {
    var all = list.querySelectorAll('.del__item');
    var seen = false;
    var after = [];
    for (var i = 0; i < all.length; i += 1) {
      if (all[i].getAttribute('data-id') === id) { seen = true; continue; }
      if (seen && all[i].getBoundingClientRect().height > 0) after.push(all[i]);
    }
    return after;
  }

項目07は累積面積31.4%・平均強度94.3・11/23フレームで動きが測定されました。

08最終訪問時刻と比較して位置を計算する未読区切り線

findUnreadIndex() が各コメントの「何分前」という値を LAST_VISIT_MINUTES_AGO と比べて最初の新着の位置を返し、insertDivider() が createElement で作った区切り線をその手前へ挿入します。投稿時刻は仮の相対値で持ち、現在時刻の Date は読まないので、ラベルは textContent に入れるだけで済みます。

最終訪問時刻と比較して位置を計算する未読区切り線
新しいタブで開く

08 · 最終訪問時刻と比較して位置を計算する未読区切り線 — 計測 累積面積 19.872% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LAST_VISIT_MINUTES_AGOun__dividerfindUnreadIndex
  function findUnreadIndex() {
    var items = list.querySelectorAll('.un__item');
    for (var i = 0; i < items.length; i += 1) {
      if (minAgoOf(items[i]) < LAST_VISIT_MINUTES_AGO) return i;
    }
    return -1;
  }

  function insertDivider(animate) {
    var idx = findUnreadIndex();
    if (idx < 0) {
      status.textContent = '新着コメントはありません。';
      return;
    }
    var items = list.querySelectorAll('.un__item');
    var firstUnread = items[idx];

    var divider = document.createElement('li');
    divider.className = 'un__divider';
    divider.setAttribute('role', 'listitem');
    var dividerLabel = document.createElement('span');
    dividerLabel.className = 'un__dividerLabel';
    dividerLabel.textContent = 'ここから新着コメント';
    divider.appendChild(dividerLabel);
    list.insertBefore(divider, firstUnread);

項目08は累積面積19.9%・平均強度36.2・6/23フレームで動きが測定されました。

09複数の計算を一つのパネルに集めたコメント欄ヒーロー場面

専用の定数 HERO_REPLY_INDENT_STEP_PX・HERO_DELETE_SHIFT_SPRING_STIFFNESS・HERO_LIKE_POP_AMPLITUDE を持ち、applyIndents() が data-depth から marginLeft を計算します。01 の幅計算・04 のいいね・07 の削除を一つのパネルで同時に動かし、SPRING_SETTLE_EASING の曲線は最大1.126まで行き過ぎてから戻ります。

複数の計算を一つのパネルに集めたコメント欄ヒーロー場面
新しいタブで開く

09 · 複数の計算を一つのパネルに集めたコメント欄ヒーロー場面 — 計測 累積面積 25.116% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

HERO_REPLY_INDENT_STEP_PXHERO_DELETE_SHIFT_SPRING_STIFFNESShero__guide
  var HERO_REPLY_INDENT_STEP_PX = 20;
  var HERO_DELETE_SHIFT_SPRING_STIFFNESS = 220;
  var HERO_LIKE_POP_AMPLITUDE = 1.16;

  // $ease-lerp-settle 曲線(site/static/demo-tokens.scss)と同じサンプル — 1 を超えてから(最大 1.126)戻ってくる。
  var SPRING_SETTLE_EASING = 'linear(0.0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1)';

  var root = document.getElementById('heroRoot');
  var list = document.getElementById('heroList');
  var status = document.getElementById('heroStatus');

  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (reduce) root.classList.remove('is-demo');

  // ① インデントの計算
  function applyIndents() {
    var rows = list.querySelectorAll('.hero__cmt');
    for (var i = 0; i < rows.length; i += 1) {
      var depth = parseInt(rows[i].getAttribute('data-depth'), 10);
      rows[i].style.marginLeft = (depth * HERO_REPLY_INDENT_STEP_PX) + 'px';
    }
  }

項目09は累積面積25.1%・平均強度77.4・15/23フレームで動きが測定されました。

値がコメント欄の反応を変える場所 — 幅・遷移速度・詰め速度

同じ九つでも、インデント幅や並び替えの速さ、削除後の詰め方は項目ごとに違って見えます。その差は下の表の数値と、三つの定数が決めています。

項目 内容 累積面積 平均強度 動いたフレーム
01 返信インデント 5.2% 65.5 11/23 フレーム
02 返信の折りたたみ 74.9% 26.6 8/23 フレーム
03 並び替え遷移 21.9% 82.1 7/23 フレーム
04 いいねカウントポップ 24.6% 38.4 8/23 フレーム
05 スケルトンローディング 12.3% 36.6 14/23 フレーム
06 固定コメント強調 19.1% 15.8 11/23 フレーム
07 削除シフト 31.4% 94.3 11/23 フレーム
08 未読区切り線 19.9% 36.2 6/23 フレーム
09 ヒーローパネル 25.1% 77.4 15/23 フレーム

01 のように画面の大半が止まったままインデント幅一つだけを計算する項目は、動く部分が小さいので面積が低く出るのが普通です。02 はパネルが背景ごと広がるうえにルート背景も脈打つので、他より大きく測れます。REPLY_INDENT_STEP_PX は返信が一段深くなるごとにインデントが何px増えるか、SORT_FLIP_DURATION_MS は並び替えで各コメントが新しい位置へ移るまでの時間、DELETE_SHIFT_SPRING_STIFFNESS は削除した隙間を詰めるスプリングの硬さを決めます。コメント欄 素材 として画像を眺めるだけでは分からない違いが、この三つを変えると画面の反応としてすぐ見えます。

どこで壊れるか — 深い返信のオーバーフロー・並び替え中のちらつき・削除直後のフォーカス移動

01 は data-depth × REPLY_INDENT_STEP_PX で幅を決めますが、深さの上限がコードのどこにもないため、返信への返信が重なるほど幅は増え続けます。狭い画面では数段で本文の欄が横へ押し出されて切れて見えるので、実際のコメント欄に使うなら段数の上限か折り返しを足してください。

03 は並び替えボタンを続けて押し、前の applySort() のアニメーションが終わる前に新しい位置計算が重なると、前の動きの上に新しい動きが乗って位置が一瞬跳ねて見えます。applySort() の中には実行中のアニメーションを cancel() する処理がないので、実際のページに入れるなら測り直す前に止めるのが安全です。

07 は削除ボタンが消えたあとにフォーカスをどこへ置くかを決める処理が、9つのどのコードにもありません。キーボードで削除した直後はフォーカスの行き先が失われやすいので、次のコメントか一覧の見出しへ移す一行を足してください。

この九つを実際のコメント欄に入れるときは、こうした深さとタイミングの重なりまで一緒に考えると、見落としが減ります。深さと時間の計算が入った9つのソースを zip からダウンロードして開くときに入力するパスワードは m2uzmqnz です。

変形3つとアクセシビリティ

変形 値 感じ方
返信インデント幅を調整する 01 REPLY_INDENT_STEP_PX 24 → 12 値を下げると深い返信でも狭く畳まれて画面内に収まり、値を上げると数段階で横にはみ出して切れます。
並び替え遷移速度を調整する 03 SORT_FLIP_DURATION_MS 320 → 640 値を下げると並び替え時にコメントがほぼ瞬間移動のように位置を変え、値を上げると互いにすれ違うようにゆっくり位置を変えます。
削除後に詰まる弾性を調整する 07 DELETE_SHIFT_SPRING_STIFFNESS 220 → 120 値を上げると削除された隙間が速く硬く詰まり、値を下げると揺れながらゆっくり詰まります。

アクセシビリティは後から足す別の層ではありません。9つ全部が matchMedia でprefers-reduced-motionを確認し、動作を減らす設定のときは動きの代わりに最終状態を直接置きます(02 は高さを直接置き、05 は全行を本物のコメントに切り替え、09 はデモ用の演出クラスを外します)。キー入力を受ける処理は7項目にあり、06 と 08 にはありません。ただし先ほど書いたとおり、フォーカス移動のコードは9つのどこにもありません。

コメント欄 css はライブラリなしで並び替えと削除まで計算できるか?

できます。9項目とも外部のライブラリを読み込まず、06 は requestAnimationFrame、残りの8項目は Element.animate と位置・時間の計算だけで、インデント・開閉・並び替え・削除を動かしています。数値の出どころが深さ・高さ・経過時間のどれか一つに決まっているので、どこが動きを決めているかをコードの上でそのまま追えます。

コメント欄 css を入れる前によくある質問

コメント欄 フリー素材 のように、自分のページへそのままコピーして使えますか?

はい。各項目は index.html の中で完結しているので、1項目だけコピーして貼れます。投稿者名と本文はダミーなので、自分の文言に差し替えてから使ってください。

深い返信でインデント幅を狭くするにはどうしますか?

REPLY_INDENT_STEP_PX を24から12のあたりへ下げると、段数が増えても幅の伸びがゆるやかになり、画面の外へ押し出されにくくなります。変えるのはこの値一つだけで、01 の他の部分は触りません。

削除したあとのコメントの詰め方を変えられますか?

DELETE_SHIFT_SPRING_STIFFNESS を上げると速く硬く詰まり、下げると揺れながら遅く詰まります。値の効き方は上の変形表のとおりで、09 のヒーロー場面は専用の HERO_DELETE_SHIFT_SPRING_STIFFNESS を持つため、07 を変えても 09 には影響しません。

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

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