CodeType
A code pane that types itself out character by character with a blinking caret, holding its final box so the page never reflows mid-play.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/code-type.jsonDemo · DOMzero CLS
use-canvas-scene.ts
const wisp = useTokenColor("--wisp", [162, 72, 52]);
useCanvasScene(ref, {
draw: ({ ctx, width, height, dt }) => {
ctx.clearRect(0, 0, width, height);
paint(ctx, width, height, dt, wisp);
},
});"use client";
import * as React from "react";
import { cx, useMotionRateRef, useMotionTrigger, useReducedMotion } from "@pouriahlabs/wisp-ui";
/**
* CodeType — a code pane that types itself out without moving the page.
*
* A naïve typewriter appends characters into an empty box, so the pane grows a
* line at a time and everything below it slides down for the whole run — a
* multi-second layout shift, the one thing this library exists to prevent. On
* multi-line code it is worst: the box's final height isn't known until the
* last newline lands.
*
* So the finished code is always in the layout, holding the exact box — every
* line, every level of indentation reserved — while a hidden sizer. The typed
* prefix is painted over it, absolutely positioned, and the box is correct from
* the first frame to the last however little has been typed so far.
*
* The real code is the accessible name throughout (aria-label on the root, the
* animating layer aria-hidden), so assistive tech is handed the whole listing,
* not a half-typed fragment — and the settled copy is selectable once done.
*/
export interface CodeTypeProps extends React.HTMLAttributes<HTMLElement> {
/** The code to type. Also the accessible name while typing. */
code: string;
/** Milliseconds spent on each character. */
speed?: number;
/** Wait this long after entering view before starting, ms. */
delay?: number;
/** Replay every time it re-enters the viewport. */
replay?: boolean;
/** Rendered as a `<pre>` by default so newlines and indentation survive. */
as?: React.ElementType;
}
// A code pane must be monospace for the caret to sit on a character grid and
// for the sizer and the typed overlay to occupy identical space per glyph.
const MONO =
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace';
export function CodeType({
code,
speed = 28,
delay = 0,
replay = false,
as: Tag = "pre",
className,
style,
...props
}: CodeTypeProps) {
// `null` is the settled state — full code shown, no caret. A string is the
// typed-so-far prefix, i.e. the run is in flight.
const [typed, setTyped] = React.useState<string | null>(null);
const reduced = useReducedMotion();
const raf = React.useRef(0);
// Motion speed multiplies the typing cadence; duration stretches it. The
// component's own `speed` prop stays the authored per-character time. Live,
// so a slider retimes a run in flight.
const rateRef = useMotionRateRef();
// Seeded explicitly — `useRef<T>()` with no argument is a type error under
// `@types/react` 19, and this file gets copied into consumers' repos.
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const ref = useMotionTrigger<HTMLElement>(
{
replay,
rootMargin: "0px 0px -8% 0px",
run: (done) => {
let last = 0;
let elapsed = 0;
const frame = (now: number) => {
// Drive the count off elapsed time, not a per-frame increment, so the
// typing keeps its pace across dropped frames and refresh rates.
elapsed += (last ? now - last : 0) * rateRef.current.speed;
last = now;
const perChar = speed * (rateRef.current.duration || 1);
const count = Math.min(code.length, Math.floor(elapsed / perChar));
setTyped(code.slice(0, count));
if (count < code.length) raf.current = requestAnimationFrame(frame);
else {
// Hand the box back to the settled layer — one selectable copy of
// the code, no overlay, no caret.
setTyped(null);
done();
}
};
timer.current = setTimeout(() => {
raf.current = requestAnimationFrame(frame);
}, delay);
},
settle: () => {
cancelAnimationFrame(raf.current);
if (timer.current) clearTimeout(timer.current);
setTyped(null);
},
cancel: () => {
cancelAnimationFrame(raf.current);
if (timer.current) clearTimeout(timer.current);
},
},
[code, speed, delay, replay],
);
// Reduced motion is its own still frame: the whole listing, static, no caret.
// Guarding on it as well as trusting the trigger's settle means a stale
// in-flight frame can never paint even for the tick before settle lands.
const typing = typed !== null && !reduced;
return (
<Tag
ref={ref}
aria-label={code}
className={cx("wisp-code-type", className)}
style={{ margin: 0, whiteSpace: "pre", fontFamily: MONO, ...style }}
{...props}
>
{/* The overlay anchors to this wrapper, not to the root: `inset: 0` on an
absolute child reaches the *padding* edge of its containing block, so
anchoring to a padded root (`className="p-4"`, the normal case for a
code pane) would shift the typed text off the settled copy by exactly
the padding, then jump on completion. The wrapper hugs the content. */}
<span style={{ position: "relative", display: "block" }}>
{/* Always in the layout: this is what fixes the box. Hidden rather than
removed while typing, so it keeps occupying exactly its own space. */}
<span aria-hidden={typing} style={typing ? { visibility: "hidden" } : undefined}>
{code}
</span>
{typing ? (
<span
aria-hidden
style={{ position: "absolute", inset: 0, whiteSpace: "pre", overflow: "hidden" }}
>
{typed}
{/* Blinking block at the typing head; `wisp-caret` stills itself under
reduced motion, though we never render it there. */}
<span className="wisp-caret" />
</span>
) : null}
</span>
</Tag>
);
}
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