Installation
npx shadcn@latest add @pixeldosa/progressive-revealRequires `cn` at `@/lib/utils`. Is a plain generic function component (no `forwardRef`) since nothing here needs an imperative handle and `forwardRef` has poor generic-type ergonomics. Pass a real `keyExtractor` whenever the source array might ever be spliced rather than purely appended — without one, index-based keys can misattribute 'already seen' identity to the wrong item if earlier entries are removed or reordered.
Usage
"use client";
import * as React from "react";
import { ProgressiveReveal } from "@/components/ui/progressive-reveal";
export function SearchResults() {
const [items, setItems] = React.useState<Result[]>([]);
const [streaming, setStreaming] = React.useState(false);
// ...append to `items` as results arrive from your stream...
return (
<ProgressiveReveal
items={items}
keyExtractor={(item) => item.id}
isStreaming={streaming}
renderItem={(item) => <ResultCard result={item} />}
/>
);
}
Each item is given its entrance transition exactly once — an item already present on a prior render keeps its existing DOM node and is never remounted, so its animation can never replay, no matter how often the parent re-renders.
Why not a typewriter effect
The full value of each item already exists the moment it arrives — revealing it character-by-character would misrepresent something already fully generated as being "typed live." This component only ever fades a whole item in, never streams its content.
Grid layout
<ProgressiveReveal items={cards} layout="grid" renderItem={(card) => <Card {...card} />} />
Features
- Animate once, never replay. Entrance is enforced by React's own keyed reconciliation, not a timer — an item present on a prior render keeps its DOM node, so no amount of parent re-rendering can restart its transition.
- No fabricated typing. Items fade in whole. Content that already exists in full is never revealed character-by-character (DESIGN.md §3.3).
- Stable layout while streaming — items append without shifting what's already been read.
- Announced as a running count, once, through a single
aria-liveregion rather than one announcement per arrival, so a fast stream doesn't flood a screen reader. - Two layouts —
stackandgrid— sharing one entrance behaviour.
Examples
A grid of results rather than a vertical stack:
<ProgressiveReveal
items={cards}
layout="grid"
keyExtractor={(card) => card.id}
renderItem={(card) => <ResultCard result={card} />}
/>
Showing the trailing "More loading…" indicator while the stream is still open — flip
isStreaming to false the moment it closes, so the indicator never outlives the work
it represents:
<ProgressiveReveal
items={results}
keyExtractor={(result) => result.id}
isStreaming={status === "streaming"}
renderItem={(result) => <ResultRow result={result} />}
/>
Pass a real keyExtractor whenever the array might be spliced rather than purely
appended — the array index is only a safe key while items are append-only:
<ProgressiveReveal items={items} keyExtractor={(item) => item.id} renderItem={renderItem} />
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | T[] | — | Required. The current known items; append to it as more arrive. |
renderItem | (item: T, index: number) => React.ReactNode | — | Required. |
keyExtractor | (item: T, index: number) => React.Key | array index | Use a real key whenever the array might be spliced, not purely appended. |
isStreaming | boolean | false | Shows a trailing "More loading…" indicator. |
layout | "stack" | "grid" | "stack" |
Accessibility
- Item arrivals are announced through a single
aria-live="polite"region reporting the running count, not one announcement per item, so a fast stream doesn't spam a screen reader. - The streaming indicator's text is the real signal; its pulsing dot is decorative.
- Under
prefers-reduced-motion, per-item entrance becomes instant and the pulse stops oscillating, but the "More loading…" text stays exactly as informative.
When not to use
Streaming prose is a different problem — reach for an existing chat/response
component (e.g. Vercel AI Elements) instead. A single value that changes over time is
Smart Field or Diff Accept, not a collection.
why it's built this way
Engineering Notes
Enforces 'animate a new item in exactly once' by relying on React's own keyed reconciliation instead of hand-rolled 'already seen' tracking: each item is wrapped in a small RevealItem component keyed by keyExtractor (or index), which mounts fresh — and therefore plays its entrance transition — only the first time its key appears in the list; on every subsequent render where that key was already present, React reuses the existing DOM node and the component is not remounted, so the animation structurally cannot replay. This is simpler and more reliable than maintaining a Set of seen keys by hand, which is easy to get subtly wrong around React's own re-render timing. Scoped deliberately to structured collections (rows, cards), not prose — an append-only list of discrete items is inherently more layout-stable than a single growing text block, since new rows appear below existing content without reflowing it, which is also why this differs from and does not compete with Vercel AI Elements' prose-streaming components (see the roadmap's North Star Reconciliation table).
how it moves, and when it doesn't
Motion Notes
Per-item entrance is a plain opacity + small upward translate (translate-y-1 to 0) using the same two-phase-mount pattern (mount closed, flip to open on the next animation frame) Command Menu and Selection Actions already use, on --pd-duration-fast/--pd-ease-standard. No item's own content is ever revealed character-by-character — the full item value already exists at arrival time, so a typewriter effect would misrepresent it as being generated live, the same reasoning every other AI-tier component in this system has already established independently. The trailing streaming indicator uses Tailwind's built-in animate-pulse (core Tailwind, no plugin required) gated by motion-safe:, falling back to a static reduced-opacity dot under motion-reduce: — the 'more is coming' text itself is unaffected either way, so the signal survives motion removal.