DesignPass.dev

TextType

// preview

Text
Typing speed (ms/char)
Deleting speed (ms/char)
Hold before delete (ms)
Humanize
Loop
Cursor

// source

TSJS
TailwindCSS
/*!
 * TextType, a DesignPass.dev component by Ernest Liu (ernestliu.com)
 * Docs & live playground: https://designpass.dev/components/text-type
 * MIT licensed, keep this notice in copies and adaptations.
 */
"use client";

import React, { useEffect, useMemo, useRef, type CSSProperties } from "react";

export interface TextTypeProps {
  /** One sentence or a cycle of sentences. */
  text: string | string[];
  /** Ms per typed character. */
  typingSpeed?: number;
  /** Ms per deleted character. */
  deletingSpeed?: number;
  /** Ms the finished sentence holds before deleting. */
  pauseDuration?: number;
  /** Ms before the first keystroke. */
  initialDelay?: number;
  /** Cycle through the sentences forever; off types the last one and stops. */
  loop?: boolean;
  /** 0-1: random per-keystroke timing jitter, so typing reads as human. */
  humanize?: number;
  showCursor?: boolean;
  cursorCharacter?: string;
  /** Fired each time a sentence finishes typing. */
  onSentenceComplete?: (sentence: string, index: number) => void;
  className?: string;
  cursorClassName?: string;
  style?: CSSProperties;
}

/**
 * A typewriter that types, holds, deletes, and cycles through sentences.
 * Characters land straight into the DOM via a ref (no React re-render per
 * keystroke), timing is humanized with jitter and punctuation pauses, and
 * the cursor only blinks while the typist is idle, like a real caret.
 * Zero dependencies, honors prefers-reduced-motion.
 */
export default function TextType({
  text,
  typingSpeed = 65,
  deletingSpeed = 32,
  pauseDuration = 2000,
  initialDelay = 250,
  loop = true,
  humanize = 0.4,
  showCursor = true,
  cursorCharacter = "|",
  onSentenceComplete,
  className = "",
  cursorClassName = "",
  style,
}: TextTypeProps) {
  const textRef = useRef<HTMLSpanElement>(null);
  const cursorRef = useRef<HTMLSpanElement>(null);
  const onSentenceCompleteRef = useRef(onSentenceComplete);
  onSentenceCompleteRef.current = onSentenceComplete;

  const sentences = useMemo(() => (Array.isArray(text) ? text : [text]), [text]);

  useEffect(() => {
    const target = textRef.current;
    if (!target || sentences.length === 0) return;

    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      target.textContent = sentences[0];
      return;
    }

    let timer: ReturnType<typeof setTimeout>;
    let blink: Animation | null = null;
    let idleTimer: ReturnType<typeof setTimeout>;
    let cancelled = false;

    // The caret stays solid while keystrokes are landing and starts
    // blinking only after a short idle beat, like a real text editor.
    const restBlink = () => {
      blink?.cancel();
      blink = null;
      clearTimeout(idleTimer);
      const cursor = cursorRef.current;
      if (!cursor) return;
      cursor.style.opacity = "1";
      idleTimer = setTimeout(() => {
        blink = cursor.animate([{ opacity: 1 }, { opacity: 0 }], {
          duration: 1000,
          iterations: Infinity,
          easing: "steps(2, jump-none)",
        });
      }, 220);
    };

    const jitter = (base: number) =>
      base * (1 + (Math.random() * 2 - 1) * Math.min(Math.max(humanize, 0), 1));

    const schedule = (fn: () => void, ms: number) => {
      timer = setTimeout(() => {
        if (!cancelled) fn();
      }, ms);
    };

    let sentenceIndex = 0;
    let charIndex = 0;

    const typeNext = () => {
      const sentence = [...sentences[sentenceIndex]];
      if (charIndex < sentence.length) {
        charIndex += 1;
        target.textContent = sentence.slice(0, charIndex).join("");
        restBlink();
        const char = sentence[charIndex - 1];
        // Humans hesitate after punctuation and word boundaries.
        const pause = /[.,!?;:]/.test(char) ? 3.2 : char === " " ? 1.6 : 1;
        schedule(typeNext, jitter(typingSpeed) * pause);
        return;
      }

      onSentenceCompleteRef.current?.(sentences[sentenceIndex], sentenceIndex);
      const isLast = sentenceIndex === sentences.length - 1;
      if (isLast && !loop) return;
      schedule(deleteNext, pauseDuration);
    };

    const deleteNext = () => {
      const sentence = [...sentences[sentenceIndex]];
      if (charIndex > 0) {
        charIndex -= 1;
        target.textContent = sentence.slice(0, charIndex).join("");
        restBlink();
        schedule(deleteNext, jitter(deletingSpeed));
        return;
      }
      sentenceIndex = (sentenceIndex + 1) % sentences.length;
      schedule(typeNext, jitter(typingSpeed) * 3);
    };

    target.textContent = "";
    restBlink();
    schedule(typeNext, initialDelay);

    return () => {
      cancelled = true;
      clearTimeout(timer);
      clearTimeout(idleTimer);
      blink?.cancel();
    };
  }, [sentences, typingSpeed, deletingSpeed, pauseDuration, initialDelay, loop, humanize]);

  return (
    <span aria-label={sentences[0]} role="text" className={`inline-block ${className}`} style={style}>
      <span ref={textRef} aria-hidden="true" className="whitespace-pre-wrap" />
      {showCursor ? (
        <span ref={cursorRef} aria-hidden="true" className={`inline-block ${cursorClassName}`}>
          {cursorCharacter}
        </span>
      ) : null}
    </span>
  );
}

// install

Install the TextType component from DesignPass into this project by running:

npx shadcn@latest add "https://designpass.dev/r/TextType-TS-TW.json"

If the project has no components.json yet, run `npx shadcn@latest init` first.
Then show me a minimal usage example.

Need the license details? Read the component license.

// updates

Know when new components drop

A short email when something new lands in the library. No noise, unsubscribe anytime.