GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Notification Animation CSS: 9 Panel UI Patterns

Notification animation css: "How many notifications are left?" This panel answers with one unread-count value.

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine are ordered by what the badge value touches first: 01-03 live on the badge and the dot itself, 04-06 move whole rows once a person acts on them, and 07-09 swap the entire list when its count value crosses zero or its tab value changes.

A dashboard bell needs more than a red dot

Pick a product with an inbox-shaped screen -- a support queue, a project board, an admin panel -- and a bell icon sits in the header with a number on it. That number is the only thing a visitor trusts before they click: if it pulses when a new row arrives, holds still while they read, and drops to zero cleanly when the list empties, the panel feels alive without saying so. A notification bell animation css pattern that only swaps the digit reads as broken the moment two rows arrive at once, because nothing marks the change.

01Notification Panel That Opens After Its Badge Value Pulses

Every time the unread-count value grows, the badge pulses larger, then smaller, twice, and pressing the bell unfolds the panel below it from a zero height value.

Notification Panel That Opens After Its Badge Value Pulses
Open in a new tab

01 · Notification Panel That Opens After Its Badge Value Pulses — Measured area 34.257% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

NOTIF_BADGE_PULSE_MSmeasureHeightpulseBadge
  function pulseBadge() {
    badge.classList.remove("nb__badge--pulse");
    ring.classList.remove("nb__pulseRing--go");
    if (!reduce) {
      // calc layer: derive the ring spread ratio from the badge's measured diameter
      // (getBoundingClientRect) -- proportional to live badge size, not a fixed px, so it still spreads exactly past the badge edge on a 320px stage
      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");
    }
  }

Measured: cumulative area 34.3%, 10/23 frames moved.

02Header List That Collapses by a Date-group Value

Notifications are bucketed under a header by a date-group value, and pressing the header shrinks a height value in proportion to that group's item count, folding the child list away.

Header List That Collapses by a Date-group Value
Open in a new tab

02 · Header List That Collapses by a Date-group Value — Measured area 29.136% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

Measured: cumulative area 29.1%, 9/23 frames moved.

03Dot Indicator That Turns Into a Ring When a Read Value Flips

While an item's read value is off, it draws a solid dot, and the moment the value flips on, it redraws the same radius value as a hollow ring with only the outline left.

Dot Indicator That Turns Into a Ring When a Read Value Flips
Open in a new tab

03 · Dot Indicator That Turns Into a Ring When a Read Value Flips — Measured area 23.068% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

Measured: cumulative area 23.1%, 18/23 frames moved.

04Swipe Item That Dismisses Past a Drag-distance Value

Once the sideways drag-distance ratio clears its threshold, or the drag speed clears a separate threshold on its own, the item's opacity value and height value are both driven to zero together, removing it from the list.

Swipe Item That Dismisses Past a Drag-distance Value
Open in a new tab

04 · Swipe Item That Dismisses Past a Drag-distance Value — Measured area 38.515% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

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";
      }
    }

Measured: cumulative area 38.5%, 6/23 frames moved.

05Snooze Item That Steps Aside by a Push-distance Value

Pressing snooze moves just that item aside by a push-distance value, then subtracts one from the remaining-count value and redraws the timer label.

Snooze Item That Steps Aside by a Push-distance Value
Open in a new tab

05 · Snooze Item That Steps Aside by a Push-distance Value — Measured area 41.176% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

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 " + snoozed + " · " + DURATIONS[durIdx];
  }

Measured: cumulative area 41.2%, 11/23 frames moved.

06Mark-all-read Wave That Follows an Order-index Value

Pressing mark-all-read walks the list-order value from the top, adding a small delay value to each item so the dots turn into rings one after another instead of all at once.

Mark-all-read Wave That Follows an Order-index Value
Open in a new tab

06 · Mark-all-read Wave That Follows an Order-index Value — Measured area 14.643% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

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 = "Marked all as read";
  }

Measured: cumulative area 14.6%, 14/23 frames moved.

07Empty-state Message That Swaps In When a Remaining-count Value Hits Zero

The panel watches the remaining-count value, and the instant it reaches zero, the list's opacity value fades down while the empty-state message's opacity value fades up in its place.

Empty-state Message That Swaps In When a Remaining-count Value Hits Zero
Open in a new tab

07 · Empty-state Message That Swaps In When a Remaining-count Value Hits Zero — Measured area 6.199% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

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 = "No notifications yet";
  }

Measured: cumulative area 6.2%, 8/23 frames moved.

08Items That Stagger In When a Load-more Value Is Pressed

Pressing load-more fetches the next-page value, and each new item multiplies its order value by a short delay value, then rises in as its translate-up distance and opacity value animate together.

Items That Stagger In When a Load-more Value Is Pressed
Open in a new tab

08 · Items That Stagger In When a Load-more Value Is Pressed — Measured area 29.639% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

NOTIF_LOAD_STAGGER_MSloadMorefillStatic
    // force a reflow, then set final values next frame -- if start value 0 and end
    // value 1 land in the same tick the browser never paints the start frame and
    // jumps straight to the end value, so the transition stays invisible (measured
    // 2026-09-24: opacity already read 1 every frame). Forcing a reflow (reading
    // offsetHeight) locks the start state before two requestAnimationFrame calls land the final values.
    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)";
        });
      });
    });

Measured: cumulative area 29.6%, 10/23 frames moved.

09Filter List That Crossfades When a Tab-index Value Changes

When the tab-index value switches between all/mentions/system, the previous list's opacity value and offset value fade down together while the new filtered result's opacity value fades up, crossing over.

Filter List That Crossfades When a Tab-index Value Changes
Open in a new tab

09 · Filter List That Crossfades When a Tab-index Value Changes — Measured area 20.828% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

Measured: cumulative area 20.8%, 11/23 frames moved.

How the nine compare

Item Cumulative area Average intensity Frames moved
01 Notification Panel That Opens After Its Badge Value Pulses 34.3% 95.8 10/23
02 Header List That Collapses by a Date-group Value 29.1% 179.9 9/23
03 Dot Indicator That Turns Into a Ring When a Read Value Flips 23.1% 42.7 18/23
04 Swipe Item That Dismisses Past a Drag-distance Value 38.5% 105.1 6/23
05 Snooze Item That Steps Aside by a Push-distance Value 41.2% 178.2 11/23
06 Mark-all-read Wave That Follows an Order-index Value 14.6% 75.1 14/23
07 Empty-state Message That Swaps In When a Remaining-count Value Hits Zero 6.2% 71.1 8/23
08 Items That Stagger In When a Load-more Value Is Pressed 29.6% 73.4 10/23
09 Filter List That Crossfades When a Tab-index Value Changes 20.8% 64.0 11/23

Where a panel like this actually sits

None of these nine are a lone widget -- they sit behind one bell icon inside a header that also holds search, a profile menu, and whatever the product's main action is. A support queue uses 06 so an agent can clear forty tickets without forty separate re-renders; a project board leans on 02 so a busy sprint doesn't bury today's items under last week's; a billing panel needs 07 more than the rest, since an empty state that just appears reads as a loading bug rather than good news. Picking one item per panel, not all nine, keeps the header honest about how much just changed.

Accessibility: what reduced motion actually removes

Every item reads the same reduce flag from a media query and branches on it directly in its handler rather than only in CSS, because delay values (NOTIF_GROUP_COLLAPSE_DELAY_MS, NOTIF_MARK_ALL_STAGGER_MS, NOTIF_LOAD_STAGGER_MS) are JS timers, not transition-duration declarations a stylesheet override could catch alone. With reduced motion on, item 01's badge pulse and 03's spring both skip straight to their end state, item 02 and 06 apply every stagger delay as zero so rows change together instead of one after another, and 05's two nested timeouts collapse to zero so a snoozed row is gone on the same frame it slides. The unread-count value itself still updates in every case -- only the timing in between is removed.

A tab-count trap that took two rewrites to see

While building this notification icon animation css set, item 08 shipped once with the opacity value already at 1 on every measured frame -- a load-more stagger that never staggered, because the start value 0 and the end value 1 were both assigned inside the same tick and the browser skipped straight to the end. Item 09 hides a smaller version of the same trap: filtering by tab has to fade two full lists across each other, and if only one list layer exists, the count value briefly shows the wrong total mid-crossfade. Both traps only surfaced once the panel was measured frame by frame -- neither looked wrong in a quick glance at the running demo.

08 multiplies each new item's order value by a delay value so they rise in one after another, and 09 crossfades a previous filtered list against a new one when a tab-index value changes -- both items force a reflow before touching the transitioned values, which is the one line standing between a panel that looks alive and one that silently skips its own animation. The nine source files behind p3z5t2dn keep that reflow line exactly where the measured demo runs it, so pasting the zip in reproduces the same fix rather than the earlier broken pass.

Three variants, one value moved each time

Name Value Feel
Adjust the badge pulse-interval value 01 NOTIF_BADGE_PULSE_MS 900 → 1600 Shortening the value should pulse the badge in quick, tight beats that read as urgent; lengthening it should widen the interval so the badge breathes more slowly instead.
Adjust the group-header collapse-delay value 02 NOTIF_GROUP_COLLAPSE_DELAY_MS 80 → 240 Shortening the value should collapse the child items almost simultaneously right after the header is pressed; lengthening it should stagger each item's collapse, reading as a cascading fold.
Adjust the snooze slide-distance value 05 NOTIF_SNOOZE_SLIDE_PX 40 → 120 Shortening the value should nudge the snoozed item aside just enough to make room; lengthening it should push the item far past the edge, reading as fully cleared away.

Questions People Ask Before Copying These Nine

Is a notification animation css codepen embed the same thing as this zip?

An embed on a codepen-style page only shows the code; it never gives you the file tree with its scss partials or the demo's own measured values. The zip in this post unpacks into the same structure the nine demos above run from, so pasting it in keeps the same badge, panel, and list markup intact.

Why does item 07 fade the list out before showing the empty message?

Crossfading both directions at once -- list opacity down, message opacity up -- keeps one element on screen at every measured frame, so the panel never shows a blank gap between "has rows" and "is empty." A hard swap would read as a flicker instead of a state change.

Can I reuse the spring in item 03 for the badge pulse in item 01?

Item 03's tick() integrates a damped spring every frame to redraw a radius value, while item 01's pulseBadge() only toggles two classes and lets a CSS transition interpolate the scale value once. Swapping them means rewriting item 01 to hold its own animation loop instead of delegating to the stylesheet.

Read next

  • CSS Toast Notification -- a single transient message instead of a persistent panel, useful when the count value never needs to be visible.
  • Mega Menu Navigation UI -- another header element that has to open and close without shifting the layout around it, the same constraint item 01's panel works under.
  • Activity Feed Widget UI -- a longer-running list that shares item 08's stagger-in problem once new rows arrive at the top.

Source: MDN -- prefers-reduced-motion

Enter the archive password

The password is inside this article. You will find it as you read.