TypeWriter

TypeWriter is a component that simulates typing and deleting text, creating a typewriter effect.

Hi! I'm a 
Hi! I'm a 
Hi! I'm a 

Installation

Install Dependencies

npm i clsx tailwind-merge framer-motion

Create @/utils/cn.ts file

import clsx, { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Typewriter Component


"use client";
import { cva, type VariantProps } from "class-variance-authority";
import React, { useEffect, useState } from "react";

const typewriterVariants = cva("relative", {
  variants: {
    variant: {
      default: "",
      thick: "",
      nocaret: "after:hidden"
    }
  },
  defaultVariants: {
    variant: "default"
  }
});

export interface TypeWriterProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof typewriterVariants> {
  words: string[];
  typeSpeed?: number;
  deleteSpeed?: number;
  delayBetweenWords?: number;
}

const TypeWriter: React.FC<TypeWriterProps> = ({
  words,
  typeSpeed = 150,
  deleteSpeed = 100,
  delayBetweenWords = 1000,
  className,
  variant,
  ...props
}) => {
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);

  useEffect(() => {
    const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
    setPrefersReducedMotion(mql.matches);

    const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
    mql.addEventListener("change", handler);
    return () => mql.removeEventListener("change", handler);
  }, []);

  const animationCSS = prefersReducedMotion
    ? words
        .map(
          (word, index) => `
        ${index * 25}%, ${(index + 1) * 25}% { content: "${word}"; }
      `
        )
        .join("\n")
    : words
        .map((word, i) => {
          const previousWords = words.slice(0, i);
          const start = previousWords.reduce((acc, w) => acc + w.length * typeSpeed + w.length * deleteSpeed + delayBetweenWords, 0);
          const typing = word
            .split("")
            .map((_, charIndex) => {
              const charStart = start + charIndex * typeSpeed;
              const percentage =
                (charStart / words.reduce((acc, w) => acc + w.length * typeSpeed + w.length * deleteSpeed + delayBetweenWords, 0)) * 100;
              return `${percentage}% { content: "${word.slice(0, charIndex + 1)}"; }`;
            })
            .join("\n");
          const fullWord = start + word.length * typeSpeed;
          const deleteStart = fullWord + delayBetweenWords;
          const deleting = word
            .split("")
            .map((_, charIndex) => {
              const charStart = deleteStart + charIndex * deleteSpeed;
              const percentage =
                (charStart / words.reduce((acc, w) => acc + w.length * typeSpeed + w.length * deleteSpeed + delayBetweenWords, 0)) * 100;
              return `${percentage}% { content: "${word.slice(0, word.length - charIndex - 1)}"; }`;
            })
            .join("\n");
          return `${typing}\n${deleting}`;
        })
        .join("\n");

  const totalDuration = prefersReducedMotion
    ? words.length * 3000
    : words.reduce((acc, w) => acc + w.length * typeSpeed + w.length * deleteSpeed + delayBetweenWords, 0);

  return (
    <>
      <style jsx>{`
        @keyframes typing {
          ${animationCSS}
        }
        @keyframes blink {
          0%,
          100% {
            border-color: transparent;
          }
          50% {
            border-color: currentColor;
          }
        }
      `}</style>
      <span
        className={typewriterVariants({ variant, className })}
        style={
          {
            "--totalDuration": `${totalDuration}ms`
          } as React.CSSProperties
        }
        {...props}
      >
        <span
          className={`before:animate-[typing_var(--totalDuration)_steps(1,end)_infinite] after:ml-[1px] after:animate-[blink_0.7s_steps(1,end)_infinite] after:border-r after:border-current after:content-[''] ${variant === "thick" ? "after:border-r-[1ch]" : ""} ${variant === "nocaret" ? "after:hidden" : ""} `}
        ></span>
      </span>
    </>
  );
};

export default TypeWriter;

Examples

Default

Hi! I'm a 

Thick

Hi! I'm a 

No Caret

Hi! I'm a