Skip to content
UI Kit
Accent color

Typewriter

Types, holds and deletes with a caret, over one or several lines. The space is reserved, so nothing around it moves.

One of 5 components in Text effects

3 variants

Variants

Hero loop

The last words of a headline take turns, typed and deleted in a loop.

Prompt on scroll

A long sentence typed by the scroll position, wrapping naturally, with a block caret. Scroll back and it deletes.

Command

Commands in a terminal, each typed and replaced by the next, with an underscore caret.

Details

What Typewriter brings along, read from the registry and the source.

Category
Text effects
Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/typewriter.tsx
Brings along
utilskit
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The TypewriterProps interface from components/ui/typewriter.tsx, with the comments from the code.

  • linesrequired
    string[]

    One or more lines. Each is typed, held, deleted, then the next one follows.

  • asoptional
    Tag

    h1h2h3h4pspandiv

    Element to render. Default span, so it sits inside a heading.

  • loopoptional
    boolean

    Start over after the last line. Default true with more than one line.

  • typeSpeedoptional
    number

    Milliseconds per typed character, varied a little like a hand. Default 60.

  • deleteSpeedoptional
    number

    Milliseconds per deleted character. Default 28.

  • holdoptional
    number

    Milliseconds a finished line stays. Default 1900.

  • startDelayoptional
    number

    Milliseconds before the first key. Default 350.

  • caretoptional
    TypewriterCaret

    barblockunderscorenone

    Caret shape. Default bar, in the accent color (set --kitfx-tw-caret to change it).

  • triggeroptional
    'load' | 'view' | 'in-view' | 'scroll'

    load: types right away. view (or in-view): once it scrolls in (default). scroll: the first line is typed by the scroll position, it types on the way into the window and deletes again when you scroll back.

  • scrollRangeoptional
    [number, number]

    With trigger scroll: the part of the way through the window that types the line, 0 to 1. Default [0.05, 0.55].

  • labeloptional
    string

    What screen readers hear. Default: the lines joined with commas (one line: that line).

Accessibility

Read from the source of the component.

  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 228 lines
components/ui/typewriter.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion } from '@/lib/kit';

export type TypewriterCaret = 'bar' | 'block' | 'underscore' | 'none';
type Tag = 'h1' | 'h2' | 'h3' | 'h4' | 'p' | 'span' | 'div';

export interface TypewriterProps {
  /** One or more lines. Each is typed, held, deleted, then the next one follows. */
  lines: string[];
  /** Element to render. Default span, so it sits inside a heading. */
  as?: Tag;
  /** Start over after the last line. Default true with more than one line. */
  loop?: boolean;
  /** Milliseconds per typed character, varied a little like a hand. Default 60. */
  typeSpeed?: number;
  /** Milliseconds per deleted character. Default 28. */
  deleteSpeed?: number;
  /** Milliseconds a finished line stays. Default 1900. */
  hold?: number;
  /** Milliseconds before the first key. Default 350. */
  startDelay?: number;
  /** Caret shape. Default bar, in the accent color (set --kitfx-tw-caret to change it). */
  caret?: TypewriterCaret;
  /**
   * load: types right away. view (or in-view): once it scrolls in (default).
   * scroll: the first line is typed by the scroll position, it types on the
   * way into the window and deletes again when you scroll back.
   */
  trigger?: 'load' | 'view' | 'in-view' | 'scroll';
  /** With trigger scroll: the part of the way through the window that types the line, 0 to 1. Default [0.05, 0.55]. */
  scrollRange?: [number, number];
  /** What screen readers hear. Default: the lines joined with commas (one line: that line). */
  label?: string;
  className?: string;
}

/*
 * Space is reserved by all lines stacked invisibly in one grid cell, so the
 * surrounding text never moves. The visible line always contains its full
 * text, the part not typed yet is only invisible: a word therefore never
 * jumps to the next line while it is being typed.
 * Before hydration the visible line is hidden (only with scripting on and
 * motion allowed, and at most 2.5 s), so the full line does not flash and
 * then disappear; without JavaScript the line is simply there.
 */
const CSS = `
.kitfx-tw{display:inline-grid;max-width:100%}
.kitfx-tw>*{grid-area:1/1}
.kitfx-tw-ghost{visibility:hidden}
.kitfx-tw-rest{visibility:hidden}
.kitfx-tw-caret{position:relative}
.kitfx-tw-caret::after{content:"";position:absolute;left:.04em;top:.11em;height:.98em;width:.07em;border-radius:1px;background:var(--kitfx-tw-caret,color-mix(in oklch,var(--primary) 75%,var(--foreground)));animation:kitfx-tw-blink 1.06s steps(1) infinite}
.kitfx-tw[data-caret=block] .kitfx-tw-caret::after{width:.52em;left:.03em;opacity:.85}
.kitfx-tw[data-caret=underscore] .kitfx-tw-caret::after{top:.99em;height:.08em;width:.52em}
.kitfx-tw[data-typing] .kitfx-tw-caret::after{animation:none}
.kitfx-tw-caret[data-off]::after{opacity:0;transition:opacity .4s}
@keyframes kitfx-tw-blink{50%{opacity:0}}
@keyframes kitfx-tw-hold{from{visibility:hidden}}
@media (scripting:enabled) and (prefers-reduced-motion:no-preference){.kitfx-tw[data-pending] .kitfx-tw-line{animation:kitfx-tw-hold 0s 2.5s both}}
@media (prefers-reduced-motion:reduce){.kitfx-tw-caret::after{display:none}}`;

type Phase = 'typing' | 'holding' | 'deleting' | 'done';

/**
 * Scroll link. Writes --kitfx-p (0 to 1) on the element: 0 when it has
 * travelled `start` of its way through the window, 1 at `end` (0 is its top
 * at the bottom edge, 1 its bottom at the top edge). The value is scaled to
 * the part of that way the page can still scroll, so a line at the end of a
 * short page still finishes. Never with reduced motion, then the CSS
 * fallback 1 keeps the final state.
 */
function useScrollLink(ref: { current: HTMLElement | null }, on: boolean, range: readonly [number, number], onChange?: (p: number) => void) {
  const [start, end] = range;
  const listener = useRef(onChange);
  listener.current = onChange;
  useEffect(() => {
    const el = ref.current;
    if (!el || !on || prefersReducedMotion()) return;
    let frame = 0;
    const span = Math.max(0.0001, end - start);
    const at = (top: number, h: number, vh: number) => Math.min(1, Math.max(0, ((vh - top) / (h + vh) - start) / span));
    const update = () => {
      frame = 0;
      const r = el.getBoundingClientRect();
      const vh = window.innerHeight;
      const rest = Math.max(0, document.documentElement.scrollHeight - vh - window.scrollY);
      const most = at(r.top - rest, r.height, vh);
      const p = most < 0.02 ? 1 : Math.min(1, at(r.top, r.height, vh) / most);
      el.style.setProperty('--kitfx-p', p.toFixed(4));
      listener.current?.(p);
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      el.style.removeProperty('--kitfx-p');
    };
  }, [ref, on, start, end]);
}


/** A stable pseudo random number in [0, 1) for a position, so typing rhythm is the same on every visit. */
const jitter = (n: number) => {
  const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
  return x - Math.floor(x);
};

/**
 * Types a line letter by letter, deletes it and types the next. The caret
 * blinks while it waits and stays solid while it types. Pauses off screen.
 * Screen readers get the label once, never the letters. Without JavaScript
 * and with reduced motion the (last, or with loop the first) line stands
 * complete without a caret.
 *   <h1>Websites for <Typewriter lines={['law firms', 'cafés', 'studios']} /></h1>
 */
export function Typewriter({ lines, as: Tag = 'span', loop, typeSpeed = 60, deleteSpeed = 28, hold = 1900, startDelay = 350, caret = 'bar', trigger = 'view', scrollRange = [0.05, 0.55], label, className }: TypewriterProps) {
  const many = lines.length > 1;
  const repeat = loop ?? many;
  const finalLine = repeat || trigger === 'scroll' ? 0 : lines.length - 1;
  const ref = useRef<HTMLElement>(null);
  const [line, setLine] = useState(finalLine);
  const [count, setCount] = useState(lines[finalLine]?.length ?? 0);
  const [phase, setPhase] = useState<Phase>('done');
  const [pending, setPending] = useState(true);
  const [active, setActive] = useState(false);
  const [onScreen, setOnScreen] = useState(true);
  const scroll = trigger === 'scroll';
  // Scroll: the position types the first line, the caret stays until it is complete
  useScrollLink(ref, scroll && lines.length > 0, scrollRange, (p) => {
    const full = lines[0]?.length ?? 0;
    setLine(0);
    setCount(p >= 1 ? full : Math.round(p * full));
    setPhase(p >= 1 ? 'done' : 'typing');
  });

  // Start: reset to an empty first line and wait for the screen
  useEffect(() => {
    setPending(false);
    if (prefersReducedMotion() || !lines.length || scroll) return;
    setLine(0);
    setCount(0);
    setPhase('typing');
    const el = ref.current;
    if (!el) return;
    let started = trigger === 'load';
    if (started) setActive(true);
    const io = new IntersectionObserver(
      ([e]) => {
        const on = !!e?.isIntersecting;
        setOnScreen(on);
        if (on && !started) {
          started = true;
          setActive(true);
        }
      },
      { rootMargin: '0px 0px -10% 0px' },
    );
    io.observe(el);
    return () => io.disconnect();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [lines.join('\n'), trigger, scroll]);

  // The clock: one step at a time, only while active and on screen
  useEffect(() => {
    if (!active || !onScreen || phase === 'done') return;
    const text = lines[line] ?? '';
    let wait = 0;
    let step: () => void;
    if (phase === 'typing') {
      if (count >= text.length) {
        const last = line === lines.length - 1;
        wait = 0;
        step = () => setPhase(last && !repeat ? 'done' : 'holding');
      } else {
        const ch = text[count] ?? '';
        const pause = /[,.;!?]/.test(text[count - 1] ?? '') ? 3.5 : ch === ' ' ? 1.6 : 1;
        wait = (count === 0 && line === 0 ? startDelay : 0) + typeSpeed * pause * (0.65 + jitter(line * 97 + count) * 0.7);
        step = () => setCount((c) => c + 1);
      }
    } else if (phase === 'holding') {
      wait = hold;
      step = () => setPhase('deleting');
    } else {
      if (count <= 0) {
        wait = 320;
        step = () => {
          setLine((i) => (i + 1) % lines.length);
          setPhase('typing');
        };
      } else {
        wait = deleteSpeed;
        step = () => setCount((c) => c - 1);
      }
    }
    const timer = window.setTimeout(step, wait);
    return () => window.clearTimeout(timer);
  }, [active, onScreen, phase, count, line, lines, repeat, typeSpeed, deleteSpeed, hold, startDelay]);

  const text = lines[line] ?? '';
  const typing = (active || scroll) && (phase === 'typing' || phase === 'deleting');
  const showCaret = caret !== 'none' && phase !== 'done' && !pending;
  const name = label ?? (many && !scroll ? lines.join(', ') : (lines[0] ?? ''));

  return (
    <Tag ref={ref as never} className={cn('kitfx-tw', className)} data-caret={caret} data-typing={typing ? '' : undefined} data-pending={pending ? '' : undefined}>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <span className="sr-only">{name}</span>
      {lines.map((l, i) => (
        <span key={i} aria-hidden className="kitfx-tw-ghost">
          {l}
        </span>
      ))}
      <span aria-hidden className="kitfx-tw-line">
        {text.slice(0, count)}
        {showCaret && <span className="kitfx-tw-caret" />}
        <span className="kitfx-tw-rest">{text.slice(count)}</span>
      </span>
    </Tag>
  );
}

Frequent questions

How do I install Typewriter?

With aiw add @ai-website/typewriter. The command puts components/ui/typewriter.tsx into your project, the code is yours from then on. It needs no further npm packages.

Which variants does Typewriter have?

3 variants, Hero loop, Prompt on scroll and Command.

Can an AI assistant build with Typewriter?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Typewriter with aiw_components and install it with aiw_add @ai-website/typewriter.

Often searched

Jump to

Copied to the clipboard