Live feed
A live list. A new event unfolds at the top out of a blur and the rows beneath settle down on a spring; the oldest folds away at the bottom.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"use client";
// LiveFeed: a live list. A new event unfolds at the top out of a blur and the rows beneath settle down on a spring;
// the oldest folds away at the bottom. Urgent events tint the glyph. Relative times tick only while on screen, every
// second while anything is under a minute old, every 30s after that. With no `items` it plays a demo stream.
import { useEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { AlertTriangle, Banknote, CheckCircle2, HardDrive, MessageCircle, Pause, Play, ShoppingBag, UserPlus } from "lucide-react";
export type FeedKind = "order" | "signup" | "payment" | "build" | "storage" | "comment";
export type FeedItem = {
/** Unique, stable id. */
id: number;
/** Picks the glyph. */
kind: FeedKind;
title: string;
body: string;
/** Timestamp in ms (Date.now()). */
at: number;
/** Tints the glyph and time red. */
urgent?: boolean;
};
export type LiveFeedCue = "disable" | "enable";
export type LiveFeedProps = {
/** Newest first. Push new events onto the front from your socket or poller; the list animates them in. Leave out for a demo stream. */
items?: FeedItem[];
/** Rows shown (the card reserves exactly this many, so it never changes height). */
max?: number;
/** Header title. */
title?: string;
/** Controlled paused state. The pause toggle shows when this block runs its demo stream or `onPausedChange` is given. */
paused?: boolean;
/** Uncontrolled initial paused state. */
defaultPaused?: boolean;
/** Called when the toggle is pressed. Pausing your own stream is yours to honour upstream. */
onPausedChange?: (paused: boolean) => void;
/** Sound hook: "disable" on pause, "enable" on resume. */
onSound?: (cue: LiveFeedCue) => void;
className?: string;
style?: CSSProperties;
};
const ICON: Record<FeedKind, ReactNode> = {
order: <ShoppingBag />,
signup: <UserPlus />,
payment: <Banknote />,
build: <CheckCircle2 />,
storage: <HardDrive />,
comment: <MessageCircle />,
};
const ROW = 54;
const SNAP = { type: "spring", stiffness: 380, damping: 34, mass: 0.8 } as const;
const ago = (ms: number) => (ms < 3000 ? "now" : ms < 60000 ? `${Math.floor(ms / 1000)}s` : ms < 3600000 ? `${Math.floor(ms / 60000)}m` : `${Math.floor(ms / 3600000)}h`);
// the demo stream
const SEED: [FeedKind, string, string, boolean?][] = [
["order", "New order", "#4821 · $120"],
["signup", "Hugo signed up", "via invite link"],
["payment", "Payment received", "$1,240 from •••• 4821"],
["build", "Build passed", "main · 1m 12s"],
["storage", "Storage at 92%", "exports pause at the limit", true],
["comment", "Isla commented", "on Launch week"],
["order", "New order", "#4822 · $48"],
["build", "Build failed", "fix/slider · 38s", true],
];
const make = (k: number, at: number): FeedItem => {
const [kind, title, body, urgent] = SEED[k % SEED.length];
return { id: k, kind, title, body, urgent, at };
};
// true while the element is near the viewport, so timers and loops run only when seen
function useOnScreen(ref: RefObject<Element | null>) {
const [on, setOn] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (typeof IntersectionObserver === "undefined") return setOn(true);
const io = new IntersectionObserver(([e]) => setOn(e.isIntersecting), { rootMargin: "160px" });
io.observe(el);
return () => io.disconnect();
}, [ref]);
return on;
}
export function LiveFeed({ items: given, max = 4, title = "Activity", paused: ctrl, defaultPaused = false, onPausedChange, onSound, className, style }: LiveFeedProps) {
const demo = given === undefined;
const root = useRef<HTMLDivElement>(null);
const on = useOnScreen(root);
const [innerPaused, setInnerPaused] = useState(defaultPaused);
const paused = ctrl ?? innerPaused;
const live = !paused;
// demo stream: one event every 2.4s while on screen and not paused
const [fake, setFake] = useState<FeedItem[]>(() => [make(1, 0), make(0, 0)]);
const n = useRef(2);
// seed timestamps after mount so server and client agree
useEffect(() => setFake((l) => l.map((it, i) => ({ ...it, at: Date.now() - (i + 1) * 14000 }))), []);
useEffect(() => {
if (!demo || !on || paused) return;
const id = window.setInterval(() => {
const k = n.current++;
setFake((l) => [make(k, Date.now()), ...l].slice(0, 8));
}, 2400);
return () => clearInterval(id);
}, [demo, on, paused]);
const items = (given ?? fake).slice(0, max);
// `now` is null until mount, so server and client render the same markup (no time text yet)
const [now, setNow] = useState<number | null>(null);
const youngest = items.reduce((m, it) => Math.max(m, it.at), 0);
useEffect(() => {
if (!on) return;
setNow(Date.now());
const step = Date.now() - youngest < 60000 ? 1000 : 30000;
const id = window.setInterval(() => setNow(Date.now()), step);
return () => clearInterval(id);
}, [on, youngest]);
return (
<div ref={root} className={["tlf-root", className].filter(Boolean).join(" ")} style={style} data-live={live || undefined} data-off={!on || undefined}>
<div className="tlf-head">
<span className="tlf-dot" aria-hidden />
<span className="tlf-title">{title}</span>
{(demo || onPausedChange) && (
<button
type="button"
className="tlf-toggle"
aria-pressed={paused}
onClick={() => {
onSound?.(live ? "disable" : "enable");
if (ctrl === undefined) setInnerPaused(!paused);
onPausedChange?.(!paused);
}}
>
{live ? <Pause size={11} strokeWidth={2.4} /> : <Play size={11} strokeWidth={2.4} />}
{live ? "Pause" : "Resume"}
</button>
)}
</div>
<ul className="tlf-list" aria-live="polite" style={{ height: max * ROW }}>
<AnimatePresence initial={false} mode="popLayout">
{items.map((it) => (
<motion.li
key={it.id}
className="tlf-row"
data-kind={it.kind}
data-urgent={it.urgent || undefined}
data-fresh={(now !== null && now - it.at < 1800) || undefined}
layout
initial={{ height: 0, opacity: 0, y: -8, filter: "blur(6px)" }}
animate={{ height: ROW, opacity: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } }}
exit={{ height: 0, opacity: 0, filter: "blur(4px)", transition: { duration: 0.22, ease: [0.4, 0, 1, 1] } }}
transition={{ ...SNAP, opacity: { duration: 0.2, delay: 0.06 }, filter: { duration: 0.26, delay: 0.06 } }}
>
<span className="tlf-glyph" aria-hidden>
{it.urgent ? <AlertTriangle /> : ICON[it.kind]}
</span>
<span className="tlf-text">
<b>{it.title}</b>
<small>{it.body}</small>
</span>
<span className="tlf-time">{now === null ? "" : ago(Math.max(0, now - it.at))}</span>
</motion.li>
))}
</AnimatePresence>
</ul>
</div>
);
}
.tlf-root,
.tlf-root * {
box-sizing: border-box;
}
.tlf-root {
--tlf-ease: cubic-bezier(0.22, 1, 0.36, 1);
inline-size: 300px;
padding: 10px 10px 8px;
border-radius: var(--ts-r-panel);
font-family: var(--ts-font, system-ui, sans-serif);
color: var(--ts-fg);
background: var(--ts-raised);
box-shadow: var(--ts-edge);
}
/* header: status dot, title, pause toggle */
.tlf-head {
display: flex;
align-items: center;
column-gap: 9px;
block-size: 36px;
padding: 0 2px 4px 8px;
font-weight: 500;
font-size: 13px;
letter-spacing: -0.01em;
}
.tlf-title {
flex: 1 1 0;
}
.tlf-dot {
position: relative;
inline-size: 7px;
block-size: 7px;
border-radius: 999px;
background: var(--ts-fg-faint);
transition: background 0.22s var(--tlf-ease);
}
.tlf-root[data-live] .tlf-dot {
background: var(--ts-success);
}
.tlf-root[data-live] .tlf-dot::after {
content: "";
position: absolute;
inset-block: 0;
inset-inline: 0;
border-radius: inherit;
background: var(--ts-success);
animation: tlf-breathe 2s var(--tlf-ease) infinite;
}
@keyframes tlf-breathe {
from {
scale: 1;
opacity: 0.55;
}
70%,
to {
scale: 2.8;
opacity: 0;
}
}
.tlf-toggle {
display: inline-flex;
align-items: center;
column-gap: 5px;
block-size: 28px;
padding-inline: 9px 11px;
padding-block: 0;
border: 0;
border-radius: 999px;
cursor: pointer;
font: 500 12px var(--ts-font, system-ui, sans-serif);
color: color-mix(in srgb, var(--ts-fg) 70%, var(--ts-raised));
background: rgba(var(--ts-fg-rgb), 0.06);
-webkit-tap-highlight-color: transparent;
transition: background 0.16s var(--tlf-ease), color 0.16s var(--tlf-ease), scale 0.22s var(--tlf-ease);
}
.tlf-toggle:hover {
color: var(--ts-fg);
background: rgba(var(--ts-fg-rgb), 0.1);
}
.tlf-toggle:active {
scale: 0.94;
}
.tlf-toggle:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px var(--ts-raised), 0 0 0 4px var(--ts-accent);
}
/* the list reserves its full height, so the card never grows and the grid never reflows */
.tlf-list {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
margin: 0;
padding: 0;
list-style: none;
}
.tlf-row {
position: relative;
display: grid;
grid-template-columns: 30px minmax(0, 1fr) auto;
align-items: center;
column-gap: 12px;
padding-inline: 8px;
}
.tlf-row + .tlf-row::before {
content: "";
position: absolute;
inset: 0 8px auto 50px;
block-size: 1px;
background: var(--ts-border);
}
/* glyph */
.tlf-glyph {
position: relative;
display: grid;
place-items: center;
inline-size: 30px;
block-size: 30px;
border-radius: 999px;
color: var(--ts-fg);
background: rgba(var(--ts-fg-rgb), 0.06);
}
.tlf-glyph svg {
inline-size: 14px;
block-size: 14px;
stroke-width: 2;
}
.tlf-row[data-urgent] .tlf-glyph {
color: var(--ts-danger);
}
.tlf-row[data-fresh] .tlf-glyph svg {
animation: tlf-land 0.52s cubic-bezier(0.3, 1.4, 0.4, 1) 0.1s both;
}
@keyframes tlf-land {
from {
scale: 0.5;
rotate: -12deg;
}
45% {
scale: 1.15;
}
to {
scale: 1;
rotate: 0deg;
}
}
/* title over detail, both clipped to one line */
.tlf-text {
display: grid;
row-gap: 2px;
}
.tlf-text b,
.tlf-text small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tlf-text b {
font-weight: 500;
font-size: 13px;
letter-spacing: -0.01em;
}
.tlf-text small {
font-size: 12px;
color: color-mix(in srgb, var(--ts-fg) 50%, var(--ts-raised));
}
/* age: dim, green while fresh, red when urgent */
.tlf-time {
padding-right: 4px;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: color-mix(in srgb, var(--ts-fg) 40%, var(--ts-raised));
transition: color 0.6s var(--tlf-ease);
}
.tlf-row[data-fresh] .tlf-time {
color: var(--ts-success);
}
.tlf-row[data-urgent] .tlf-time {
color: var(--ts-danger);
}
/* off screen or reduced motion: the loops hold still */
.tlf-root[data-off] .tlf-dot::after,
.tlf-root[data-off] .tlf-glyph svg {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.tlf-root[data-live] .tlf-dot::after,
.tlf-row[data-fresh] .tlf-glyph svg {
animation: none;
}
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { LiveFeed } from "./LiveFeed";
import "./LiveFeed.css";
export default function Demo() {
return <LiveFeed />;
}