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.json
Demo · 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.

ChatReelesc or double-click to exit full screendouble-tap to exit

demo/chat-reel.tsx
"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