Lantern

Typewriter.

Terminal lines that type themselves out, with a pause between lines, an optional loop and a blinking cursor. Use it inside a Terminal or on its own.

LANTERN
~/my-little-world

npx lanterncn add terminal

Checking registry... done

Installing dependencies... done

Created 1 file: components/ui/terminal.tsx

lantern publish ./site

published hub://your-little-world/

ConnectedLoops

Installation#

With the CLI

$npx lanterncn add typewriter

This also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/typewriter. See Installation if your project is not set up yet.

Manually

  1. Copy the source into your project

    components/ui/typewriter.tsx
    "use client";
    
    import * as React from "react";
    
    import { cn } from "@/lib/utils";
    import { TerminalBody } from "@/components/ui/terminal";
    
    type TypewriterLine = {
      /** Show the prompt symbol and style the line as a typed command. */
      prompt?: boolean;
      text: string;
      /** Milliseconds to wait before this line starts. Defaults to the pause prop. */
      delay?: number;
      /** Print the whole line at once instead of typing it. Defaults to true for output lines (no prompt). */
      instant?: boolean;
    };
    
    type TypewriterProps = Omit<React.ComponentProps<"div">, "children"> & {
      lines: TypewriterLine[];
      /** Milliseconds per character. */
      speed?: number;
      /** Milliseconds between lines. */
      pause?: number;
      /** Milliseconds before the first line. */
      startDelay?: number;
      /** Start again after the last line. */
      loop?: boolean;
      /** Milliseconds to hold the finished text before looping. */
      loopDelay?: number;
      /** Show a blinking block cursor. */
      cursor?: boolean;
      /** When finished, leave an empty prompt line with the cursor on it. */
      finalPrompt?: boolean;
      promptSymbol?: React.ReactNode;
      onComplete?: () => void;
    };
    
    const reducedMotionQuery = "(prefers-reduced-motion: reduce)";
    
    function subscribeReducedMotion(callback: () => void) {
      const mql = window.matchMedia(reducedMotionQuery);
      mql.addEventListener("change", callback);
      return () => mql.removeEventListener("change", callback);
    }
    
    function usePrefersReducedMotion() {
      return React.useSyncExternalStore(
        subscribeReducedMotion,
        () => window.matchMedia(reducedMotionQuery).matches,
        () => false,
      );
    }
    
    type Step = { line: number; char: number; done: boolean };
    const initialStep: Step = { line: 0, char: -1, done: false };
    
    function Typewriter({
      className,
      lines,
      speed = 45,
      pause = 600,
      startDelay = 400,
      loop = false,
      loopDelay = 2400,
      cursor = true,
      finalPrompt = true,
      promptSymbol = "$",
      onComplete,
      ...props
    }: TypewriterProps) {
      const reduced = usePrefersReducedMotion();
      // char -1 means the current line is waiting to start.
      const [step, setStep] = React.useState<Step>(initialStep);
      const onCompleteRef = React.useRef(onComplete);
      // Inline line arrays get a new identity on every parent render; only restart timers when the content changes.
      const linesKey = JSON.stringify(lines);
      const linesRef = React.useRef(lines);
      React.useEffect(() => {
        onCompleteRef.current = onComplete;
        linesRef.current = lines;
      });
    
      React.useEffect(() => {
        const lines = linesRef.current;
        if (reduced || lines.length === 0) return;
    
        if (step.done) {
          if (!loop) return;
          const t = window.setTimeout(() => setStep(initialStep), loopDelay);
          return () => window.clearTimeout(t);
        }
    
        const current = lines[step.line];
        if (!current) return;
        const instant = current.instant ?? !current.prompt;
    
        if (step.char === -1) {
          const wait = current.delay ?? (step.line === 0 ? startDelay : pause);
          const t = window.setTimeout(
            () => setStep({ line: step.line, char: instant ? current.text.length : 0, done: false }),
            wait,
          );
          return () => window.clearTimeout(t);
        }
    
        if (step.char < current.text.length) {
          const t = window.setTimeout(() => setStep({ ...step, char: step.char + 1 }), speed);
          return () => window.clearTimeout(t);
        }
    
        if (step.line < lines.length - 1) {
          setStep({ line: step.line + 1, char: -1, done: false });
        } else {
          setStep({ ...step, done: true });
          onCompleteRef.current?.();
        }
      }, [step, reduced, linesKey, speed, pause, startDelay, loop, loopDelay]);
    
      const showAll = reduced || step.done;
      const lastVisible = showAll ? lines.length - 1 : step.char === -1 ? step.line - 1 : step.line;
      const cursorEl = cursor ? (
        <span
          data-slot="typewriter-cursor"
          className="ml-px inline-block h-[1.1em] w-[0.6em] bg-current align-[-0.2em] text-terminal-foreground motion-safe:animate-lantern-blink"
        />
      ) : null;
      const cursorOnPrompt = showAll && finalPrompt;
    
      return (
        <div data-slot="typewriter" className={cn("font-mono text-[13px] leading-[1.7]", className)} {...props}>
          <div aria-hidden="true">
            {lines.slice(0, lastVisible + 1).map((line, i) => {
              const text = !showAll && i === step.line ? line.text.slice(0, Math.max(step.char, 0)) : line.text;
              const isCursorLine = !cursorOnPrompt && i === lastVisible;
              const afterOutput = i > 0 && line.prompt && !lines[i - 1].prompt;
              return (
                <div
                  key={i}
                  data-slot={line.prompt ? "typewriter-command" : "typewriter-output"}
                  className={cn(
                    "min-h-[1.7em] break-words whitespace-pre-wrap",
                    line.prompt ? "text-terminal-foreground" : "text-[#d9b774]",
                    afterOutput && "mt-3",
                  )}
                >
                  {line.prompt && <span className="mr-2 text-primary select-none">{promptSymbol}</span>}
                  {text}
                  {isCursorLine && cursorEl}
                </div>
              );
            })}
            {lastVisible < 0 && cursor && <div className="min-h-[1.7em]">{cursorEl}</div>}
            {cursorOnPrompt && (
              <div className={cn("min-h-[1.7em]", lines.length > 0 && !lines[lines.length - 1].prompt && "mt-3")}>
                <span className="mr-2 text-primary select-none">{promptSymbol}</span>
                {cursorEl}
              </div>
            )}
          </div>
          <div className="sr-only">
            {lines.map((line, i) => (
              <p key={i}>{line.text}</p>
            ))}
          </div>
        </div>
      );
    }
    
    /** Typewriter inside the padded body of a Terminal. Use in place of TerminalBody. */
    function TerminalTypewriter({
      className,
      bodyClassName,
      ...props
    }: TypewriterProps & { bodyClassName?: string }) {
      return (
        <TerminalBody data-slot="terminal-typewriter" className={bodyClassName}>
          <Typewriter className={className} {...props} />
        </TerminalBody>
      );
    }
    
    export { Typewriter, TerminalTypewriter, type TypewriterLine };
  2. Update the import paths to match your project

    The source imports cn from @/lib/utils and uses terminal.

Usage#

import { Typewriter, TerminalTypewriter } from "@/components/ui/typewriter";
<Terminal>
  <TerminalHeader>LANTERN</TerminalHeader>
  <TerminalTypewriter
    loop
    lines={[
      { prompt: true, text: "lantern publish ./site" },
      { text: "published hub://your-little-world/", delay: 800 },
    ]}
  />
</Terminal>

Each line takes prompt, text, delay (milliseconds before it starts) and instant. Command lines (prompt: true) are typed at speed milliseconds per character; output lines print whole unless instant is false.

Visitors who prefer reduced motion see every line at once. Screen readers get the full text immediately from a visually hidden copy; the animated text is hidden from them.

The terminal grows as lines appear. Give the body a min height (bodyClassName on TerminalTypewriter, className on Typewriter) to keep the layout still.

Examples#

Standalone

Turtle log

turtle status 07

fuel 812 / 1000 position -144 64 302

turtle mine --depth 12

mining... 12 blocks cleared