AI

Streaming Table

A table that renders while its rows are still arriving — the header locks the column geometry on frame one, cells still in flight shimmer in place, and the footer count settles into a total.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, CircleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Keyframes
 *
 * They ship with the component through a React 19 hoisted <style> — no Tailwind
 * config edits, and duplicate instances dedupe by href.
 *
 * The row entrance is opacity + a 6px lift, nothing more. A streamed table is
 * already staggered by arrival time, so an animation-delay ramp (the usual list

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/streaming-table.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "StreamingTable" component: a table that is
rendered while its rows are still arriving from a model or a tool call. Only
lucide-react for icons and a cn() class merger. No table library — the component
does not sort, filter, select or paginate; it renders arrival.

Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
  children. The root is a column: table card on top, status footer under it.
- columns: Column[] — { key, header, align?: "left"|"center"|"right", width?,
  wrap?, className?, headerClassName?, skeletonWidth?, cell?(value, row) }.
  `key` is looked up in row.cells and is the React key of the column.
- rows: Row[] — { id, cells: Record<string, ReactNode>, streaming? }.
  Three-valued cells, and this is the heart of the component:
    · key missing / undefined = has NOT arrived
    · null                    = arrived, and it is empty
    · anything else           = arrived, render it
  `column.cell` is called only for the third case.
- isStreaming?: boolean (default false) — true while anything can still arrive.
- total?: number — expected row count when the producer knows it.
- error?: ReactNode — the run failed or was stopped.
- placeholderRows?: number (default 3), caption?, minWidth?, emptyLabel?,
  emptyCell? (default an em dash), streamingLabel? (default "streaming…"),
  formatCount?(received, total), announce?: "summary" | "off", hideFooter?,
  onSettle?({ received, interrupted }).

Behavior — layout is locked before row one
- Render the header (and a <colgroup> carrying column widths) on the first
  frame, from `columns` alone. The header is the layout contract; rows never
  get a vote on geometry.
- The table is table-fixed. With auto layout the browser re-measures every
  column each time a cell lands and the whole table twitches sideways for the
  length of the run; fixed layout makes an arriving cell a paint, not a reflow.
- Cells truncate on one line by default (wrap: true opts out per column),
  because a wrapping cell changes its row's height mid-stream and shoves every
  row below it down. Give the plain-text cells a title attribute so the
  truncated value is still readable.
- Right-aligned columns also get tabular figures: proportional digits change
  width per token, so a number column would visibly breathe while it fills.

Behavior — the three states of a cell
- A cell that has not arrived renders a placeholder bar ONLY while it can still
  arrive, i.e. the row is in flight: isStreaming AND (row.streaming ?? it is the
  last row). A shimmer that can never resolve is a lie.
- Any other missing cell — an earlier row that came in short, or any pending
  cell once the run ended or errored — settles into the muted empty mark. The
  same mark renders for an explicit null.
- The placeholder is exactly one text line tall (the text-sm line box), so it
  occupies the height its value will occupy; otherwise every arriving cell
  nudges the table by a few pixels.
- Placeholder widths are hashed from `${row.id}:${column.key}` into a small set
  of percentages. Math.random() in render would re-roll every bar on every
  arriving cell, so the whole skeleton would twitch each time one of its
  neighbours resolved.
- Cells inside a row may arrive in any order — presence in row.cells is the
  only test; never assume left-to-right.

Behavior — rows, placeholders and the entrance
- Placeholder ("ghost") rows hold the body open before the first row lands, and
  are CONSUMED by arriving rows: count = clamp(placeholderRows - received, 0),
  additionally capped by total - received so the skeleton never promises rows
  that cannot exist. The body therefore only grows while the run is healthy;
  the one honest shrink is a run that ends short of placeholderRows.
- Ghost rows are keyed by index (survivors keep their DOM node as the stack is
  eaten) and aria-hidden — inventing table rows for a screen reader is a lie
  about the data.
- New rows animate in with opacity + a ~6px lift, once. Do NOT add an
  animation-delay ramp: arrival time already staggers them, and a ramp makes
  late rows feel laggy.
- Snapshot the row ids present on the FIRST render and never animate those:
  opening an already-finished 200-row table must not replay 200 entrances.
  Keep that snapshot frozen for the component's lifetime, because the class has
  to stay put — removing an animation class mid-animation snaps the element to
  its end state, which would cut every entrance short as the next row lands.
  Document the corollary: a replayed run should mint fresh row ids.
- Zero rows and not streaming renders one full-width body row with emptyLabel:
  a run that returned nothing is an answer, not a failure.

Behavior — the footer and settling
- The footer is one line: a marker plus text. Streaming = pulsing dot +
  "12 of 24 rows · streaming…"; finished = check + "24 rows"; error = alert glyph
  + the error node, in the destructive token. Every marker occupies the same box
  so the footer does not re-flow when the run flips.
- An error ends the run for every purpose: pending cells settle, ghost rows
  disappear, the dot stops. Rows already received are never cleared.
- One-shot settle lock: when the run flips from streaming to finished, fire
  onSettle({ received, interrupted }) and publish one screen-reader summary —
  exactly once, and never for a table that was mounted already finished
  (nothing happened while the user was here). Re-arm the lock only when a run
  is actually streaming again.

Behavior — screen readers
- aria-busy rides the table wrapper, not the root: assistive tech may hold back
  a live region while an ANCESTOR is busy, which would swallow the summary at
  the exact moment it matters.
- A separate sr-only <div role="status" aria-live="polite"> stays EMPTY for the
  whole run and receives one sentence when it settles ("Table complete. 24
  rows." / "Stream stopped. 7 rows received."). Announcing per row would make a
  screen reader restart mid-sentence dozens of times.
- Pending cells are aria-hidden and silent. With five columns and three
  placeholder rows, an honest "loading" label would be read out fifteen times;
  the busy flag already says it once.

Rendering & styling
- Semantic tokens only: bg-card and border for the card, border-b between rows,
  text-muted-foreground for the header/footer/empty mark, bg-muted/40 for the
  in-flight row, bg-muted-foreground/20 for the placeholder bar with a
  via-foreground/15 sweep, bg-primary for the live dot, text-primary for the
  done check, text-destructive for the error line. No hex, no rgb(), no oklch().
- The card wraps the table in overflow-x-auto, so minWidth makes narrow screens
  scroll instead of crushing five columns — and the scroll box clips the table
  to the card's rounded corners for free.
- Keyframes (row entrance, sweep, dot) ship inside the component through a
  React 19 hoisted <style href precedence>; duplicate instances dedupe by href.
- prefers-reduced-motion: the entrance, the sweep and the dot pulse are all
  motion-reduce:[animation:none]. The placeholder bar stays visible and the
  layout still tells you what has and has not arrived — the component loses its
  motion, never its meaning.
- Expose data-state on the root ("streaming" | "complete" | "error"),
  data-streaming on the in-flight row and data-pending on a pending cell, so a
  consumer can restyle those without forking the component.

Customization levers
- Density: header/cell px-3 py-2 and the text-sm root are the only spacing
  knobs; drop to py-1.5 for a log-like table, raise to py-3 for a report.
- Column widths: percentages in `width` (they feed <colgroup>); leave a column
  unset to let it share the remainder. Set minWidth once you have more than
  four columns.
- placeholderRows: 0 kills the skeleton entirely (the table simply grows from
  an empty body), 5-6 reserves a taller shape for a query you know is long.
- Placeholder look: swap the sweep for animate-pulse, or pass skeletonWidth per
  column to make the skeleton mimic the real value distribution.
- Cell rendering: `cell(value, row)` turns any column into badges, links or
  monospace figures — keep the rendered chunk one line box tall or the row will
  resize when it arrives.
- Status line: formatCount for i18n ("12 / 24 行"), streamingLabel for the verb,
  hideFooter when a surrounding panel already owns the run status, announce:
  "off" when that panel also owns the live region.
- The empty mark (emptyCell) is what a null cell and a never-arrived cell look
  like — an em dash, "n/a", or a muted icon.
- Long results: wrap the card in your own max-height scroll container and pair
  it with a follow-scroll (see streaming-code); this component deliberately
  does not own the scroll position.

Concepts

  • Layout lock — the header and its <colgroup> exist before row one and the table is fixed-layout, so an arriving cell repaints one box instead of re-measuring every column; without it a streamed table twitches sideways for the whole run.
  • Cell-level pending — arrival is tracked per cell, not per row: a key missing from row.cells has not landed, null landed empty, anything else renders — which is what lets a half-written row show its first two values instead of waiting for the whole tuple.
  • Resolvable shimmer — a placeholder animates only where a value can still arrive (the in-flight row of a live run); once the run ends or errors, every pending cell settles into the empty mark, because a shimmer that can never resolve is a lie about the data.
  • Consumed skeleton — ghost rows hold the body open before the first row and are eaten one by one as real rows land, capped by total, so the table's height grows monotonically instead of collapsing and re-expanding.
  • Enter once — the entrance is decided from the id snapshot taken on the first render and then frozen: rows that were already there never animate, rows that land while you watch animate exactly once, and the class is never removed mid-flight (which would snap the row to its end state).
  • Settle once — the flip from arriving to finished is the only event: it fires onSettle, flips the count to a total and puts one sentence into a live region that stayed silent all run, instead of narrating every row.

On This Page