GODRICH

9 Activity Feed UI Widgets — Sidebar-Ready Lists

An activity feed ui is a list that keeps stacking in a screen corner, not a toast that flashes and fades.

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

The order follows what a list goes through, not what a builder builds first. Team activity stacks in from the top (01), edits leave their before-and-after behind (02), read and unread split apart (03). Mentions roll in from outside (04), replies fold into a bundle (05), a document climbs from submit to approve (06). That is the stacking half. Next comes the picking half: log lines get picked by level (07), a brand-new feed wakes up at its first activity (08), and a summary counter folds the whole day into one number with hourly bars (09).

01Team activity stream where new work lands on top

Each entry is one line of who did what and when, with an avatar and a timestamp. The list itself carries role="log" and aria-live, and pressing Add activity grows a new row into place at the top while the rest slide down a row.

role="log"aria-livetranslateY
document.getElementById('ts-add').addEventListener('click', function () {
  ts.classList.remove('is-demo');
  var row = document.getElementById('ts-tpl').content.firstElementChild.cloneNode(true);
  row.classList.add('is-shut');
  tsList.insertBefore(row, tsList.firstChild);
  void row.offsetWidth; // two passes so the grid transition actually animates
  row.classList.remove('is-shut');
  tsCountUp();
});

02Change history diff that puts values back

The old value is struck through with del and the new one underlined with ins, so the before-and-after reads at a glance. Each row's Undo button snaps that one row back to its previous value and updates the count of remaining edits.

delinssteps(1, end)
b.addEventListener('click', function () {
  dh.classList.remove('is-demo');
  var row = b.closest('.dh__row');
  var back = row.classList.toggle('is-back');
  b.setAttribute('aria-pressed', back ? 'true' : 'false');
  dhLeft += back ? -1 : 1;
  dhCount();
});

03Notification list that splits read from unread

Unread rows keep a blue dot pulsing on a white card; opening one makes the dot vanish and the row cut to its read color in a single frame. Mark all read flips every remaining row, and the leftover count is announced out loud.

scalesteps(1, end)aria-live
function ncSync() {
  var left = ncRows.filter(function (r) {
    return !r.classList.contains('is-read');
  }).length;
  ncBadge.textContent = ncBadge.getAttribute('data-t')
    .replace('{n}', left);
}
document.getElementById('nc-all').addEventListener('click', function () {
  nc.classList.remove('is-demo');
  ncRows.forEach(function (r) { r.classList.add('is-read'); });
  ncSync();
});

04Social mention cards that roll in from the side

Every fresh mention rolls in from the right edge, tilts a touch, then settles upright. Mentions only arrive and never scatter away, and because the list is a role="log", each newcomer is read out in order.

translateXrotaterole="log"
document.getElementById('mn-more').addEventListener('click', function () {
  mn.classList.remove('is-demo');
  var row = document.getElementById('mn-tpl').content.firstElementChild.cloneNode(true);
  mnList.insertBefore(row, mnList.firstChild);
  row.addEventListener('animationend', function () {
    row.classList.remove('is-in');
  }, { once: true });
});

05Mini comment thread with folding replies

The reply bundle folds and unfolds as grid-template-rows travels between 0fr and 1fr, and the toggle carries aria-expanded. Pressing Reply lifts a new comment in from the bottom of the bundle.

grid-template-rowsaria-expandedtranslateY
function ctOpen(on) {
  ctRe.classList.toggle('is-open', on);
  ctTg.setAttribute('aria-expanded', on ? 'true' : 'false');
  ctTg.classList.toggle('is-open', on);
}
ctTg.addEventListener('click', function () {
  ct.classList.remove('is-demo');
  ctOpen(!ctRe.classList.contains('is-open'));
});

06Status badge timeline that lights up stage by stage

Submit, review, and approve stack vertically, and the connector fills up to whichever stage is current. Pressing Next stage hands aria-current to the new stage, and the phase chip rewrites itself to match.

scaleYsteps(1, end)aria-current
function stGo(i) {
  stAt = (i + stRows.length) % stRows.length;
  stRows.forEach(function (r, n) {
    r.classList.toggle('is-now', n === stAt);
    r.classList.toggle('is-done', n < stAt);
    if (n === stAt) { r.setAttribute('aria-current', 'step'); }
    else { r.removeAttribute('aria-current'); }
  });
  stPhase.setAttribute('data-at', String(stAt));
}

07System log filtered by level chips

Info, warning, and error chips remember their pressed state with aria-pressed, and only the pressed levels keep their rows on screen while the rest drop out through hidden. Turning every chip off means no filtering at all, so every line comes back.

aria-pressedhiddensteps(1, end)
function lgFilter() {
  var on = lgChips.filter(function (c) {
    return c.getAttribute('aria-pressed') === 'true';
  }).map(function (c) { return c.getAttribute('data-k'); });
  var showAll = on.length === 0 || on.length === lgChips.length;
  lgRows.forEach(function (r) {
    r.hidden = !(showAll || on.indexOf(r.getAttribute('data-k')) >= 0);
  });
}

08The moment an empty feed meets its first activity

A freshly opened service spends most of its life with an empty feed. The empty-state notice steps aside in one cut, the first activity rises from below, and the region is marked aria-live so screen readers hear what just arrived.

steps(1, end)translateYaria-live
function efSync(on) {
  ef.classList.toggle('has-act', on);
  efPhase.textContent = on
    ? efPhase.getAttribute('data-t1').replace('{n}', efCount)
    : efPhase.getAttribute('data-t2');
}
document.getElementById('ef-add').addEventListener('click', function () {
  ef.classList.remove('is-demo');
  var row = document.getElementById('ef-tpl').content.firstElementChild.cloneNode(true);
  efList.insertBefore(row, efList.firstChild);
  efCount++;
  efSync(true);
});

09Summary counter that tallies today in hourly bars

When today's count moves, the number is announced, and the hourly bars grow from their roots on an even grid. Pressing Recount actually raises the number and actually stretches the last bar.

aria-livescaleYtransform-origin
function tick(ts) {
  if (!t0) { t0 = ts; }
  var p = Math.min(1, (ts - t0) / 400);
  var v = Math.round(from + (to - from) * p);
  scN.textContent = String(v);
  if (p < 1) { requestAnimationFrame(tick); }
}
requestAnimationFrame(tick);
w.style.setProperty('--f', '.85'); // the bar itself actually grows

Where it breaks — the trap

The first thing that breaks is stacking two animations that move the same property onto one element. The unread dot in 03 needed both a vanishing cut and a pulse, both written against transform, and whichever animation came later in the list simply won, so the cut never happened at all. The cut moved to opacity, and the pulse kept transform. The bars in 09 hit the same wall with a grow animation and a fold animation side by side; the fold won forever, and the bars never grew, so both ended up merged into one animation that grows and folds inside a single cycle.

The second is splitting a sentence into fragments. Slicing an activity line into name, particle, and object looks tidy in Korean, but the moment the page ships in English or Japanese, the word order collapses. The fix was to keep every line as a name node plus one plain sentence node, and move emphasis from inline words to the row's background and its left bar. That is why the sentences survive a language swap.

The third is resetting a transform that was doing layout work. The stage dots in 06 sit on the rail with translate(-50%, -50%), and a motion-off block that wipes transforms wholesale flings every dot into the corner of its rail. Dots keep their positioning transform, and only the animation is switched off.

A related habit worth keeping: thin elements report small areas. The connector in 06 stayed at the smallest cumulative area of the nine, 1.62%, while the average difference inside its changed pixels reached 92.6. The diff rows in 02 sat at 2.81% area while holding an intensity of 72.3. A thin part speaks through intensity rather than area, so the two numbers are always read separately.

The last one is the narrow screen. A phone gives these widgets 174px of vertical room inside a 320px cell, which is not enough for every row, so the row-dropping widgets drop their oldest rows first (01, 03, 04, 05), the log drops its last line (07), and the remaining four shrink type and spacing only. The archive holding all nine vanilla and React versions asks for the password sub453jm, and everything shown in this article is inside it.

Accessibility

With reduced-motion turned on, all nine loops stop, and only the result states remain. The new row stays grown, read items keep their read color, and the approval timeline rests past review, so what got decided is still on screen when the motion is gone.

For feed widgets, accessibility comes down to how a newcomer gets announced. Stacking lists are wrapped in role="log" with aria-live="polite" (01, 04, 05), and the numbers and hints that refresh are read at the same time (03, 08, 09). Pressed states go through aria-pressed (02, 07), folding through aria-expanded (05), and the current stage through aria-current (06).

Item On screen With a screen reader Operated by
01 activity stream new row lands on top role="log" reads the row Add activity
02 change history struck value → underlined value undo via aria-pressed Undo
03 notification list dot pulse → read color leftover count in aria-live open item · mark all
04 social mentions card rolls in from the side mention read via role="log" Fetch mentions
05 comment thread reply bundle folds aria-expanded toggle · Reply
06 status timeline connector fills aria-current="step" Next stage
07 log filter chip press, rows drop chips via aria-pressed chip toggle
08 empty feed switch empty state → first activity first activity in aria-live New activity · Clear
09 summary counter bars grow in stagger count in aria-live Recount

When to reach for role="log" instead of a status role is laid out in the MDN log role document. Screens that actually use these widgets live under the notifications category and the dashboard category, and the parts driven by real JavaScript sit in the JS category.

FAQ

How do I split work between toasts and a notification list?

If losing the message in three seconds is fine, use a toast; if it is a record someone might scroll back to, use a list. Toasts erase themselves, while the lists in this article keep their rows on screen so past activity stays readable. The roles split the same way: a toast is a role="status", a stacking list is a role="log".

Won't a screen reader get chatty if items keep arriving?

That is why aria-live sits on the list itself and each update adds exactly one row. Only the fresh row is read, and rows already read once stay silent. When a count changes, the small hint announces just the number instead of repeating the whole list.

What happens when the feed grows to hundreds of rows?

Show less instead of more, in three moves. Old rows fold away (05), only the wanted level stays visible (07), and the day's volume compresses into bars (09). Put those three together in a sidebar and the day still reads without ever unfolding the long list.

Enter the archive password

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