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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/streaming-table.jsonPrompt
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.cellshas not landed,nulllanded 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.
Streaming Markdown
A progressive Markdown renderer for LLM streams that paints only what can no longer change — the ambiguous tail waits in a buffer instead of flashing raw markup.
Streaming Code
A code panel that renders while the model is still writing it — caret on the last line, frozen finished lines, regex tinting, copy-what-arrived and auto-scroll the reader can break.