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.jsonDemo · 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 }
"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
- ✓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