bottega

effects

Typewriter Text

A phrase-cycling typewriter that types each character in, holds, then erases at ~2× speed before cycling. Differentiators: a thin 2px caret that fades on a non-linear ease (OLED-cursor feel, not an 80s blink) and an optional static 'anchor' prefix so hero headings stay compositionally stable while only the suffix animates. Accessible (role=text + polite live region), hydration-safe, and reduced-motion aware (hard instant reveal).

We build fast sites, bold brands, real systems

Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/typewriter-text

Usage

Usagetsx
<TypewriterText phrases={[]} />

Props

PropTypeDefaultDescription
phrases*string[]Ordered list of full strings to cycle through. Required, min 1 item.
anchorstringOptional static prefix rendered before the animated suffix. When provided, each entry in `phrases` is treated as the suffix only.
typingSpeednumber55Milliseconds per character during type-in. Default 55.
deletingSpeednumber28Milliseconds per character during erase. Default 28 (~2× faster).
pauseAfterTypenumber1800Ms to hold the completed phrase before erasing. Default 1800.
pauseAfterDeletenumber400Ms to hold the empty caret before typing the next phrase. Default 400.
loopbooleantrueWhether to cycle indefinitely. Default true. When false, stops after the last phrase is fully typed (caret remains, blinking).
caretColorstringCSS color overriding the caret. Default: var(--primary).
classNamestringAdditional Tailwind classes applied to the root <span>.
aria-labelstringFull accessible label. When omitted, a comma-joined list of all phrases (prefixed by `anchor` where set) is computed as the accessible name.

Source

typewriter-text.tsxtsx
"use client";

import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
import styles from "./typewriter-text.module.css";

export interface TypewriterTextProps {
  /** Ordered list of full strings to cycle through. Required, min 1 item. */
  phrases: string[];
  /**
   * Optional static prefix rendered before the animated suffix. When provided,
   * each entry in `phrases` is treated as the suffix only.
   */
  anchor?: string;
  /** Milliseconds per character during type-in. Default 55. */
  typingSpeed?: number;
  /** Milliseconds per character during erase. Default 28 (~2× faster). */
  deletingSpeed?: number;
  /** Ms to hold the completed phrase before erasing. Default 1800. */
  pauseAfterType?: number;
  /** Ms to hold the empty caret before typing the next phrase. Default 400. */
  pauseAfterDelete?: number;
  /**
   * Whether to cycle indefinitely. Default true. When false, stops after the
   * last phrase is fully typed (caret remains, blinking).
   */
  loop?: boolean;
  /** CSS color overriding the caret. Default: var(--primary). */
  caretColor?: string;
  /** Additional Tailwind classes applied to the root <span>. */
  className?: string;
  /**
   * Full accessible label. When omitted, a comma-joined list of all phrases
   * (prefixed by `anchor` where set) is computed as the accessible name.
   */
  "aria-label"?: string;
}

type Phase = "typing" | "paused-full" | "deleting" | "paused-empty";

export function TypewriterText({
  phrases,
  anchor,
  typingSpeed = 55,
  deletingSpeed = 28,
  pauseAfterType = 1800,
  pauseAfterDelete = 400,
  loop = true,
  caretColor,
  className = "",
  "aria-label": ariaLabel,
}: TypewriterTextProps) {
  // Mount-gate so SSR HTML === client first render (avoids React #418).
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);

  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
  const animate = mounted && !reduce;

  // Accessible name: user-supplied, else comma-joined full phrases.
  const fullPhrases = phrases.map((p) => (anchor ? `${anchor}${p}` : p));
  const computedLabel = ariaLabel ?? fullPhrases.join(", ");

  // Static fallback text used for SSR / first paint / reduced-motion. Renders
  // the whole set as a comma list when looping over several phrases, else the
  // first phrase in full — a hard instant reveal, never a slowed animation.
  const staticSuffix =
    loop && phrases.length > 1 ? phrases.join(", ") : (phrases[0] ?? "");

  // Animation state. Initialise to the first character of the first phrase so
  // there is no pre-mount empty flash when animation kicks in post-hydration.
  const [phraseIndex, setPhraseIndex] = useState(0);
  const [text, setText] = useState(() => (phrases[0] ? phrases[0].slice(0, 1) : ""));
  const [phase, setPhase] = useState<Phase>("typing");

  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    if (!animate) return;

    const current = phrases[phraseIndex] ?? "";

    const clear = () => {
      if (timer.current) clearTimeout(timer.current);
    };

    if (phase === "typing") {
      if (text.length < current.length) {
        // ±15ms organic jitter on type-in only (Math.random is post-mount,
        // inside an effect — never runs during SSR/first paint).
        const jitter = Math.random() * 30 - 15;
        const delay = Math.max(0, typingSpeed + jitter);
        timer.current = setTimeout(() => {
          setText(current.slice(0, text.length + 1));
        }, delay);
      } else {
        const isLast = phraseIndex === phrases.length - 1;
        if (!loop && isLast) return; // complete: caret blinks, no transition
        timer.current = setTimeout(() => setPhase("paused-full"), pauseAfterType);
      }
      return clear;
    }

    if (phase === "paused-full") {
      timer.current = setTimeout(() => setPhase("deleting"), 0);
      return clear;
    }

    if (phase === "deleting") {
      if (text.length > 0) {
        // Erase is mechanically uniform — no jitter, a confident backspace.
        timer.current = setTimeout(() => {
          setText(current.slice(0, text.length - 1));
        }, deletingSpeed);
      } else {
        timer.current = setTimeout(() => setPhase("paused-empty"), pauseAfterDelete);
      }
      return clear;
    }

    // paused-empty: advance to next phrase and resume typing.
    setPhraseIndex((i) => (i + 1) % phrases.length);
    setPhase("typing");
    return clear;
  }, [
    animate,
    phase,
    text,
    phraseIndex,
    phrases,
    loop,
    typingSpeed,
    deletingSpeed,
    pauseAfterType,
    pauseAfterDelete,
  ]);

  // Caret is solid (frozen) while characters are moving; blinks while paused.
  // The blink class is only added in the animated path — the static/reduced/
  // first-paint base renders the motionless `styles.caret` alone.
  const caretSolid = phase === "typing" || phase === "deleting";
  const caretClass = [
    styles.caret,
    styles.caretBlink,
    caretSolid ? styles.caretSolid : "",
  ]
    .filter(Boolean)
    .join(" ");

  const suffix = animate ? text : staticSuffix;
  // Live-region surfaces the complete phrase only (updates fire on completion,
  // not per character) — for animated mode that's the full current phrase.
  const liveText = animate ? (phrases[phraseIndex] ?? "") : staticSuffix;

  return (
    <span
      role="text"
      aria-label={computedLabel}
      className={["inline-flex items-baseline text-foreground", className]
        .filter(Boolean)
        .join(" ")}
    >
      {/* Visually-hidden live region: announces completed phrases politely. */}
      <span className="sr-only" aria-live="polite" aria-atomic="true">
        {anchor ? `${anchor}${liveText}` : liveText}
      </span>

      {/* Visual layer is hidden from AT — the label + live region carry meaning. */}
      <span aria-hidden="true">
        {anchor ? <span className="font-[inherit]">{anchor}</span> : null}
        <span>{suffix}</span>
        <span
          className={animate ? caretClass : styles.caret}
          style={
            caretColor
              ? ({ "--caret-color": caretColor } as React.CSSProperties)
              : undefined
          }
        />
      </span>
    </span>
  );
}

Dependencies

  • motion
  • @bottega/tokens