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.
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/
import { Diamond } from "lucide-react";
import { Terminal, TerminalAddress, TerminalFooter, TerminalHeader } from "@/components/ui/terminal";
import { TerminalTypewriter } from "@/components/ui/typewriter";
const session = [
{ prompt: true, text: "npx lanterncn add terminal" },
{ text: "Checking registry... done", delay: 500 },
{ text: "Installing dependencies... done", delay: 700 },
{ text: "Created 1 file:\n components/ui/terminal.tsx", delay: 400 },
{ prompt: true, text: "lantern publish ./site", delay: 900 },
{ text: "published hub://your-little-world/", delay: 800 },
];
export default function TypewriterDemo() {
return (
<Terminal tilt className="w-full max-w-md">
<TerminalHeader>
<Diamond /> LANTERN
</TerminalHeader>
<TerminalAddress>~/my-little-world</TerminalAddress>
<TerminalTypewriter lines={session} loop bodyClassName="min-h-[260px] text-[12px] sm:text-[13px]" />
<TerminalFooter>
<span>Connected</span>
<span>Loops</span>
</TerminalFooter>
</Terminal>
);
}Installation#
With the CLI
$npx lanterncn add typewriterThis 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
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 };Update the import paths to match your project
The source imports
cnfrom@/lib/utilsand 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 status 07
fuel 812 / 1000 position -144 64 302
turtle mine --depth 12
mining... 12 blocks cleared
import { Typewriter } from "@/components/ui/typewriter";
export default function TypewriterStandalone() {
return (
<div className="w-full max-w-md rounded-lg border bg-card p-5">
<div className="mb-3 font-mono text-[10px] tracking-[0.2em] text-success uppercase">Turtle log</div>
<Typewriter
speed={35}
loop
loopDelay={3000}
className="min-h-[140px] text-xs"
lines={[
{ prompt: true, text: "turtle status 07" },
{ text: "fuel 812 / 1000\nposition -144 64 302" },
{ prompt: true, text: "turtle mine --depth 12", delay: 1200 },
{ text: "mining... 12 blocks cleared", delay: 900 },
]}
/>
</div>
);
}