# Typewriter

> Framework-agnostic typewriter effect with multi-segment support, per-segment styling, viewport triggers, loops, and custom cursors

A shadcn/ui block from the [@elements](https://www.tryelements.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/elements/typewriter).

- Page, with a live preview: https://shadcn.exchange/r/elements/typewriter
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/elements/typewriter-light-be3832c55ab794ca.png
- Registry item: https://www.tryelements.dev/r/typewriter.json
- Categories: animations
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://www.tryelements.dev/r/typewriter.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Notes from the registry

Polymorphic Typewriter component. Supports a single string or an array of segments (each with its own className, speed, and delay), viewport-triggered start, looping, blinking or hidden cursor, and an onComplete callback. Zero external animation dependencies — uses plain React state + timers so it works anywhere React runs.

## Files

### `registry/default/blocks/animations/typewriter/components/elements/typewriter.tsx`

```tsx
"use client";

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

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

export type TypewriterSegment =
  | string
  | {
      text: string;
      className?: string;
      delay?: number;
      speed?: number;
    };

export type TypewriterProps = {
  text: TypewriterSegment | TypewriterSegment[];
  as?: React.ElementType;
  className?: string;
  speed?: number;
  delay?: number;
  startOnView?: boolean;
  loop?: boolean;
  loopDelay?: number;
  cursor?: boolean;
  cursorCharacter?: string;
  cursorClassName?: string;
  onComplete?: () => void;
};

const DEFAULT_SPEED = 30;

function normalize(text: TypewriterProps["text"]) {
  const segments = Array.isArray(text) ? text : [text];
  return segments.map((segment) =>
    typeof segment === "string" ? { text: segment } : segment,
  );
}

function TypewriterBase({
  text,
  as: Component = "span",
  className,
  speed = DEFAULT_SPEED,
  delay = 0,
  startOnView = false,
  loop = false,
  loopDelay = 1500,
  cursor = true,
  cursorCharacter = "|",
  cursorClassName,
  onComplete,
}: TypewriterProps) {
  const segments = useMemo(() => normalize(text), [text]);
  const containerRef = useRef<HTMLElement | null>(null);
  const [started, setStarted] = useState(!startOnView);
  const [segmentIndex, setSegmentIndex] = useState(0);
  const [charIndex, setCharIndex] = useState(0);
  const [done, setDone] = useState(false);

  useEffect(() => {
    if (!startOnView || started || !containerRef.current) return;
    const node = containerRef.current;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry?.isIntersecting) {
          setStarted(true);
          observer.disconnect();
        }
      },
      { threshold: 0.1 },
    );
    observer.observe(node);
    return () => observer.disconnect();
  }, [startOnView, started]);

  useEffect(() => {
    if (!started) return;
    if (segmentIndex >= segments.length) {
      setDone(true);
      onComplete?.();
      if (loop) {
        const resetTimer = window.setTimeout(() => {
          setDone(false);
          setSegmentIndex(0);
          setCharIndex(0);
        }, loopDelay);
        return () => window.clearTimeout(resetTimer);
      }
      return;
    }

    const current = segments[segmentIndex];
    if (!current) return;
    const currentSpeed = current.speed ?? speed;
    const segmentDelay = current.delay ?? 0;
    const isFirstChar = charIndex === 0;
    const leadingDelay = isFirstChar
      ? segmentIndex === 0
        ? delay + segmentDelay
        : segmentDelay
      : 0;

    if (charIndex < current.text.length) {
      const wait = isFirstChar ? leadingDelay + currentSpeed : currentSpeed;
      const timer = window.setTimeout(() => {
        setCharIndex((idx) => idx + 1);
      }, wait);
      return () => window.clearTimeout(timer);
    }

    setSegmentIndex((idx) => idx + 1);
    setCharIndex(0);
    return;
  }, [
    started,
    segmentIndex,
    charIndex,
    segments,
    speed,
    delay,
    loop,
    loopDelay,
    onComplete,
  ]);

  const MotionComponent = Component as keyof JSX.IntrinsicElements;

  const segmentsWithKeys = useMemo(
    () =>
      segments.map((segment, idx) => ({
        ...segment,
        key: `${idx}-${segment.text.slice(0, 24)}`,
      })),
    [segments],
  );

  const renderedSegments = segmentsWithKeys.map((segment, idx) => {
    if (idx > segmentIndex) return null;
    const value =
      idx < segmentIndex ? segment.text : segment.text.slice(0, charIndex);
    if (!value) return null;
    return (
      <span key={segment.key} className={cn(segment.className)}>
        {value}
      </span>
    );
  });

  return React.createElement(
    MotionComponent,
    {
      ref: containerRef as React.RefObject<HTMLElement>,
      "data-slot": "typewriter",
      className: cn("inline-block whitespace-pre-wrap", className),
      "aria-live": "polite",
    },
    <>
      {renderedSegments}
      {cursor && (
        <span
          aria-hidden="true"
          className={cn(
            "inline-block translate-y-[0.05em] animate-[typewriter-blink_1s_steps(1)_infinite]",
            done && !loop && "opacity-0",
            cursorClassName,
          )}
          style={{
            marginLeft: done ? 0 : "0.05em",
          }}
        >
          {cursorCharacter}
        </span>
      )}
      <style>{`
        @keyframes typewriter-blink {
          50% { opacity: 0; }
        }
      `}</style>
    </>,
  );
}

export const Typewriter = React.memo(TypewriterBase);
```

### `registry/default/blocks/animations/typewriter/routes/layout.tsx`

```tsx
import { Geist, Geist_Mono } from "next/font/google";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {children}
      </body>
    </html>
  );
}
```

### `registry/default/blocks/animations/typewriter/routes/page.tsx`

```tsx
import { Typewriter } from "@/registry/default/blocks/animations/typewriter/components/elements/typewriter";

export default function TypewriterPage() {
  return (
    <div className="min-h-screen bg-background text-foreground">
      <div className="container mx-auto px-4 py-16">
        <div className="max-w-3xl mx-auto space-y-16">
          <div className="text-center space-y-4">
            <h1 className="text-3xl font-bold">Typewriter</h1>
            <p className="text-muted-foreground">
              Framework-agnostic typewriter effect with multi-segment support,
              viewport triggers, and custom cursors.
            </p>
          </div>

          <section className="space-y-3">
            <p className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
              Single line
            </p>
            <Typewriter
              className="text-xl"
              text="Hola, soy Andenar. Te voy a ayudar a entender tu situación tributaria."
            />
          </section>

          <section className="space-y-3">
            <p className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
              Multi-segment with emphasis
            </p>
            <Typewriter
              className="text-xl leading-relaxed"
              speed={24}
              text={[
                { text: "Vamos a repasar " },
                {
                  text: "6 temas clave",
                  className: "text-[#c2794a] font-medium",
                  speed: 60,
                },
                { text: " para armar tu diagnóstico personalizado." },
              ]}
            />
          </section>

          <section className="space-y-3">
            <p className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
              Loop with custom cursor
            </p>
            <Typewriter
              className="text-2xl font-medium"
              loop
              loopDelay={1800}
              cursorCharacter="▌"
              text="streams · en · vivo"
            />
          </section>

          <section className="space-y-3">
            <p className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
              Triggered on viewport entry
            </p>
            <div className="h-72 overflow-y-auto rounded-lg border p-6">
              <div className="h-[200%] flex flex-col justify-end">
                <Typewriter
                  className="text-lg"
                  startOnView
                  text="Este párrafo espera a estar visible antes de escribirse."
                />
              </div>
            </div>
          </section>

          <section className="space-y-3">
            <p className="text-xs font-mono uppercase tracking-wider text-muted-foreground">
              As a heading
            </p>
            <Typewriter
              as="h2"
              className="text-4xl font-semibold tracking-tight"
              speed={40}
              text="Polymorphic element"
            />
          </section>
        </div>
      </div>
    </div>
  );
}
```
