ChatReel
A scripted exchange with a real thinking state. Every AI product ships one of these and every one of them is bespoke.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/chat-reel.jsonDemo · DOMscripted
YouThe hero particles drift over my headline.
Wisp
WispTag the h1 with data-wisp-avoid — packets crash at its edge instead.
YouAnd on a phone, where it wraps to three lines?
WispIt re-measures on reflow. The box follows the wrap.
"use client";
import * as React from "react";
import { cx, useReducedMotion, useScriptedSequence } from "@pouriahlabs/wisp-ui";
/**
* ChatReel — a scripted exchange with a real thinking state.
*
* Every AI product ships a version of this on its landing page and every one of
* them is bespoke. Same structural rule as CodeReel: all turns are laid out
* first and revealed, so the transcript can't push the page around as it plays.
*
* When the script is taller than its container, the track is *translated* so the
* newest turn stays in view and older ones slide off the top. That is a
* compositor-only transform, not a scroll and not a layout change — the
* container's height never moves, so nothing below the component can be pushed
* around by a long line of copy.
*/
export interface ChatTurn {
/** Which side the turn sits on. */
side: "user" | "agent";
/** Small uppercase label above the bubble. */
who: string;
/** Turn body. Omit with `thinking` to render the dots instead. */
text?: string;
/** Render an animated thinking indicator in place of text. */
thinking?: boolean;
hold?: number;
}
export interface ChatReelProps extends React.HTMLAttributes<HTMLDivElement> {
turns: readonly ChatTurn[];
/** Pause before replaying, ms. `0` runs once and rests on the finished exchange. */
loop?: number;
}
export function ChatReel({ turns, loop = 3000, className, ...props }: ChatReelProps) {
const host = React.useRef<HTMLDivElement>(null);
const track = React.useRef<HTMLDivElement>(null);
const still = useReducedMotion();
const timings = React.useMemo(
() => turns.map((turn) => ({ hold: turn.hold ?? (turn.thinking ? 1100 : 1500) })),
[turns],
);
const shown = useScriptedSequence(host, { steps: timings, loop });
const [offset, setOffset] = React.useState(0);
// How far past the container's bottom edge does the newest visible turn
// sit? Read through a ref so a resize can call the latest version without
// tearing down and rebuilding the observer below on every reveal.
const measure = React.useCallback(() => {
const hostEl = host.current;
const trackEl = track.current;
if (!hostEl || !trackEl) return;
const last = trackEl.children[shown - 1] as HTMLElement | undefined;
if (!last) {
setOffset(0);
return;
}
// `offsetTop` is measured from the host's border edge, so the resting place
// for the newest turn is the padding box's bottom — not `clientHeight`,
// which would park it flush against the frame and eat the bottom padding.
const padBottom = Number.parseFloat(getComputedStyle(hostEl).paddingBottom) || 0;
const bottom = last.offsetTop + last.offsetHeight;
setOffset(Math.max(0, bottom - (hostEl.clientHeight - padBottom)));
}, [shown]);
const latestMeasure = React.useRef(measure);
latestMeasure.current = measure;
// Layout effect so the offset lands in the same paint as the turn it
// follows, with no flash of the un-shifted stack.
React.useLayoutEffect(() => {
measure();
}, [measure, turns]);
// A resize can change how much of the track needs to shift without `shown`
// or `turns` changing at all — a narrower container wraps turns onto more
// lines, which moves the newest one's bottom edge just as surely as a new
// reveal does.
React.useEffect(() => {
const hostEl = host.current;
if (!hostEl) return;
const resizeObserver = new ResizeObserver(() => latestMeasure.current());
resizeObserver.observe(hostEl);
return () => resizeObserver.disconnect();
}, []);
return (
<div ref={host} className={cx("wisp-chat", offset > 0 && "is-scrolled", className)} {...props}>
<div
ref={track}
className="wisp-chat-track"
style={{
transform: offset ? `translateY(${-offset}px)` : undefined,
transition: still ? "none" : "transform .42s cubic-bezier(.2,.8,.2,1)",
}}
>
{turns.map((turn, i) => (
<div key={i} className={cx("wisp-chat-turn", `is-${turn.side}`, i < shown && "is-shown")}>
<span className="wisp-chat-who">{turn.who}</span>
{turn.thinking ? (
<span className="wisp-chat-dots" role="status" aria-label="Thinking">
<i />
<i />
<i />
</span>
) : (
turn.text
)}
</div>
))}
</div>
</div>
);
}
Certified against the contract
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React