CodeReel

A code pane that reveals line by line and lands a diff. Rows are laid out before the first frame, so the pane never reflows mid-play.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/code-reel.json
Demo · DOMscripted
21− const boxes = useRef([])
21+ const boxes = useObstacles(ref, avoid ?? '[data-wisp-avoid]')
23 useCanvasScene(ref, { draw, boxes })
25+ // Trim the trail at the last box it crosses —
26+ // a comet that stops has a tail that stops too.
27 return <canvas ref={ref} className="wisp-canvas" />
28 }

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

demo/code-reel.tsx
"use client";

import * as React from "react";
import { cx, useReducedMotion, useScriptedSequence } from "@pouriahlabs/wisp-ui";

/**
 * CodeReel — a code pane that reveals line by line and lands a diff.
 *
 * The structural rule: every row is rendered up front and revealed by opacity.
 * A pane that appends rows grows as it plays, which pushes whatever sits below
 * it down the page — the exact layout shift the rest of this library exists to
 * prevent. Laying the rows out first settles the pane's height before the first
 * frame, and the reveal is then pure compositing.
 *
 * When the listing is taller than its pane — a long transcript, or a full-screen
 * viewer that has handed it extra lines to play through — the rows are
 * *translated* so the newest revealed line stays in view and the earlier ones
 * slide up off the top, exactly as ChatReel does its turns. That is a
 * compositor-only transform, not a scroll and not a layout change: the pane's
 * own height never moves, so nothing below the component is ever pushed around,
 * and a reader watching full screen sees the code keep typing past the fold and
 * scroll to follow instead of stopping at the bottom edge.
 */

export interface CodeReelLine {
  /** Gutter line number. Repeat it across a -/+ pair, as a real diff does. */
  number?: number;
  /** Diff marker. */
  change?: "add" | "remove";
  /** Line content. Plain text — bring your own highlighter if you want one. */
  text: string;
  hold?: number;
}

export interface CodeReelProps extends React.HTMLAttributes<HTMLDivElement> {
  lines: readonly CodeReelLine[];
  /** Pause before replaying, ms. `0` runs once and rests on the finished pane. */
  loop?: number;
}

export function CodeReel({ lines, loop = 2600, className, ...props }: CodeReelProps) {
  const host = React.useRef<HTMLDivElement>(null);
  const track = React.useRef<HTMLDivElement>(null);
  const still = useReducedMotion();

  const timings = React.useMemo(
    () => lines.map((line) => ({ hold: line.hold ?? (line.change ? 520 : 260) })),
    [lines],
  );

  const shown = useScriptedSequence(host, { steps: timings, loop });
  const [offset, setOffset] = React.useState(0);

  // How far past the pane's bottom edge does the newest revealed row sit? Read
  // through a ref so a resize can re-run the current version without tearing
  // down the observer below on every reveal. Mirrors ChatReel exactly.
  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 from the host's border edge, so the resting place for the
    // newest row 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 shift lands in the same paint as the row it follows,
  // with no flash of the un-scrolled listing.
  React.useLayoutEffect(() => {
    measure();
  }, [measure, lines]);

  // A resize can change how far the track must shift without `shown` or `lines`
  // changing — going full screen is exactly that, a taller pane over the same
  // listing — so re-measure whenever the pane's box 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-code", offset > 0 && "is-scrolled", className)} {...props}>
      <div
        ref={track}
        className="wisp-code-track"
        style={{
          transform: offset ? `translateY(${-offset}px)` : undefined,
          transition: still ? "none" : "transform .42s cubic-bezier(.2,.8,.2,1)",
        }}
      >
        {lines.map((line, i) => (
          <div
            key={i}
            className={cx(
              "wisp-code-row",
              line.change === "add" && "is-add",
              line.change === "remove" && "is-remove",
              i < shown && "is-shown",
            )}
          >
            <span className="wisp-code-number">{line.number ?? ""}</span>
            <span className="wisp-code-mark">
              {line.change === "add" ? "+" : line.change === "remove" ? "−" : " "}
            </span>
            <span className="wisp-code-text">{line.text}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

Certified against the contract