Skip to contentVibraUI
Data display

Live feed

An activity feed whose rows arrive on an interval, badged with how each one went.

The first limit rows are drawn before anything renders, so the server sends a real frame rather than an empty box waiting for a tick — which is why the source has to be seeded rather than random: the same rows have to come out on both sides. After that one row arrives every every milliseconds, newest first, and the oldest falls off at the limit. It stops when nobody is watching: a hidden tab, a reader who has asked for less motion, or live={false} all park it on the rows it has, and data-live says which it is. Each row is an ActivityFeed row with no actor — nobody did it — so the avatar column is gone and a StatusBadge carrying the tone in a word opens the line instead, never colour alone. The list is a polite live region announcing additions only, so a reader is told what arrived rather than handed the whole feed again every tick.

Install

npx shadcn@latest add @vibra/live-feed

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
source() => { id: string; text: string; tone: "success" | "warning" | "danger" | "info" | "neutral"; at: Date }—Called for each new row, and limit times before the first render. Seed it.
everynumber—Milliseconds between rows.
limitnumber8How many rows the feed holds before the oldest falls off.
livebooleantrueFalse parks the feed on the rows it already has.
nowDate—What the stamps are measured against — a page fixed to a reference date passes it.

Dependencies

Source

components/ui/live-feed.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { useInterval } from "@/hooks/use-interval"
import { useReducedMotion } from "@/hooks/use-reduced-motion"
import {
  ActivityFeed,
  type ActivityFeedItem,
  type ActivityFeedProps,
} from "@/components/ui/activity-feed"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"

export type LiveFeedTone = "success" | "warning" | "danger" | "info" | "neutral"

/** One thing that just happened: what it was, how it went, and when. */
export type LiveFeedEvent = {
  id: string
  text: string
  tone: LiveFeedTone
  at: Date
}

/** The pill each tone wears. A tone is a meaning, so it resolves to a token, never a literal. */
const TONE_VARIANT: Record<LiveFeedTone, StatusVariant> = {
  success: "success",
  warning: "warning",
  danger: "danger",
  info: "info",
  neutral: "neutral",
}

const TONE_LABEL: Record<LiveFeedTone, string> = {
  success: "OK",
  warning: "Warn",
  danger: "Error",
  info: "Info",
  neutral: "Note",
}

/** How many rows the feed holds before the oldest falls off the bottom. */
const DEFAULT_LIMIT = 8

export type LiveFeedProps = Omit<ActivityFeedProps, "items"> & {
  /** Called for each new row. Seed it, so the server's first frame and the client's agree. */
  source: () => LiveFeedEvent
  /** Milliseconds between rows. */
  every: number
  limit?: number
  /** False parks the feed on the rows it already has. */
  live?: boolean
}

/** The rows a feed opens on: `limit` of them, newest first, drawn before anything renders. */
function firstFrame(source: () => LiveFeedEvent, limit: number): LiveFeedEvent[] {
  const rows: LiveFeedEvent[] = []
  for (let i = 0; i < limit; i++) rows.unshift(source())
  return rows
}

/** Whether the document is currently on screen. False on the server, where it is not. */
function useHidden(): boolean {
  const [hidden, setHidden] = React.useState(false)

  React.useEffect(() => {
    const read = () => setHidden(document.hidden)
    read()
    document.addEventListener("visibilitychange", read)
    return () => document.removeEventListener("visibilitychange", read)
  }, [])

  return hidden
}

/**
 * An activity feed whose rows arrive on their own.
 *
 * The first `limit` rows are drawn before anything renders, so the server sends
 * a real frame rather than an empty box waiting for a tick — which is why the
 * source has to be seeded rather than random. After that a row arrives every
 * `every` milliseconds, newest first, and the oldest falls off at `limit`.
 *
 * It stops when nobody is watching: a hidden tab, a reader who has asked for
 * less motion, or `live={false}` all park it on the rows it already has, and
 * `data-live` says which it is. The list is a polite live region, so a new row
 * is announced once, after whatever the reader is doing.
 */
function LiveFeed({
  className,
  source,
  every,
  limit = DEFAULT_LIMIT,
  live = true,
  ...props
}: LiveFeedProps) {
  const root = React.useRef<HTMLDivElement | null>(null)
  const [rows, setRows] = React.useState(() => firstFrame(source, limit))
  const hidden = useHidden()
  const reduced = useReducedMotion(root)
  const running = live && !hidden && !reduced && every > 0

  useInterval(() => {
    setRows((current) => [source(), ...current].slice(0, limit))
  }, running ? every : null)

  const items: ActivityFeedItem[] = rows.map((row) => ({
    id: row.id,
    time: row.at,
    action: (
      <span className="flex min-w-0 items-baseline gap-2">
        <StatusBadge
          status={row.tone}
          variant={TONE_VARIANT[row.tone]}
          size="sm"
          label={TONE_LABEL[row.tone]}
          className="shrink-0 self-center"
        />
        <span className="min-w-0">{row.text}</span>
      </span>
    ),
  }))

  return (
    <div
      ref={root}
      data-slot="live-feed"
      data-live={running || undefined}
      className={cn("w-full", className)}
    >
      {/* The composite-input exception: the root takes className and the state
          attributes, and everything else — groupByDay, emptyMessage, now, an
          id, an aria-* — goes to the feed, which is where it belongs. */}
      <ActivityFeed
        items={items}
        // Polite, and only the rows: a reader is told what arrived, not handed
        // the whole feed again every tick.
        aria-live="polite"
        aria-relevant="additions"
        {...props}
      />
    </div>
  )
}

export { LiveFeed }