Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 03:13:31Get Lifetime →
loopkit
freeNew142 lines · framer-motion

Text Typewriter

One sentence, a rotating subject. A full-width section with a fixed sentence whose last part types in, holds, deletes, and moves on to the next word from a list — a blinking caret throughout.

Use it for
A headline that wants to speak to several audiences in turn without several separate sections.
Themes
Light + dark
License
Commercial use
Live

Built for

An interface that waits for designers

The subject keeps changing; the sentence around it never breaks stride.

tsx
"use client";

import * as React from "react";
import { useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";

function useReducedMotionSafe() {
  const prefersReduced = useReducedMotion();
  const [mounted, setMounted] = React.useState(false);
  React.useEffect(() => setMounted(true), []);
  return mounted ? !!prefersReduced : false;
}

/**
 * Full-width kinetic text section: a fixed prefix followed by a rotating
 * subject that types in, holds, then deletes and moves to the next word,
 * with a blinking caret.
 */

export interface TextTypewriterProps {
  eyebrow?: string;
  prefix?: string;
  /** Comma-separated subjects the caret cycles through. */
  words?: string;
  subcopy?: string;
  typeSpeed?: number;
  accent?: "signal" | "info" | "lime" | "ok";
  className?: string;
}

const accentText: Record<string, string> = { signal: "text-signal", info: "text-info", lime: "text-lime", ok: "text-ok" };

function useTypewriter(words: string[], typeSpeed: number, active: boolean) {
  const [text, setText] = React.useState(words[0] ?? "");
  const [index, setIndex] = React.useState(0);

  React.useEffect(() => {
    if (!active || words.length === 0) {
      setText(words[0] ?? "");
      return;
    }
    let cancelled = false;
    let timeoutId: ReturnType<typeof setTimeout>;
    let wordI = 0;
    let charI = words[0]?.length ?? 0;
    let phase: "pause" | "deleting" | "typing" = "pause";

    const step = () => {
      if (cancelled) return;
      const word = words[wordI % words.length];
      let delay = typeSpeed;

      if (phase === "typing") {
        charI++;
        if (charI >= word.length) {
          phase = "pause";
          delay = 1500;
        } else {
          delay = typeSpeed + Math.random() * typeSpeed * 0.5;
        }
      } else if (phase === "pause") {
        phase = "deleting";
        delay = 900;
      } else if (phase === "deleting") {
        charI--;
        if (charI <= 0) {
          charI = 0;
          phase = "typing";
          wordI++;
          setIndex(wordI % words.length);
        }
        delay = typeSpeed * 0.5;
      }

      setText(word === words[wordI % words.length] ? words[wordI % words.length].slice(0, Math.max(charI, 0)) : "");
      timeoutId = setTimeout(step, delay);
    };

    timeoutId = setTimeout(step, typeSpeed);
    return () => {
      cancelled = true;
      clearTimeout(timeoutId);
    };
  }, [words, typeSpeed, active]);

  return { text, index };
}

export function TextTypewriter({
  eyebrow = "Built for",
  prefix = "An interface that waits for",
  words = "designers,engineers,founders,agents",
  subcopy = "The subject keeps changing; the sentence around it never breaks stride.",
  typeSpeed = 85,
  accent = "signal",
  className,
}: TextTypewriterProps) {
  const reduce = useReducedMotionSafe();
  const hostRef = React.useRef<HTMLDivElement>(null);
  const [visible, setVisible] = React.useState(false);
  const list = React.useMemo(() => words.split(",").map((w) => w.trim()).filter(Boolean), [words]);

  React.useEffect(() => {
    const el = hostRef.current;
    if (!el) return;
    const io = new IntersectionObserver(([e]) => setVisible(e.isIntersecting), { threshold: 0.15 });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const { text } = useTypewriter(list, typeSpeed, visible && !reduce);
  const display = reduce ? list[0] ?? "" : text;

  return (
    <section ref={hostRef} className={cn("relative w-full overflow-hidden bg-background px-4 py-24 sm:px-6 lg:px-10", className)}>
      <div className="relative mx-auto max-w-4xl text-center">
        <p className="font-mono text-[11px] uppercase tracking-[0.22em] text-muted-foreground">{eyebrow}</p>
        <h2 className="mt-4 font-display text-[clamp(1.9rem,5.4vw,3.75rem)] font-extrabold leading-[1.12] tracking-[-0.03em]">
          {prefix}
          <br className="hidden sm:block" />{" "}
          <span className="relative inline-flex items-baseline">
            <span className={cn(accentText[accent])}>{display}</span>
            <span
              aria-hidden
              className={cn("ml-1 inline-block h-[0.85em] w-[3px] translate-y-[0.08em] bg-foreground", !reduce && "lk-caret-blink")}
            />
          </span>
        </h2>
        <p className="mx-auto mt-5 max-w-xl text-base leading-relaxed text-muted-foreground">{subcopy}</p>
      </div>
      {!reduce && (
        <style>{`
          @keyframes lk-caret-blink { 0%, 45% { opacity: 1; } 50%, 100% { opacity: 0; } }
          .lk-caret-blink { animation: lk-caret-blink 1s steps(1) infinite; }
        `}</style>
      )}
    </section>
  );
}

export default TextTypewriter;

How it behaves

  • →Cycles through a comma-separated list of words: types in with slightly randomised per-character timing, pauses, deletes, and moves to the next word.
  • →Caret blinks on a steady CSS animation independent of the typing state.
  • →Starts only once the section is at least 15% visible (IntersectionObserver) and stops scheduling further steps once it isn't.
  • →typeSpeed scales both the typing and deleting cadence.
  • →prefers-reduced-motion shows the first word fully typed with a static caret, no cycling.

Accessibility

  • ✓The sentence is a single <h2>; the rotating word is inline so the whole line still reads as one heading.
  • ✓The caret is aria-hidden and purely decorative.
  • ✓Honours prefers-reduced-motion by freezing on the first word.
  • ✓Honours prefers-reduced-motion.