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.