Skip to content

Typewriter

Types, deletes and cycles phrases with a blinking caret.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/typewriter.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/typewriter/typewriter.tsx
    "use client";
    
    import { useEffect, useRef, useState, type ComponentProps } from "react";
    import { motion, useInView, useReducedMotion } from "motion/react";
    import { cn } from "@/lib/utils";
    import { duration } from "@/lib/motion";
    
    export interface TypewriterProps extends Omit<ComponentProps<"span">, "children"> {
      /** Phrases to type, in order. */
      phrases: string[];
      /** Milliseconds per typed character; each keystroke varies a little so it feels human. @default 60 */
      typeSpeed?: number;
      /** Milliseconds per deleted character. @default 30 */
      deleteSpeed?: number;
      /** Milliseconds a finished phrase stays before it's deleted. @default 1600 */
      pause?: number;
      /** Milliseconds before the first keystroke. @default 400 */
      startDelay?: number;
      /** Cycle forever. When false, typing stops once the last phrase is typed. @default true */
      loop?: boolean;
      /** Stop typing. Typing also pauses on its own while offscreen. @default false */
      paused?: boolean;
      /** Show a blinking caret. @default true */
      caret?: boolean;
      /** Reserve the width of the longest phrase so surrounding text never shifts. @default false */
      reserveSpace?: boolean;
      /** Called each time a phrase has been fully typed. */
      onPhraseTyped?: (index: number) => void;
    }
    
    type Phase = "typing" | "holding" | "deleting" | "done";
    
    function graphemes(value: string) {
      if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
        return Array.from(new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(value), (s) => s.segment);
      }
      return Array.from(value);
    }
    
    export function Typewriter({
      phrases,
      typeSpeed = 60,
      deleteSpeed = 30,
      pause = 1600,
      startDelay = 400,
      loop = true,
      paused = false,
      caret = true,
      reserveSpace = false,
      onPhraseTyped,
      className,
      ...props
    }: TypewriterProps) {
      const reduceMotion = useReducedMotion();
      const visualRef = useRef<HTMLSpanElement>(null);
      const inView = useInView(visualRef, { amount: 0.2 });
      const [state, setState] = useState<{ index: number; length: number; phase: Phase; started: boolean }>({
        index: 0,
        length: 0,
        phase: "typing",
        started: false,
      });
      const typedRef = useRef(onPhraseTyped);
      useEffect(() => {
        typedRef.current = onPhraseTyped;
      });
    
      const count = phrases.length;
      const phrase = phrases[state.index % Math.max(count, 1)] ?? "";
    
      useEffect(() => {
        if (paused || !inView || count === 0 || state.phase === "done") return;
        const { index, length, phase, started } = state;
        const chars = graphemes(phrase);
        let wait = 0;
        let next = state;
    
        if (phase === "typing") {
          const full = chars.length;
          if (reduceMotion) {
            // Whole phrases appear at once and crossfade instead of being typed.
            wait = started ? 0 : startDelay;
            next = { index, length: full, phase: "holding", started: true };
          } else {
            const previous = chars[length - 1] ?? "";
            // Keystrokes vary ±40%, with a beat after punctuation.
            wait = !started ? startDelay : typeSpeed * (0.6 + Math.random() * 0.8) + (/[,.!?;:]/.test(previous) ? typeSpeed * 3 : 0);
            next = length + 1 >= full ? { index, length: full, phase: "holding", started: true } : { index, length: length + 1, phase, started: true };
          }
          if (next.phase === "holding" && !loop && index === count - 1) next = { ...next, phase: "done" };
        } else if (phase === "holding") {
          wait = pause;
          next = reduceMotion
            ? { index: (index + 1) % count, length: 0, phase: "typing", started }
            : { index, length, phase: "deleting", started };
        } else if (phase === "deleting") {
          wait = length > 0 ? deleteSpeed : typeSpeed * 4;
          next = length > 0 ? { index, length: length - 1, phase, started } : { index: (index + 1) % count, length: 0, phase: "typing", started };
        }
    
        const timer = window.setTimeout(() => {
          setState(next);
          if (next.phase !== phase && (next.phase === "holding" || next.phase === "done")) typedRef.current?.(index);
        }, wait);
        return () => window.clearTimeout(timer);
      }, [state, phrase, count, paused, inView, reduceMotion, typeSpeed, deleteSpeed, pause, startDelay, loop]);
    
      const typed = graphemes(phrase).slice(0, state.length).join("");
      // The caret holds steady while keys are moving and blinks while waiting (never under reduced motion).
      const blinking = !reduceMotion && (state.phase === "holding" || state.phase === "done" || !state.started || paused);
    
      const caretMark = (
        <span aria-hidden="true" className="ml-[0.06em] inline-block h-[1em] w-[0.08em] min-w-[2px] rounded-[1px] align-[-0.12em]" />
      );
    
      return (
        <span className={cn("inline-grid", className)} {...props}>
          <span className="sr-only select-none">{phrases.join(", ")}</span>
          {reserveSpace
            ? phrases.map((phrase, i) => (
                <span key={i} aria-hidden="true" className="invisible col-start-1 row-start-1 select-none whitespace-pre">
                  {phrase}
                  {caret ? caretMark : null}
                </span>
              ))
            : null}
          <span ref={visualRef} aria-hidden="true" className="col-start-1 row-start-1 whitespace-pre-wrap">
            <motion.span
              key={reduceMotion ? state.index : "typed"}
              initial={reduceMotion ? { opacity: 0 } : false}
              animate={{ opacity: 1 }}
              transition={{ duration: duration.base }}
            >
              {typed}
            </motion.span>
            {caret ? (
              <motion.span
                key={blinking ? "blink" : "solid"}
                className="ml-[0.06em] inline-block h-[1em] w-[0.08em] min-w-[2px] rounded-[1px] bg-current align-[-0.12em]"
                initial={{ opacity: 1 }}
                animate={blinking ? { opacity: [1, 1, 0, 0] } : { opacity: 1 }}
                transition={blinking ? { duration: 1.05, times: [0, 0.5, 0.5, 1], repeat: Infinity, ease: "linear" } : { duration: 0 }}
              />
            ) : null}
          </span>
        </span>
      );
    }

Usage

import { Typewriter } from "@/components/ui/typewriter/typewriter";

export function Hero() {
  return (
    <h1 className="text-4xl font-semibold">
      Interfaces that feel <Typewriter phrases={["alive", "effortless", "inevitable"]} />
    </h1>
  );
}

API reference

PropTypeDefaultDescription
phrases*string[]–Phrases to type, in order.
typeSpeednumber60Milliseconds per typed character; each keystroke varies a little so it feels human.
deleteSpeednumber30Milliseconds per deleted character.
pausenumber1600Milliseconds a finished phrase stays before it's deleted.
startDelaynumber400Milliseconds before the first keystroke.
loopbooleantrueCycle forever. When false, typing stops once the last phrase is typed.
pausedbooleanfalseStop typing. Typing also pauses on its own while offscreen.
caretbooleantrueShow a blinking caret.
reserveSpacebooleanfalseReserve the width of the longest phrase so surrounding text never shifts.
onPhraseTyped(index: number) => void–Called each time a phrase has been fully typed.

Also accepts every native <span> attribute, including ref and className.

  • Keystrokes vary by ±40% and pause briefly after punctuation, so typing reads as human rather than mechanical.
  • Use `reserveSpace` when the typewriter sits mid-sentence: the line keeps the width of the longest phrase and the words after it never shift.

Accessibility

  • Screen readers read every phrase once, from a visually hidden copy; the half-typed text and caret are aria-hidden, so nothing is announced keystroke by keystroke.
  • Typing pauses while the text is offscreen, and `paused` stops it entirely if you need a pause control for long-running loops.
  • With reduced motion enabled, whole phrases crossfade instead of being typed and the caret stops blinking.