TypeLog
A scripted terminal that lands lines in sequence, shimmers while it thinks and flashes the beats that carry the story.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/type-log.jsonDemo · DOMscripted
$wisp add packet-field
✓resolved registry · 2 files
✓components/wisp/packet-field.tsx
✓lib/wisp/use-canvas-scene.ts
reading design tokens…
✓bound --wisp, --lantern
✓obstacles: 4 boxes measured
✓protected area painted: 0.000%
✓off-screen: raf released
✓dpr clamped to 2
!reduced-motion: still frame verified
✓ready in 412ms
"use client";
import * as React from "react";
import { cx, useReducedMotion, useScriptedSequence } from "@pouriahlabs/wisp-ui";
/**
* TypeLog — a scripted terminal.
*
* Every dev-tool landing page has to show the product working, and everyone
* hand-rolls it. Hand this an array of steps and it plays: lines land in
* sequence, a thinking step shimmers, and the beats that carry the story flash
* once as they arrive so they read louder than the routine lines around them.
*
* Every line is rendered up front and revealed by opacity — the structural
* rule shared with CodeReel. A pane that appends lines as they land grows
* while it plays, which pushes whatever sits below it down the page on every
* step; laying the transcript out first settles the pane's height before the
* first frame; and the reveal is then pure compositing, not layout.
*
* When the transcript outgrows its pane — a long script, or a full-screen
* viewer playing extra lines through it — the lines ride a track that
* translates to keep the newest one in view, the same compositor-only follow
* CodeReel and ChatReel use, so the terminal scrolls past the fold without the
* pane's height ever moving.
*
* Colour comes from custom properties so the component carries no palette of
* its own; see `.wisp-log` in `wisp-ui/styles.css` for the defaults.
*/
export type TypeLogKind = "command" | "ok" | "thinking" | "warn" | "done" | "plain";
export interface TypeLogStep {
kind?: TypeLogKind;
text: string;
/** Override the default hold for this step, ms. */
hold?: number;
}
export interface TypeLogProps extends React.HTMLAttributes<HTMLDivElement> {
steps: readonly TypeLogStep[];
/** Pause before replaying, ms. `0` runs once and rests on the finished transcript. */
loop?: number;
}
const DEFAULT_HOLD: Record<TypeLogKind, number> = {
command: 900,
thinking: 1250,
ok: 480,
warn: 620,
done: 700,
plain: 480,
};
const MARK: Record<TypeLogKind, string> = {
command: "$",
ok: "✓",
thinking: " ",
warn: "!",
done: "✓",
plain: " ",
};
export function TypeLog({ steps, loop = 2400, className, ...props }: TypeLogProps) {
const host = React.useRef<HTMLDivElement>(null);
const track = React.useRef<HTMLDivElement>(null);
const still = useReducedMotion();
const timings = React.useMemo(
() => steps.map((step) => ({ hold: step.hold ?? DEFAULT_HOLD[step.kind ?? "plain"] })),
[steps],
);
const shown = useScriptedSequence(host, { steps: timings, loop });
const [offset, setOffset] = React.useState(0);
// Follow the newest revealed line past the pane's bottom edge — the same
// measure ChatReel and CodeReel run. Held in a ref so a resize re-runs the
// current version without rebuilding the observer 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;
}
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;
React.useLayoutEffect(() => {
measure();
}, [measure, steps]);
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-log", offset > 0 && "is-scrolled", className)} {...props}>
<div
ref={track}
className="wisp-log-track"
style={{
transform: offset ? `translateY(${-offset}px)` : undefined,
transition: still ? "none" : "transform .42s cubic-bezier(.2,.8,.2,1)",
}}
>
{steps.map((step, i) => {
const kind = step.kind ?? "plain";
return (
<div
key={`${i}-${step.text}`}
className={cx("wisp-log-line", `is-${kind}`, i < shown && "is-shown")}
>
<span className="wisp-log-mark">{MARK[kind]}</span>
<span className="wisp-log-text">{step.text}</span>
{kind === "command" ? <span className="wisp-caret" /> : null}
</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