Velora UI
All components

Typewriter

Types, holds and deletes through a list of words.

1.0 KB gzippedDeps: motion/reactReduced-motion safeBase UI & Radix compatible

Build it faster.

Installation

npx shadcn@latest add https://velora.colorlib.com/r/typewriter.json

Installs with: motion

Source

"use client";

import { useEffect, useState } from "react";
import { useReducedMotion } from "motion/react";

import { cn } from "@/lib/utils";

interface TypewriterProps extends React.HTMLAttributes<HTMLSpanElement> {
  words: string[];
  /** ms per typed character */
  typeSpeed?: number;
  /** ms per deleted character */
  deleteSpeed?: number;
  /** ms to hold a completed word */
  holdTime?: number;
  loop?: boolean;
  cursor?: boolean;
}

export function Typewriter({
  words,
  typeSpeed = 70,
  deleteSpeed = 40,
  holdTime = 1800,
  loop = true,
  cursor = true,
  className,
  ...props
}: TypewriterProps) {
  const reducedMotion = useReducedMotion();
  const [wordIndex, setWordIndex] = useState(0);
  const [text, setText] = useState("");
  const [deleting, setDeleting] = useState(false);

  useEffect(() => {
    if (reducedMotion || words.length === 0) return;
    const word = words[wordIndex % words.length];

    let timeout: ReturnType<typeof setTimeout>;
    if (!deleting && text.length < word.length) {
      timeout = setTimeout(() => setText(word.slice(0, text.length + 1)), typeSpeed);
    } else if (!deleting && text.length === word.length) {
      const isLast = wordIndex === words.length - 1;
      if (loop || !isLast) {
        timeout = setTimeout(() => setDeleting(true), holdTime);
      }
    } else if (deleting && text.length > 0) {
      timeout = setTimeout(() => setText(word.slice(0, text.length - 1)), deleteSpeed);
    } else if (deleting) {
      timeout = setTimeout(() => {
        setDeleting(false);
        setWordIndex((i) => (i + 1) % words.length);
      }, deleteSpeed);
    }
    return () => clearTimeout(timeout);
  }, [text, deleting, wordIndex, words, typeSpeed, deleteSpeed, holdTime, loop, reducedMotion]);

  if (reducedMotion) {
    return (
      <span className={cn(className)} {...props}>
        {words[0]}
      </span>
    );
  }

  const longest = words.reduce((a, b) => (b.length > a.length ? b : a), "");

  // Both spans share one grid cell: the invisible longest word reserves
  // the final size, so typing never shifts surrounding layout.
  return (
    <span
      data-slot="typewriter"
      className={cn("inline-grid text-left", className)}
      {...props}
    >
      <span className="sr-only">{words[0]}</span>
      <span aria-hidden className="invisible col-start-1 row-start-1">
        {longest}
        {cursor && <span className="font-light">|</span>}
      </span>
      <span aria-hidden className="col-start-1 row-start-1">
        {text}
        {cursor && (
          <span className="animate-pulse font-light">|</span>
        )}
      </span>
    </span>
  );
}