Teseria
All elements

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

Terminal
npm i framer-motion lucide-react

2. Files

LiveFeed.tsx169 lines
"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>
  );
}
LiveFeed.css197 lines
.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;
  }
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { LiveFeed } from "./LiveFeed";
import "./LiveFeed.css";

export default function Demo() {
  return <LiveFeed />;
}