Streaming List Append
Live matches dock at the bottom of the list as they arrive, nudging the tally in the header.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Streaming List Append
*
* Matches arrive from a live query and dock at the bottom of the list,
* each one nudging the tally in the header. New arrivals enter from
* below, so nothing already read ever moves — the reader's eye keeps its
* place while the list keeps growing under it.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the panel reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `matches`, `intervalMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type StreamMatch = {
title: string;
/** Where it was found — a repository, a folder, a channel. */
origin: string;
/** Short qualifier printed on the right. */
score: string;
};
export type StreamingListAppendProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Everything the query will eventually return. */
matches?: StreamMatch[];
/** How many are already on screen when the stream opens. */
initialCount?: number;
/** Gap between arrivals, in ms. */
intervalMs?: number;
/** Header label beside the tally. */
label?: string;
/** Accent for the live marker. */
accent?: string;
/** Width — px number or any CSS length. */
width?: number | string;
/** Fires when the stream closes. */
onComplete?: () => void;
};
type VariantConfig = {
/** px an arrival rises through as it docks. */
enterY: number;
/** px the tally travels as it changes. */
tallyY: number;
fadeSeconds: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// An arriving line is read the moment it stops, so nothing here settles
// twice: damping ratios (ζ = damping / 2√stiffness) stay at or above
// 0.88. Variants change travel and pace, not the number of bounces.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// ζ ≈ 0.99, barely any travel. For a stream that returns hundreds.
subtle: {
enterY: 6,
tallyY: 10,
fadeSeconds: 0.16,
spring: { type: "spring", stiffness: 520, damping: 45 },
},
// ζ ≈ 0.93. The all-purpose setting.
default: {
enterY: 12,
tallyY: 14,
fadeSeconds: 0.2,
spring: { type: "spring", stiffness: 420, damping: 38 },
},
// ζ ≈ 0.88, more travel — for a short, dramatic result set.
playful: {
enterY: 18,
tallyY: 18,
fadeSeconds: 0.24,
spring: { type: "spring", stiffness: 330, damping: 32 },
},
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on a light surface and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const ACCENT = "#7C7CF0";
const SAMPLE_MATCHES: StreamMatch[] = [
{ title: "Refund policy v4", origin: "Handbook / Billing", score: "98%" },
{ title: "Chargeback runbook", origin: "Support / Playbooks", score: "94%" },
{ title: "Refunds after 60 days", origin: "Handbook / Billing", score: "91%" },
{ title: "Partial credit approvals", origin: "Finance / Controls", score: "86%" },
{ title: "Escalation ladder", origin: "Support / Playbooks", score: "82%" },
{ title: "Merchant of record notes", origin: "Legal / Contracts", score: "77%" },
];
export default function StreamingListAppend({
variant = "default",
matches = SAMPLE_MATCHES,
initialCount = 2,
intervalMs = 520,
label = "matches",
accent = ACCENT,
width = 320,
onComplete,
}: StreamingListAppendProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [shown, setShown] = useState(Math.min(initialCount, matches.length));
const open = shown < matches.length;
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
useEffect(() => {
if (!open) {
onCompleteRef.current?.();
return;
}
const timer = setTimeout(() => setShown((count) => count + 1), intervalMs);
return () => clearTimeout(timer);
}, [open, shown, intervalMs]);
return (
<div style={{ width }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 9,
}}
>
{/* The live marker breathes only while the stream is open, and
stops the moment it closes — a state, not decoration. */}
<motion.span
aria-hidden
animate={
open && !reduceMotion
? { opacity: [0.35, 1, 0.35] }
: { opacity: open ? 0.9 : 0.35 }
}
transition={
open && !reduceMotion
? { duration: 1.5, repeat: Infinity, ease: "easeInOut" }
: { duration: 0.24, ease: "easeOut" }
}
style={{
width: 6,
height: 6,
borderRadius: 999,
background: open ? accent : "currentColor",
flexShrink: 0,
}}
/>
<span
style={{
display: "inline-flex",
alignItems: "baseline",
gap: 5,
fontSize: 12.5,
fontWeight: 650,
}}
>
<Tally value={shown} cfg={cfg} still={Boolean(reduceMotion)} />
<span style={{ fontWeight: 550, opacity: 0.6 }}>{label}</span>
</span>
<span
style={{
marginLeft: "auto",
fontSize: 11,
opacity: 0.45,
whiteSpace: "nowrap",
}}
>
{open ? "Streaming" : "Complete"}
</span>
</div>
<motion.ul
// The list box follows its contents instead of jumping, so the
// panel below it is pushed rather than teleported.
layout={reduceMotion ? false : true}
transition={cfg.spring}
aria-live="polite"
style={{
listStyle: "none",
margin: 0,
padding: 5,
display: "flex",
flexDirection: "column",
gap: 3,
borderRadius: 12,
background: tone(4),
border: `1px solid ${tone(10)}`,
}}
>
<AnimatePresence initial={false}>
{matches.slice(0, shown).map((match) => (
<motion.li
key={match.title}
// Arrivals dock at the bottom and rise into place. Because
// nothing above them moves, the entries already read hold
// still while the list grows.
initial={{ opacity: 0, y: reduceMotion ? 0 : cfg.enterY }}
animate={{ opacity: 1, y: 0 }}
transition={{
opacity: { duration: cfg.fadeSeconds, ease: "easeOut" },
y: cfg.spring,
}}
style={{
display: "flex",
alignItems: "center",
gap: 9,
padding: "8px 9px",
borderRadius: 8,
background: tone(4),
}}
>
<span
aria-hidden
style={{
width: 22,
height: 22,
borderRadius: 6,
flexShrink: 0,
display: "grid",
placeItems: "center",
background: tone(8),
opacity: 0.7,
}}
>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
<path
d="M3 1.6h3.4L9 4.2v6.2H3z"
stroke="currentColor"
strokeWidth="1.1"
strokeLinejoin="round"
/>
</svg>
</span>
<span style={{ minWidth: 0 }}>
<span style={{ display: "block", fontSize: 12.5, fontWeight: 600 }}>
{match.title}
</span>
<span style={{ display: "block", fontSize: 11, opacity: 0.5 }}>
{match.origin}
</span>
</span>
<span
style={{
marginLeft: "auto",
fontSize: 11,
fontWeight: 600,
opacity: 0.55,
fontVariantNumeric: "tabular-nums",
}}
>
{match.score}
</span>
</motion.li>
))}
</AnimatePresence>
</motion.ul>
</div>
);
}
/** The tally. The old figure leaves upward as the new one arrives from
* below — translation and a crossfade, never a scale, because a number
* that grows and shrinks is a number you have to re-read. */
function Tally({
value,
cfg,
still,
}: {
value: number;
cfg: VariantConfig;
still: boolean;
}) {
const travel = still ? 0 : cfg.tallyY;
return (
<span
style={{
position: "relative",
display: "inline-block",
minWidth: "1ch",
height: 16,
overflow: "hidden",
fontVariantNumeric: "tabular-nums",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={value}
initial={{ y: travel, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -travel, opacity: 0 }}
transition={{
y: cfg.spring,
opacity: { duration: cfg.fadeSeconds, ease: "easeOut" },
}}
style={{
position: "absolute",
inset: 0,
display: "block",
lineHeight: "16px",
}}
>
{value}
</motion.span>
</AnimatePresence>
</span>
);
}About this pattern
A query that returns as it finds rather than all at once: crawls, log tails, federated lookups. Each arrival enters from below and docks at the end of the list, which is the whole trick — everything already on screen holds absolutely still, so a reader midway down keeps their place while the panel grows under them. The tally in the header changes by translation and a crossfade, never a scale, so the figure stays legible while it moves. A live marker breathes only while the stream is open and stops the moment it closes, making the end of the run as clear as the start.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Dashboard
Events dock at the end of the stream with a running count above them.
Related patterns
- Search Results SwapStale answers dim and stay put while the fresh set cross-fades over them.
- Chart Bars GrowBars rise out of the baseline in reading order, with the axis landing first so there is something to measure against.
- Dashboard Tiles CascadeMetric tiles resolve corner to corner in a diagonal wave instead of arriving as one slab.