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

css 通知 9選 — バッジ・パネルアニメーション

css 通知で探す人に、バッジの値一つで答えるパネルを九種集めました。「未読はあといくつ?」と聞かれたら、このパネルは未読件数の値ひとつで答える。3番はその値が0になった瞬間にバッジを輪の形に戻し、6番は同じ値が空になるとリスト全体を空状態の文言に切り替え、9番はタブを切り替えるたびにその値を数え直してフィルター結果を

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

ダッシュボードのベルに必要なのは赤い丸だけではない

受付キューやプロジェクトボードのような受信箱型の画面を思い浮かべてください。ヘッダーのベルアイコンに数字が乗っているだけでは、新しい行が入った瞬間も、読み終わってゼロに戻った瞬間も伝わりません。css 通知バッジの値が変わるたびに何かが動く——それだけでパネル全体が「生きている」と感じられます。

九つは値が最初に触れる場所で並べています。01~03 はバッジとドット自体を、04~06 は人が操作した直後の行全体を、07~09 は件数値がゼロになるかタブ値が変わった瞬間のリスト全体を動かします。

01バッジの値が脈打った後に開く通知パネル

未読件数の値が増えるたびにバッジは大きく小さくのパルスを二回繰り返し、ベルを押すとその下でパネルが高さの値を0から広げながら下りてきます。

バッジの値が脈打った後に開く通知パネル
新しいタブで開く

01 · バッジの値が脈打った後に開く通知パネル — 計測 累積面積 34.257% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

NOTIF_BADGE_PULSE_MSmeasureHeightpulseBadge
  function pulseBadge() {
    badge.classList.remove("nb__badge--pulse");
    ring.classList.remove("nb__pulseRing--go");
    if (!reduce) {
      // 計算レイヤー: バッジの実測直径(getBoundingClientRect)からリングが広がる倍率を求める --
      // 固定pxではなくその瞬間のバッジサイズに比例するため、320pxのステージでもバッジの外側まで正確に広がる
      var bw = badge.getBoundingClientRect().width || 20;
      var targetD = bw * 2.6;
      ring.style.width = bw + "px";
      ring.style.height = bw + "px";
      ring.style.setProperty("--ring-scale", (targetD / bw).toFixed(2));
      // eslint-disable-next-line no-unused-expressions
      void badge.offsetWidth;
      void ring.offsetWidth;
      badge.classList.add("nb__badge--pulse");
      ring.classList.add("nb__pulseRing--go");
    }
  }

実測値:累積面積 34.3%、10/23 フレームが動きました。

02日付グループの値で折りたたまれるヘッダーリスト

通知を日付グループの値でまとめてヘッダーの下に置き、ヘッダーを押すとそのグループの項目数に応じて高さの値が縮み、子リストが折りたたまれます。

日付グループの値で折りたたまれるヘッダーリスト
新しいタブで開く

02 · 日付グループの値で折りたたまれるヘッダーリスト — 計測 累積面積 29.136% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

NOTIF_GROUP_COLLAPSE_DELAY_MSgroupElstoggleGroup
    items.forEach(function (li, i) {
      var order = expand ? (n - 1 - i) : i;
      var delay = reduce ? 0 : order * NOTIF_GROUP_COLLAPSE_DELAY_MS;
      var h = li.dataset.h ? parseFloat(li.dataset.h) : null;
      if (h === null) {
        li.style.height = "auto";
        h = li.getBoundingClientRect().height;
        li.dataset.h = String(h);
      }
      li.style.transitionDelay = delay + "ms";
      if (expand) {
        li.style.height = "0px";
        li.style.opacity = "0";
        requestAnimationFrame(function () {
          li.style.height = h + "px";
          li.style.opacity = "1";
        });
      } else {
        li.style.height = h + "px";
        li.style.opacity = "1";
        requestAnimationFrame(function () {
          li.style.height = "0px";
          li.style.opacity = "0";
        });
      }

実測値:累積面積 29.1%、9/23 フレームが動きました。

03既読の値がオンになると輪に変わるドット表示

各項目の既読値がオフの間は塗りつぶしたドットを描き、値がオンになった瞬間、同じ半径の値を輪郭だけ残した輪の形に描き直します。

既読の値がオンになると輪に変わるドット表示
新しいタブで開く

03 · 既読の値がオンになると輪に変わるドット表示 — 計測 累積面積 23.068% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SpringtickmarkRead
    function tick(ts) {
      if (last === null) last = ts;
      var dt = Math.min((ts - last) / 1000, 0.032);
      last = ts;
      var acc = (-stiffness * (pos - target) - damping * vel);
      vel += acc * dt;
      pos += vel * dt;
      apply(pos);
      if (Math.abs(pos - target) > 0.002 || Math.abs(vel) > 0.01) {
        raf = requestAnimationFrame(tick);
      } else {
        pos = target;
        apply(pos);
        raf = 0;
        last = null;
      }
    }

実測値:累積面積 23.1%、18/23 フレームが動きました。

04移動距離の値を超えると消えるスワイプ項目

指の横方向の移動距離の値がしきい値の比率を超えるか、指を離す速さの値がしきい値を超えると、その項目の不透明度の値と高さの値を同時に0まで減らしリストから消します。

移動距離の値を超えると消えるスワイプ項目
新しいタブで開く

04 · 移動距離の値を超えると消えるスワイプ項目 — 計測 累積面積 38.515% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SWIPE_DISMISS_RATIOSWIPE_VELOCITY_PX_MSendDrag
    function endDrag(e) {
      if (!dragging) return;
      dragging = false;
      row.style.transition = "";
      var dt = Math.max(1, e.timeStamp - startT);
      var velocity = Math.abs(dx) / dt;
      var ratio = Math.abs(dx) / width;
      if (ratio > SWIPE_DISMISS_RATIO || velocity > SWIPE_VELOCITY_PX_MS) {
        row.style.transform = "translateX(-100%)";
        reveal.style.opacity = "1";
        dismiss(li, name);
      } else {
        row.style.transform = "translateX(0px)";
        reveal.style.opacity = "0";
      }
    }

実測値:累積面積 38.5%、6/23 フレームが動きました。

05押し出し値の分だけ横に避けるスヌーズ項目

スヌーズボタンを押すとその項目だけが押し出し距離の値の分だけ横へ動き、残り件数の値から1を引いてタイマーのラベルを描き直します。

押し出し値の分だけ横に避けるスヌーズ項目
新しいタブで開く

05 · 押し出し値の分だけ横に避けるスヌーズ項目 — 計測 累積面積 41.176% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

NOTIF_SNOOZE_SLIDE_PXDURATIONSsnoozeOut
  function snoozeOut(li, row, label, name) {
    var durIdx = snoozed % DURATIONS.length;
    label.textContent = DURATIONS[durIdx];
    row.style.transform = "translateX(-" + NOTIF_SNOOZE_SLIDE_PX + "px)";
    var width = li.getBoundingClientRect().width;
    window.setTimeout(function () {
      row.style.transform = "translateX(-" + (NOTIF_SNOOZE_SLIDE_PX + width) + "px)";
      var h = li.getBoundingClientRect().height;
      li.style.height = h + "px";
      window.setTimeout(function () {
        li.style.height = "0px";
        li.style.marginBottom = "0px";
      }, reduce ? 0 : 40);
      window.setTimeout(function () {
        if (li.parentNode) li.parentNode.removeChild(li);
      }, reduce ? 0 : 320);
    }, reduce ? 0 : 150);
    snoozed++;
    tray.textContent = "スヌーズ " + snoozed + "件 · " + DURATIONS[durIdx];
  }

実測値:累積面積 41.2%、11/23 フレームが動きました。

06順番の値に沿って広がる全既読処理

全既読ボタンを押すとリスト順の値に沿って上から遅延の値を少しずつ加え、点が輪に変わる処理が一斉にではなく順番に広がっていきます。

順番の値に沿って広がる全既読処理
新しいタブで開く

06 · 順番の値に沿って広がる全既読処理 — 計測 累積面積 14.643% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

NOTIF_MARK_ALL_STAGGER_MSmarkAllReadupdateCount
  function markAllRead() {
    rows.forEach(function (r, i) {
      if (r.read) return;
      var delay = reduce ? 0 : i * NOTIF_MARK_ALL_STAGGER_MS;
      r.fill.style.transitionDelay = delay + "ms";
      r.ring.style.transitionDelay = delay + "ms";
      r.tint.style.transitionDelay = delay + "ms";
      r.li.style.transitionDelay = delay + "ms";
      r.read = true;
      r.fill.style.opacity = "0";
      r.ring.style.borderWidth = "2.4px";
      r.tint.style.opacity = "0";
      r.li.style.borderLeftWidth = "0px";
    });
    updateCount();
    sr.textContent = "すべて既読にしました";
  }

実測値:累積面積 14.6%、14/23 フレームが動きました。

07残り件数の値が0になると入れ替わる空状態の文言

残り通知件数の値を監視し続け、0になった瞬間にリストの不透明度の値を下げると同時に空状態文言の不透明度の値を上げて入れ替えます。

残り件数の値が0になると入れ替わる空状態の文言
新しいタブで開く

07 · 残り件数の値が0になると入れ替わる空状態の文言 — 計測 累積面積 6.199% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

showEmptyshowListfill
  function showEmpty() {
    empty.removeAttribute("hidden");
    requestAnimationFrame(function () {
      list.classList.add("es__list--out");
      empty.classList.add("es__empty--in");
    });
    window.setTimeout(function () {
      list.setAttribute("hidden", "");
    }, reduce ? 0 : 260);
    clearBtn.disabled = true;
    sr.textContent = "受け取った通知がありません";
  }

実測値:累積面積 6.2%、8/23 フレームが動きました。

08もっと見るの値を押すと時間差で入ってくる項目

もっと見るを押すと次ページの値を取得し、新しい項目ごとに順番の値に短い遅延の値を掛けて上方向の移動距離と不透明度の値を一緒に上げながら一つずつ差し込みます。

もっと見るの値を押すと時間差で入ってくる項目
新しいタブで開く

08 · もっと見るの値を押すと時間差で入ってくる項目 — 計測 累積面積 29.639% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

NOTIF_LOAD_STAGGER_MSloadMorefillStatic
    // 強制リフローの後、次のフレームで最終値をかける -- 同じティックで開始値0と
    // 終了値1を連続で与えると、ブラウザは開始フレームを一度も描画できずに
    // 終了値へ飛んでしまい、遷移が見えなくなる(実測2026-09-24: opacityが毎フレーム
    // すでに1と記録されていた)。強制リフロー(offsetHeightの読み取り)で開始状態を
    // 固定した後、requestAnimationFrameを二回使って最終値をかける。
    void list.offsetHeight;
    requestAnimationFrame(function () {
      requestAnimationFrame(function () {
        added.forEach(function (a) {
          var delay = reduce ? 0 : a.order * NOTIF_LOAD_STAGGER_MS;
          a.li.style.transitionDelay = delay + "ms";
          a.li.style.opacity = "1";
          a.li.style.transform = "translateY(0)";
        });
      });
    });

実測値:累積面積 29.6%、10/23 フレームが動きました。

09タブの値が変わると重なって切り替わるフィルターリスト

全て・メンション・システムのタブ値が変わると、前のリストの不透明度の値と位置の値を一緒に下げながら新しいフィルター結果の不透明度の値を上げ、重なるように切り替えます。

タブの値が変わると重なって切り替わるフィルターリスト
新しいタブで開く

09 · タブの値が変わると重なって切り替わるフィルターリスト — 計測 累積面積 20.828% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

selectTabfilterCountCATS
  function filterCount(cat) {
    return cat === "all" ? ITEMS.length : ITEMS.filter(function (it) { return it.cat === cat; }).length;
  }

  function fill(el, cat) {
    el.innerHTML = "";
    var rows = cat === "all" ? ITEMS : ITEMS.filter(function (it) { return it.cat === cat; });
    rows.forEach(function (it) { el.appendChild(makeRow(it.name)); });
    return rows.length;
  }

実測値:累積面積 20.8%、11/23 フレームが動きました。

九種の比較表

項目 累積面積 平均強度 動いたフレーム
01 バッジの値が脈打った後に開く通知パネル 34.3% 95.8 10/23
02 日付グループの値で折りたたまれるヘッダーリスト 29.1% 179.9 9/23
03 既読の値がオンになると輪に変わるドット表示 23.1% 42.7 18/23
04 移動距離の値を超えると消えるスワイプ項目 38.5% 105.1 6/23
05 押し出し値の分だけ横に避けるスヌーズ項目 41.2% 178.2 11/23
06 順番の値に沿って広がる全既読処理 14.6% 75.1 14/23
07 残り件数の値が0になると入れ替わる空状態の文言 6.2% 71.1 8/23
08 もっと見るの値を押すと時間差で入ってくる項目 29.6% 73.4 10/23
09 タブの値が変わると重なって切り替わるフィルターリスト 20.8% 64.0 11/23

このパネルが実際に役立つ場面

九つのどれも単体のウィジェットではなく、検索やプロフィールメニューと並んでベルアイコンの裏に並びます。受付キューは 06 で一括既読を使えば担当者が40件を一つずつ再描画せずに消し込めます。プロジェクトボードは 02 のグループ折りたたみで今日の項目が先週の下に埋まれないようにします。請求パネルは 07 の空状態が他より大切です——突然現れる空状態は読み込みバグに見えてしまうからです。通知 パネルを一つのベルに九つ全部ではなく一つだけ選ぶことが、今何が変わったかをヘッダーに正直に伝えます。

アクセシビリティ:動きを減らすと何が消えるか

九つの項目はすべて同じ reduce フラグをメディアクエリから読み、CSS だけではなく JS ハンドラ内で分岐します——NOTIF_GROUP_COLLAPSE_DELAY_MSや NOTIF_MARK_ALL_STAGGER_MS、NOTIF_LOAD_STAGGER_MS はタイマー値であり、スタイルシートの transition-duration だけでは捕まえられないからです。リドュースモーションがオンの間は、01 のバッジパルスと 03 のバネはどちらも終了値に直行し、02 と 06 は遅延値をすべて0として行を一括に変え、05 の二重タイマーはどちらも0になりスヌーズした行は同じフレームで消えます。未読件数値自体はどの場合も更新され続け、消えるのは遅延の間だけです。

二回の作り直しで見えた落とし穴

css 通知バッジの九つを作る途中、08 は一度、毎フレーム opacity 値がすでに1と記録される状態で公開したことがあります。次々入るはずの項目が一度も次々入らず、開始値0と終了値1が同じティックで与えられていたのが原因でした。09 にも同じ穴の小さな版が隠れています——タブの切り替えでは二つのリスト層を互い違いにクロスフェードさせる必要があり、層が一つしかないと件数値が一瞬間違った合計を見せてしまいます。

08は新しい項目ごとの順番値に遅延値を掛け合わせ、一つずつ差し込み、09はタブの値が変わると前のリストと新しいリストを重なって切り替えます——どちらも値を動かす前に強制リフローを入れている一行が、パネルが生きて見えるか黙って自分のアニメーションをスキップするかの境目になっています。九つのソース一式は p3z5t2dn の向こうにあり、この強制リフローの行もそのまま入っています。

値を一つだけ動かす三つのバリエーション

名前 値 見え方
バッジのパルス周期値を調整する 01 NOTIF_BADGE_PULSE_MS 900 → 1600 値を短くするとバッジは短い間隔で速く脈打ち新着が急いでいる印象を与え、値を伸ばすと間隔が広がりバッジはゆっくり呼吸するように見えるはずです。
グループヘッダーの折りたたみ遅延値を調整する 02 NOTIF_GROUP_COLLAPSE_DELAY_MS 80 → 240 値を短くするとヘッダーを押した直後に子項目がほぼ同時に折りたたまれ、値を伸ばすと項目ごとに時間差がついて段々に消えていくように見えるはずです。
スヌーズのスライド距離値を調整する 05 NOTIF_SNOOZE_SLIDE_PX 40 → 120 値を短くするとスヌーズした項目はわずかに押しのけられる程度に見え、値を伸ばすと項目は画面の外まで大きく押し出され完全に片付いた印象になるはずです。

コピペする前によくある質問に答えます

css 通知バッジのコピペ埋め込みは、この zip と同じものですか?

埋め込みはコードだけを見せますが、scss のパーシャルやデモ自体の実測値までは含みません。この記事の zip は上の九つのデモが動いているのと同じ構造に展開されるので、貼り付ければ同じバッジ・パネル・リストのマークアップがそのまま手に入ります。

07 はなぜ空状態メッセージを見せる前にリストをフェードアウトさせるのですか?

リストの不透明度値を下げながらメッセージの不透明度値を上げる両方向のクロスフェードにすれば、毎フレーム必ず何か一つが画面に残り、「件数あり」から「空」への間に隙間ができません。切り替えがチラつくと変化ではなく点滅です。

03 のバネを 01 のバッジパルスに使い回せますか?

03 の tick() は毎フレームバネを積分して半径値を描き直しますが、01 の pulseBadge() はクラスを二つ切り替えて CSS transition に拡大値の計算を一回任せるだけです。入れ替えるには 01 側に自前のアニメーションループを書き足す必要があります。

もっと読む

  • CSS Toast Notification -- 常駐パネルではなく一回限りの短いメッセージです。件数値を常に見せる必要がない場面に向きます。
  • Mega Menu Navigation UI -- レイアウトを崩さずに開閉する制約は、01 のパネルと同じです。
  • Activity Feed Widget UI -- 新しい行が上に入る長大なリストです。08 と同じスタガー問題を共有しています。

出典: MDN -- prefers-reduced-motion

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

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