32 CSS Timelines32 / 32
Live Activity Feed Timeline — Real-Time Events with :has() Filter
A team activity stream that behaves like a product, not a demo: new events arrive on top every few seconds with a height-then-fade entrance that never shoves the page, a 'Mentions only' toggle filters the feed with :has() and zero listener code, and the stream politely stops growing while your pointer is inside it — the courtesy every real feed (Slack, GitHub, Linear) implements and most tutorials skip.
Published Updated
The code
<section class="tls-32" aria-label="Live activity feed timeline demo">
<div class="tls-32__panel">
<header class="tls-32__head">
<p class="tls-32__title"><span class="tls-32__pulse" aria-hidden="true"></span>Team activity</p>
<div class="tls-32__ctl">
<span class="tls-32__queued" id="tls-32-queued" aria-live="polite"></span>
<input class="tls-32__box" type="checkbox" id="tls-32-mentions">
<label class="tls-32__toggle" for="tls-32-mentions">@ Mentions only</label>
</div>
</header>
<ol class="tls-32__list" id="tls-32-list" role="list">
<li class="tls-32__row"><div class="tls-32__item" data-kind="mention"><span class="tls-32__av" style="--a:#7c3aed">MK</span><p class="tls-32__txt"><b>Mara</b> mentioned you in <b>#payments</b>: “can @you sanity-check the retry logic?”</p><time class="tls-32__ts" datetime="2026-07-19T09:41">9:41</time></div></li>
<li class="tls-32__row"><div class="tls-32__item" data-kind="deploy"><span class="tls-32__av" style="--a:#0f766e">CI</span><p class="tls-32__txt"><b>Deploy</b> v2.14.1 reached <b>production</b> — 3 min, all checks green</p><time class="tls-32__ts" datetime="2026-07-19T09:38">9:38</time></div></li>
<li class="tls-32__row"><div class="tls-32__item" data-kind="comment"><span class="tls-32__av" style="--a:#b45309">DA</span><p class="tls-32__txt"><b>Dev</b> commented on <b>PR #482</b>: “subgrid fixes the alignment, nice”</p><time class="tls-32__ts" datetime="2026-07-19T09:31">9:31</time></div></li>
</ol>
</div>
</section><section class="tls-32" aria-label="Live activity feed timeline demo">
<div class="tls-32__panel">
<header class="tls-32__head">
<p class="tls-32__title"><span class="tls-32__pulse" aria-hidden="true"></span>Team activity</p>
<div class="tls-32__ctl">
<span class="tls-32__queued" id="tls-32-queued" aria-live="polite"></span>
<input class="tls-32__box" type="checkbox" id="tls-32-mentions">
<label class="tls-32__toggle" for="tls-32-mentions">@ Mentions only</label>
</div>
</header>
<ol class="tls-32__list" id="tls-32-list" role="list">
<li class="tls-32__row"><div class="tls-32__item" data-kind="mention"><span class="tls-32__av" style="--a:#7c3aed">MK</span><p class="tls-32__txt"><b>Mara</b> mentioned you in <b>#payments</b>: “can @you sanity-check the retry logic?”</p><time class="tls-32__ts" datetime="2026-07-19T09:41">9:41</time></div></li>
<li class="tls-32__row"><div class="tls-32__item" data-kind="deploy"><span class="tls-32__av" style="--a:#0f766e">CI</span><p class="tls-32__txt"><b>Deploy</b> v2.14.1 reached <b>production</b> — 3 min, all checks green</p><time class="tls-32__ts" datetime="2026-07-19T09:38">9:38</time></div></li>
<li class="tls-32__row"><div class="tls-32__item" data-kind="comment"><span class="tls-32__av" style="--a:#b45309">DA</span><p class="tls-32__txt"><b>Dev</b> commented on <b>PR #482</b>: “subgrid fixes the alignment, nice”</p><time class="tls-32__ts" datetime="2026-07-19T09:31">9:31</time></div></li>
</ol>
</div>
</section>.tls-32,
.tls-32 *,
.tls-32 *::before,
.tls-32 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.tls-32 {
min-height: 100vh;
width: 100%;
--tl-accent: #4f46e5;
--tl-line: #27272e;
--tl-bg: #131318;
--tl-card: #1b1b22;
--tl-ink: #e7e7ec;
--tl-mut: #8e8e9c;
font-family: 'Segoe UI',system-ui,sans-serif;
background: #0d0d11;
padding: 64px 24px;
display: grid;
place-items: center;
}
.tls-32__panel {
width: min(560px,100%);
background: var(--tl-bg);
border: 1px solid var(--tl-line);
border-radius: 16px;
overflow: hidden;
}
.tls-32__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--tl-line);
}
.tls-32__title {
display: flex;
align-items: center;
gap: 9px;
font-size: 14px;
font-weight: 750;
color: var(--tl-ink);
}
.tls-32__pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: #34d399;
box-shadow: 0 0 8px #34d39988;
}
@media (prefers-reduced-motion: no-preference) {
.tls-32__pulse {
animation: tls-32-pulse 1.8s ease-in-out infinite;
}
@keyframes tls-32-pulse {
50% {
opacity: .35;
}
}
}
.tls-32__ctl {
display: flex;
align-items: center;
gap: 10px;
}
.tls-32__queued {
font-size: 11px;
font-weight: 700;
color: #34d399;
}
.tls-32__box {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.tls-32__toggle {
font-size: 11.5px;
font-weight: 700;
color: var(--tl-mut);
border: 1px solid var(--tl-line);
border-radius: 999px;
padding: 5px 12px;
cursor: pointer;
user-select: none;
transition: color .15s,border-color .15s;
}
.tls-32__box:checked + .tls-32__toggle {
color: #c7c9ff;
border-color: var(--tl-accent);
background: rgb(79 70 229 / .15);
}
.tls-32__box:focus-visible + .tls-32__toggle {
outline: 2px solid var(--tl-accent);
outline-offset: 2px;
}
.tls-32:has(.tls-32__box:checked) .tls-32__item:not([data-kind="mention"]) {
display: none;
}
.tls-32__list {
list-style: none;
padding: 10px 14px 14px;
display: flex;
flex-direction: column;
max-height: 340px;
overflow-y: auto;
}
.tls-32__row {
display: grid;
grid-template-rows: 1fr;
}
.tls-32__row--new {
animation: tls-32-in .35s ease both;
}
.tls-32__row--new>.tls-32__item {
min-height: 0;
overflow: hidden;
}
@keyframes tls-32-in {
from {
grid-template-rows: 0fr;
opacity: 0;
}
to {
grid-template-rows: 1fr;
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.tls-32__row--new {
animation: none;
}
}
.tls-32__item {
display: flex;
align-items: center;
gap: 12px;
background: var(--tl-card);
border: 1px solid var(--tl-line);
border-radius: 10px;
padding: 11px 13px;
margin-top: 8px;
}
.tls-32__item[data-kind="mention"] {
border-color: rgb(79 70 229 / .5);
}
.tls-32__av {
flex: none;
width: 30px;
height: 30px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 10.5px;
font-weight: 800;
color: #fff;
background: var(--a,#52525b);
}
.tls-32__txt {
flex: 1;
font-size: 12.5px;
line-height: 1.5;
color: var(--tl-mut);
}
.tls-32__txt b {
color: var(--tl-ink);
font-weight: 650;
}
.tls-32__ts {
flex: none;
font-size: 10.5px;
color: #5c5c68;
font-variant-numeric: tabular-nums;
align-self: flex-start;
padding-top: 2px;
}.tls-32,
.tls-32 *,
.tls-32 *::before,
.tls-32 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.tls-32 {
min-height: 100vh;
width: 100%;
--tl-accent: #4f46e5;
--tl-line: #27272e;
--tl-bg: #131318;
--tl-card: #1b1b22;
--tl-ink: #e7e7ec;
--tl-mut: #8e8e9c;
font-family: 'Segoe UI',system-ui,sans-serif;
background: #0d0d11;
padding: 64px 24px;
display: grid;
place-items: center;
}
.tls-32__panel {
width: min(560px,100%);
background: var(--tl-bg);
border: 1px solid var(--tl-line);
border-radius: 16px;
overflow: hidden;
}
.tls-32__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--tl-line);
}
.tls-32__title {
display: flex;
align-items: center;
gap: 9px;
font-size: 14px;
font-weight: 750;
color: var(--tl-ink);
}
.tls-32__pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: #34d399;
box-shadow: 0 0 8px #34d39988;
}
@media (prefers-reduced-motion: no-preference) {
.tls-32__pulse {
animation: tls-32-pulse 1.8s ease-in-out infinite;
}
@keyframes tls-32-pulse {
50% {
opacity: .35;
}
}
}
.tls-32__ctl {
display: flex;
align-items: center;
gap: 10px;
}
.tls-32__queued {
font-size: 11px;
font-weight: 700;
color: #34d399;
}
.tls-32__box {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.tls-32__toggle {
font-size: 11.5px;
font-weight: 700;
color: var(--tl-mut);
border: 1px solid var(--tl-line);
border-radius: 999px;
padding: 5px 12px;
cursor: pointer;
user-select: none;
transition: color .15s,border-color .15s;
}
.tls-32__box:checked + .tls-32__toggle {
color: #c7c9ff;
border-color: var(--tl-accent);
background: rgb(79 70 229 / .15);
}
.tls-32__box:focus-visible + .tls-32__toggle {
outline: 2px solid var(--tl-accent);
outline-offset: 2px;
}
.tls-32:has(.tls-32__box:checked) .tls-32__item:not([data-kind="mention"]) {
display: none;
}
.tls-32__list {
list-style: none;
padding: 10px 14px 14px;
display: flex;
flex-direction: column;
max-height: 340px;
overflow-y: auto;
}
.tls-32__row {
display: grid;
grid-template-rows: 1fr;
}
.tls-32__row--new {
animation: tls-32-in .35s ease both;
}
.tls-32__row--new>.tls-32__item {
min-height: 0;
overflow: hidden;
}
@keyframes tls-32-in {
from {
grid-template-rows: 0fr;
opacity: 0;
}
to {
grid-template-rows: 1fr;
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.tls-32__row--new {
animation: none;
}
}
.tls-32__item {
display: flex;
align-items: center;
gap: 12px;
background: var(--tl-card);
border: 1px solid var(--tl-line);
border-radius: 10px;
padding: 11px 13px;
margin-top: 8px;
}
.tls-32__item[data-kind="mention"] {
border-color: rgb(79 70 229 / .5);
}
.tls-32__av {
flex: none;
width: 30px;
height: 30px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 10.5px;
font-weight: 800;
color: #fff;
background: var(--a,#52525b);
}
.tls-32__txt {
flex: 1;
font-size: 12.5px;
line-height: 1.5;
color: var(--tl-mut);
}
.tls-32__txt b {
color: var(--tl-ink);
font-weight: 650;
}
.tls-32__ts {
flex: none;
font-size: 10.5px;
color: #5c5c68;
font-variant-numeric: tabular-nums;
align-self: flex-start;
padding-top: 2px;
}// JS owns the DATA: it builds and prepends events. CSS owns entrance motion (grid-rows 0fr→1fr) and the mentions filter (:has(:checked) — no filter JS at all).
const list = document.getElementById('tls-32-list');
const queuedChip = document.getElementById('tls-32-queued');
const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;
const MAX_ITEMS = 12;
const pool = [
{ kind: 'deploy', av: 'CI', c: '#0f766e', txt: '<b>Deploy</b> preview for <b>PR #491</b> is live' },
{ kind: 'comment', av: 'JW', c: '#dc2626', txt: '<b>Jess</b> commented on <b>Roadmap Q4</b>: \u201cself-hosted first?\u201d' },
{ kind: 'mention', av: 'TO', c: '#059669', txt: '<b>Tom</b> mentioned you in <b>#design</b>: \u201c@you the tokens PR is ready\u201d' },
{ kind: 'join', av: 'AR', c: '#9333ea', txt: '<b>Ana</b> joined the <b>Platform</b> team' },
{ kind: 'deploy', av: 'CI', c: '#0f766e', txt: '<b>Nightly build</b> passed \u00b7 214 tests, 0 flaky' },
{ kind: 'mention', av: 'MK', c: '#7c3aed', txt: '<b>Mara</b> mentioned you in <b>#payments</b>: \u201cretries look good @you\u201d' },
];
let hovering = false, queue = [], i = 0;
function insert(ev) {
const t = new Date();
const hhmm = t.getHours() + ':' + String(t.getMinutes()).padStart(2, '0');
const li = document.createElement('li');
li.className = 'tls-32__row' + (REDUCED ? '' : ' tls-32__row--new');
li.innerHTML = '<div class="tls-32__item" data-kind="' + ev.kind + '">'
+ '<span class="tls-32__av" style="--a:' + ev.c + '">' + ev.av + '</span>'
+ '<p class="tls-32__txt">' + ev.txt + '</p>'
+ '<time class="tls-32__ts" datetime="' + t.toISOString() + '">' + hhmm + '</time></div>';
list.prepend(li);
// Trim oldest — only while not hovered, so rows never vanish mid-read.
while (!hovering && list.children.length > MAX_ITEMS) list.lastElementChild.remove();
}
function tick() {
const ev = pool[i++ % pool.length];
if (hovering) { queue.push(ev); queuedChip.textContent = queue.length + ' new'; }
else insert(ev);
}
list.addEventListener('pointerenter', () => { hovering = true; });
list.addEventListener('pointerleave', () => {
hovering = false;
queue.forEach(insert); queue = []; queuedChip.textContent = '';
});
setInterval(tick, 2800); // replace with your WebSocket handler calling insert(event)// JS owns the DATA: it builds and prepends events. CSS owns entrance motion (grid-rows 0fr→1fr) and the mentions filter (:has(:checked) — no filter JS at all).
const list = document.getElementById('tls-32-list');
const queuedChip = document.getElementById('tls-32-queued');
const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;
const MAX_ITEMS = 12;
const pool = [
{ kind: 'deploy', av: 'CI', c: '#0f766e', txt: '<b>Deploy</b> preview for <b>PR #491</b> is live' },
{ kind: 'comment', av: 'JW', c: '#dc2626', txt: '<b>Jess</b> commented on <b>Roadmap Q4</b>: \u201cself-hosted first?\u201d' },
{ kind: 'mention', av: 'TO', c: '#059669', txt: '<b>Tom</b> mentioned you in <b>#design</b>: \u201c@you the tokens PR is ready\u201d' },
{ kind: 'join', av: 'AR', c: '#9333ea', txt: '<b>Ana</b> joined the <b>Platform</b> team' },
{ kind: 'deploy', av: 'CI', c: '#0f766e', txt: '<b>Nightly build</b> passed \u00b7 214 tests, 0 flaky' },
{ kind: 'mention', av: 'MK', c: '#7c3aed', txt: '<b>Mara</b> mentioned you in <b>#payments</b>: \u201cretries look good @you\u201d' },
];
let hovering = false, queue = [], i = 0;
function insert(ev) {
const t = new Date();
const hhmm = t.getHours() + ':' + String(t.getMinutes()).padStart(2, '0');
const li = document.createElement('li');
li.className = 'tls-32__row' + (REDUCED ? '' : ' tls-32__row--new');
li.innerHTML = '<div class="tls-32__item" data-kind="' + ev.kind + '">'
+ '<span class="tls-32__av" style="--a:' + ev.c + '">' + ev.av + '</span>'
+ '<p class="tls-32__txt">' + ev.txt + '</p>'
+ '<time class="tls-32__ts" datetime="' + t.toISOString() + '">' + hhmm + '</time></div>';
list.prepend(li);
// Trim oldest — only while not hovered, so rows never vanish mid-read.
while (!hovering && list.children.length > MAX_ITEMS) list.lastElementChild.remove();
}
function tick() {
const ev = pool[i++ % pool.length];
if (hovering) { queue.push(ev); queuedChip.textContent = queue.length + ' new'; }
else insert(ev);
}
list.addEventListener('pointerenter', () => { hovering = true; });
list.addEventListener('pointerleave', () => {
hovering = false;
queue.forEach(insert); queue = []; queuedChip.textContent = '';
});
setInterval(tick, 2800); // replace with your WebSocket handler calling insert(event)Here's a working CSS Timeline from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Live Activity Feed Timeline — Real-Time Events with :has() Filter
Source: https://codefronts.com/layouts/css-timelines/ledger-audit-log/
A team activity stream that behaves like a product, not a demo: new events arrive on top every few seconds with a height-then-fade entrance that never shoves the page, a 'Mentions only' toggle filters the feed with :has() and zero listener code, and the stream politely stops growing while your pointer is inside it — the courtesy every real feed (Slack, GitHub, Linear) implements and most tutorials skip.
## HTML
```html
<section class="tls-32" aria-label="Live activity feed timeline demo">
<div class="tls-32__panel">
<header class="tls-32__head">
<p class="tls-32__title"><span class="tls-32__pulse" aria-hidden="true"></span>Team activity</p>
<div class="tls-32__ctl">
<span class="tls-32__queued" id="tls-32-queued" aria-live="polite"></span>
<input class="tls-32__box" type="checkbox" id="tls-32-mentions">
<label class="tls-32__toggle" for="tls-32-mentions">@ Mentions only</label>
</div>
</header>
<ol class="tls-32__list" id="tls-32-list" role="list">
<li class="tls-32__row"><div class="tls-32__item" data-kind="mention"><span class="tls-32__av" style="--a:#7c3aed">MK</span><p class="tls-32__txt"><b>Mara</b> mentioned you in <b>#payments</b>: “can @you sanity-check the retry logic?”</p><time class="tls-32__ts" datetime="2026-07-19T09:41">9:41</time></div></li>
<li class="tls-32__row"><div class="tls-32__item" data-kind="deploy"><span class="tls-32__av" style="--a:#0f766e">CI</span><p class="tls-32__txt"><b>Deploy</b> v2.14.1 reached <b>production</b> — 3 min, all checks green</p><time class="tls-32__ts" datetime="2026-07-19T09:38">9:38</time></div></li>
<li class="tls-32__row"><div class="tls-32__item" data-kind="comment"><span class="tls-32__av" style="--a:#b45309">DA</span><p class="tls-32__txt"><b>Dev</b> commented on <b>PR #482</b>: “subgrid fixes the alignment, nice”</p><time class="tls-32__ts" datetime="2026-07-19T09:31">9:31</time></div></li>
</ol>
</div>
</section>
```
## CSS
```css
.tls-32,
.tls-32 *,
.tls-32 *::before,
.tls-32 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.tls-32 {
min-height: 100vh;
width: 100%;
--tl-accent: #4f46e5;
--tl-line: #27272e;
--tl-bg: #131318;
--tl-card: #1b1b22;
--tl-ink: #e7e7ec;
--tl-mut: #8e8e9c;
font-family: 'Segoe UI',system-ui,sans-serif;
background: #0d0d11;
padding: 64px 24px;
display: grid;
place-items: center;
}
.tls-32__panel {
width: min(560px,100%);
background: var(--tl-bg);
border: 1px solid var(--tl-line);
border-radius: 16px;
overflow: hidden;
}
.tls-32__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--tl-line);
}
.tls-32__title {
display: flex;
align-items: center;
gap: 9px;
font-size: 14px;
font-weight: 750;
color: var(--tl-ink);
}
.tls-32__pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: #34d399;
box-shadow: 0 0 8px #34d39988;
}
@media (prefers-reduced-motion: no-preference) {
.tls-32__pulse {
animation: tls-32-pulse 1.8s ease-in-out infinite;
}
@keyframes tls-32-pulse {
50% {
opacity: .35;
}
}
}
.tls-32__ctl {
display: flex;
align-items: center;
gap: 10px;
}
.tls-32__queued {
font-size: 11px;
font-weight: 700;
color: #34d399;
}
.tls-32__box {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.tls-32__toggle {
font-size: 11.5px;
font-weight: 700;
color: var(--tl-mut);
border: 1px solid var(--tl-line);
border-radius: 999px;
padding: 5px 12px;
cursor: pointer;
user-select: none;
transition: color .15s,border-color .15s;
}
.tls-32__box:checked + .tls-32__toggle {
color: #c7c9ff;
border-color: var(--tl-accent);
background: rgb(79 70 229 / .15);
}
.tls-32__box:focus-visible + .tls-32__toggle {
outline: 2px solid var(--tl-accent);
outline-offset: 2px;
}
.tls-32:has(.tls-32__box:checked) .tls-32__item:not([data-kind="mention"]) {
display: none;
}
.tls-32__list {
list-style: none;
padding: 10px 14px 14px;
display: flex;
flex-direction: column;
max-height: 340px;
overflow-y: auto;
}
.tls-32__row {
display: grid;
grid-template-rows: 1fr;
}
.tls-32__row--new {
animation: tls-32-in .35s ease both;
}
.tls-32__row--new>.tls-32__item {
min-height: 0;
overflow: hidden;
}
@keyframes tls-32-in {
from {
grid-template-rows: 0fr;
opacity: 0;
}
to {
grid-template-rows: 1fr;
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.tls-32__row--new {
animation: none;
}
}
.tls-32__item {
display: flex;
align-items: center;
gap: 12px;
background: var(--tl-card);
border: 1px solid var(--tl-line);
border-radius: 10px;
padding: 11px 13px;
margin-top: 8px;
}
.tls-32__item[data-kind="mention"] {
border-color: rgb(79 70 229 / .5);
}
.tls-32__av {
flex: none;
width: 30px;
height: 30px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 10.5px;
font-weight: 800;
color: #fff;
background: var(--a,#52525b);
}
.tls-32__txt {
flex: 1;
font-size: 12.5px;
line-height: 1.5;
color: var(--tl-mut);
}
.tls-32__txt b {
color: var(--tl-ink);
font-weight: 650;
}
.tls-32__ts {
flex: none;
font-size: 10.5px;
color: #5c5c68;
font-variant-numeric: tabular-nums;
align-self: flex-start;
padding-top: 2px;
}
```
## JavaScript
```js
// JS owns the DATA: it builds and prepends events. CSS owns entrance motion (grid-rows 0fr→1fr) and the mentions filter (:has(:checked) — no filter JS at all).
const list = document.getElementById('tls-32-list');
const queuedChip = document.getElementById('tls-32-queued');
const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;
const MAX_ITEMS = 12;
const pool = [
{ kind: 'deploy', av: 'CI', c: '#0f766e', txt: '<b>Deploy</b> preview for <b>PR #491</b> is live' },
{ kind: 'comment', av: 'JW', c: '#dc2626', txt: '<b>Jess</b> commented on <b>Roadmap Q4</b>: \u201cself-hosted first?\u201d' },
{ kind: 'mention', av: 'TO', c: '#059669', txt: '<b>Tom</b> mentioned you in <b>#design</b>: \u201c@you the tokens PR is ready\u201d' },
{ kind: 'join', av: 'AR', c: '#9333ea', txt: '<b>Ana</b> joined the <b>Platform</b> team' },
{ kind: 'deploy', av: 'CI', c: '#0f766e', txt: '<b>Nightly build</b> passed \u00b7 214 tests, 0 flaky' },
{ kind: 'mention', av: 'MK', c: '#7c3aed', txt: '<b>Mara</b> mentioned you in <b>#payments</b>: \u201cretries look good @you\u201d' },
];
let hovering = false, queue = [], i = 0;
function insert(ev) {
const t = new Date();
const hhmm = t.getHours() + ':' + String(t.getMinutes()).padStart(2, '0');
const li = document.createElement('li');
li.className = 'tls-32__row' + (REDUCED ? '' : ' tls-32__row--new');
li.innerHTML = '<div class="tls-32__item" data-kind="' + ev.kind + '">'
+ '<span class="tls-32__av" style="--a:' + ev.c + '">' + ev.av + '</span>'
+ '<p class="tls-32__txt">' + ev.txt + '</p>'
+ '<time class="tls-32__ts" datetime="' + t.toISOString() + '">' + hhmm + '</time></div>';
list.prepend(li);
// Trim oldest — only while not hovered, so rows never vanish mid-read.
while (!hovering && list.children.length > MAX_ITEMS) list.lastElementChild.remove();
}
function tick() {
const ev = pool[i++ % pool.length];
if (hovering) { queue.push(ev); queuedChip.textContent = queue.length + ' new'; }
else insert(ev);
}
list.addEventListener('pointerenter', () => { hovering = true; });
list.addEventListener('pointerleave', () => {
hovering = false;
queue.forEach(insert); queue = []; queuedChip.textContent = '';
});
setInterval(tick, 2800); // replace with your WebSocket handler calling insert(event)
```Here's a working CSS Timeline from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Live Activity Feed Timeline — Real-Time Events with :has() Filter
Source: https://codefronts.com/layouts/css-timelines/ledger-audit-log/
A team activity stream that behaves like a product, not a demo: new events arrive on top every few seconds with a height-then-fade entrance that never shoves the page, a 'Mentions only' toggle filters the feed with :has() and zero listener code, and the stream politely stops growing while your pointer is inside it — the courtesy every real feed (Slack, GitHub, Linear) implements and most tutorials skip.
## HTML
```html
<section class="tls-32" aria-label="Live activity feed timeline demo">
<div class="tls-32__panel">
<header class="tls-32__head">
<p class="tls-32__title"><span class="tls-32__pulse" aria-hidden="true"></span>Team activity</p>
<div class="tls-32__ctl">
<span class="tls-32__queued" id="tls-32-queued" aria-live="polite"></span>
<input class="tls-32__box" type="checkbox" id="tls-32-mentions">
<label class="tls-32__toggle" for="tls-32-mentions">@ Mentions only</label>
</div>
</header>
<ol class="tls-32__list" id="tls-32-list" role="list">
<li class="tls-32__row"><div class="tls-32__item" data-kind="mention"><span class="tls-32__av" style="--a:#7c3aed">MK</span><p class="tls-32__txt"><b>Mara</b> mentioned you in <b>#payments</b>: “can @you sanity-check the retry logic?”</p><time class="tls-32__ts" datetime="2026-07-19T09:41">9:41</time></div></li>
<li class="tls-32__row"><div class="tls-32__item" data-kind="deploy"><span class="tls-32__av" style="--a:#0f766e">CI</span><p class="tls-32__txt"><b>Deploy</b> v2.14.1 reached <b>production</b> — 3 min, all checks green</p><time class="tls-32__ts" datetime="2026-07-19T09:38">9:38</time></div></li>
<li class="tls-32__row"><div class="tls-32__item" data-kind="comment"><span class="tls-32__av" style="--a:#b45309">DA</span><p class="tls-32__txt"><b>Dev</b> commented on <b>PR #482</b>: “subgrid fixes the alignment, nice”</p><time class="tls-32__ts" datetime="2026-07-19T09:31">9:31</time></div></li>
</ol>
</div>
</section>
```
## CSS
```css
.tls-32,
.tls-32 *,
.tls-32 *::before,
.tls-32 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.tls-32 {
min-height: 100vh;
width: 100%;
--tl-accent: #4f46e5;
--tl-line: #27272e;
--tl-bg: #131318;
--tl-card: #1b1b22;
--tl-ink: #e7e7ec;
--tl-mut: #8e8e9c;
font-family: 'Segoe UI',system-ui,sans-serif;
background: #0d0d11;
padding: 64px 24px;
display: grid;
place-items: center;
}
.tls-32__panel {
width: min(560px,100%);
background: var(--tl-bg);
border: 1px solid var(--tl-line);
border-radius: 16px;
overflow: hidden;
}
.tls-32__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--tl-line);
}
.tls-32__title {
display: flex;
align-items: center;
gap: 9px;
font-size: 14px;
font-weight: 750;
color: var(--tl-ink);
}
.tls-32__pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: #34d399;
box-shadow: 0 0 8px #34d39988;
}
@media (prefers-reduced-motion: no-preference) {
.tls-32__pulse {
animation: tls-32-pulse 1.8s ease-in-out infinite;
}
@keyframes tls-32-pulse {
50% {
opacity: .35;
}
}
}
.tls-32__ctl {
display: flex;
align-items: center;
gap: 10px;
}
.tls-32__queued {
font-size: 11px;
font-weight: 700;
color: #34d399;
}
.tls-32__box {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.tls-32__toggle {
font-size: 11.5px;
font-weight: 700;
color: var(--tl-mut);
border: 1px solid var(--tl-line);
border-radius: 999px;
padding: 5px 12px;
cursor: pointer;
user-select: none;
transition: color .15s,border-color .15s;
}
.tls-32__box:checked + .tls-32__toggle {
color: #c7c9ff;
border-color: var(--tl-accent);
background: rgb(79 70 229 / .15);
}
.tls-32__box:focus-visible + .tls-32__toggle {
outline: 2px solid var(--tl-accent);
outline-offset: 2px;
}
.tls-32:has(.tls-32__box:checked) .tls-32__item:not([data-kind="mention"]) {
display: none;
}
.tls-32__list {
list-style: none;
padding: 10px 14px 14px;
display: flex;
flex-direction: column;
max-height: 340px;
overflow-y: auto;
}
.tls-32__row {
display: grid;
grid-template-rows: 1fr;
}
.tls-32__row--new {
animation: tls-32-in .35s ease both;
}
.tls-32__row--new>.tls-32__item {
min-height: 0;
overflow: hidden;
}
@keyframes tls-32-in {
from {
grid-template-rows: 0fr;
opacity: 0;
}
to {
grid-template-rows: 1fr;
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.tls-32__row--new {
animation: none;
}
}
.tls-32__item {
display: flex;
align-items: center;
gap: 12px;
background: var(--tl-card);
border: 1px solid var(--tl-line);
border-radius: 10px;
padding: 11px 13px;
margin-top: 8px;
}
.tls-32__item[data-kind="mention"] {
border-color: rgb(79 70 229 / .5);
}
.tls-32__av {
flex: none;
width: 30px;
height: 30px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 10.5px;
font-weight: 800;
color: #fff;
background: var(--a,#52525b);
}
.tls-32__txt {
flex: 1;
font-size: 12.5px;
line-height: 1.5;
color: var(--tl-mut);
}
.tls-32__txt b {
color: var(--tl-ink);
font-weight: 650;
}
.tls-32__ts {
flex: none;
font-size: 10.5px;
color: #5c5c68;
font-variant-numeric: tabular-nums;
align-self: flex-start;
padding-top: 2px;
}
```
## JavaScript
```js
// JS owns the DATA: it builds and prepends events. CSS owns entrance motion (grid-rows 0fr→1fr) and the mentions filter (:has(:checked) — no filter JS at all).
const list = document.getElementById('tls-32-list');
const queuedChip = document.getElementById('tls-32-queued');
const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;
const MAX_ITEMS = 12;
const pool = [
{ kind: 'deploy', av: 'CI', c: '#0f766e', txt: '<b>Deploy</b> preview for <b>PR #491</b> is live' },
{ kind: 'comment', av: 'JW', c: '#dc2626', txt: '<b>Jess</b> commented on <b>Roadmap Q4</b>: \u201cself-hosted first?\u201d' },
{ kind: 'mention', av: 'TO', c: '#059669', txt: '<b>Tom</b> mentioned you in <b>#design</b>: \u201c@you the tokens PR is ready\u201d' },
{ kind: 'join', av: 'AR', c: '#9333ea', txt: '<b>Ana</b> joined the <b>Platform</b> team' },
{ kind: 'deploy', av: 'CI', c: '#0f766e', txt: '<b>Nightly build</b> passed \u00b7 214 tests, 0 flaky' },
{ kind: 'mention', av: 'MK', c: '#7c3aed', txt: '<b>Mara</b> mentioned you in <b>#payments</b>: \u201cretries look good @you\u201d' },
];
let hovering = false, queue = [], i = 0;
function insert(ev) {
const t = new Date();
const hhmm = t.getHours() + ':' + String(t.getMinutes()).padStart(2, '0');
const li = document.createElement('li');
li.className = 'tls-32__row' + (REDUCED ? '' : ' tls-32__row--new');
li.innerHTML = '<div class="tls-32__item" data-kind="' + ev.kind + '">'
+ '<span class="tls-32__av" style="--a:' + ev.c + '">' + ev.av + '</span>'
+ '<p class="tls-32__txt">' + ev.txt + '</p>'
+ '<time class="tls-32__ts" datetime="' + t.toISOString() + '">' + hhmm + '</time></div>';
list.prepend(li);
// Trim oldest — only while not hovered, so rows never vanish mid-read.
while (!hovering && list.children.length > MAX_ITEMS) list.lastElementChild.remove();
}
function tick() {
const ev = pool[i++ % pool.length];
if (hovering) { queue.push(ev); queuedChip.textContent = queue.length + ' new'; }
else insert(ev);
}
list.addEventListener('pointerenter', () => { hovering = true; });
list.addEventListener('pointerleave', () => {
hovering = false;
queue.forEach(insert); queue = []; queuedChip.textContent = '';
});
setInterval(tick, 2800); // replace with your WebSocket handler calling insert(event)
```How this works
The division of labor: JS owns data, CSS owns everything visual. A setInterval builds a new <li> from a small event pool and prepends it. The entrance is a CSS animation on the class the li is born with — grid-template-rows 0fr→1fr inside a wrapper row plus an opacity fade — so insertion never yanks the scroll position; the row makes room, then the content appears.
The filter is the modern trick: the toggle checkbox sits before the list, and .tls-32:has(#tls-32-mentions:checked) .tls-32__item:not([data-kind="mention"]) { display:none } hides non-mentions. The JS never touches filtering — add more filter checkboxes and write more :has() rules, not more listeners.
Hover courtesy: the interval callback checks a boolean set by pointerenter/leave and simply queues events instead of inserting while you're reading; on leave, the queue flushes. New-item count while paused shows in a chip via one line of JS. aria-live="polite" is deliberately NOT on the whole list (a feed announcing every event is unusable) — instead the queued-count chip is the live region, announcing '3 new updates' once.
Techniques used: JS-owns-data / CSS-owns-motion split · grid-rows 0fr→1fr insertion (no scroll shove) · :has(:checked) declarative feed filtering (zero filter JS) · pointer-aware pause with event queue + flush · aria-live on the count chip, not the stream · time[datetime] on every event · reduced-motion instant insertion
Make it yours
- Feed cadence: the 2800ms interval; production replaces the interval with your WebSocket handler calling the same insert(event) function.
- More filters: add a checkbox + a :has() rule per kind (deploys, comments, joins) — the JS is untouched.
- Retention: MAX_ITEMS (12 here) trims the tail so the DOM never grows unbounded.
- Entrance speed: the .35s on the wrapper row — keep under .5s; feeds should feel brisk.
Gotchas — read before shipping
- Never aria-live the whole feed — screen readers would announce every arrival and drown the user; announce the summary count instead.
- Prepend inside a grid-rows wrapper (0fr→1fr) — animating the li's own height triggers layout on every frame of every insertion.
- Pause-on-hover must QUEUE, not drop — users who read for 30 seconds still expect the missed events on mouse-out.
- Trim from the BOTTOM (oldest) and only when not hovered — removing rows under the pointer makes cards jump mid-read.
- matchMedia reduced-motion: insert instantly (no entrance class) — motion-sensitive users still get the data.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 117+ | 16+ | 121+ | 117+ |
Floor set by :has(), subgrid as this demo is written. The core technique itself goes back further — Chrome 105+.
:has() gates only the filter toggle; the feed itself (insertion, queueing, trimming) is baseline JS + CSS animation from 2020-era browsers.