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.json
Demo · 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);
  },
});

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

demo/code-type.tsx
"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