A hero headline whose last line is typed out one character at a time, held, deleted and replaced by the next phrase, with a caret that blinks only while the text is at rest. A row of chips under the copy shows which phrase is active and lets a visitor jump straight to one. The typed line has a fixed height and never wraps, so nothing below it moves, and the phrases are short enough to fit a 360px phone.
import { useEffect, useRef, useState } from "react";
import { useInView, useReducedMotion } from "framer-motion";
// The phrases the headline cycles through. Keep them under about 18 characters so they fit a phone.
const phrases = ["wedding florists.", "bike repair shops.", "ceramics studios.", "corner bakeries.", "dog groomers."];
const TYPE_MS = 58; // base delay per typed character
const DELETE_MS = 30; // delay per deleted character
const HOLD_MS = 1400; // how long a finished phrase stays
const GAP_MS = 320; // empty pause before the next phrase
type Phase = "hold" | "deleting" | "typing";
export default function TypewriterHeadlineWithCyclingPhrases() {
const reduce = useReducedMotion();
const ref = useRef<HTMLElement>(null);
const inView = useInView(ref, { amount: 0.3 });
const [index, setIndex] = useState(0);
const [count, setCount] = useState(phrases[0].length);
const [phase, setPhase] = useState<Phase>("hold");
useEffect(() => {
if (reduce || !inView) return;
const phrase = phrases[index];
let delay = 0;
let step: () => void;
if (phase === "hold") {
delay = HOLD_MS;
step = () => setPhase("deleting");
} else if (phase === "deleting") {
if (count > 0) {
delay = DELETE_MS;
step = () => setCount((c) => c - 1);
} else {
delay = GAP_MS;
step = () => {
setIndex((i) => (i + 1) % phrases.length);
setPhase("typing");
};
}
} else if (count < phrase.length) {
// Uneven rhythm: a beat after a space, small variation elsewhere, so it reads as a person typing.
const previous = phrase[count - 1];
delay = TYPE_MS + (previous === " " ? 90 : ((count * 37) % 5) * 11);
step = () => setCount((c) => c + 1);
} else {
step = () => setPhase("hold");
}
const timer = window.setTimeout(step, delay);
return () => window.clearTimeout(timer);
}, [reduce, inView, phase, count, index]);
const jumpTo = (i: number) => {
setIndex(i);
setCount(reduce ? phrases[i].length : 0);
setPhase(reduce ? "hold" : "typing");
};
const typed = phrases[index].slice(0, count);
const resting = phase === "hold";
return (
<section ref={ref} className="relative overflow-hidden bg-amber-50 px-6 py-20 text-stone-900 sm:py-28">
<style>{`
@keyframes typewriter-headline-with-cycling-phrases-blink { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }
.typewriter-headline-with-cycling-phrases-caret { animation: typewriter-headline-with-cycling-phrases-blink 1.05s steps(1) infinite; }
@media (prefers-reduced-motion: reduce) { .typewriter-headline-with-cycling-phrases-caret { animation: none; } }
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-60"
style={{ backgroundImage: "radial-gradient(rgba(120,53,15,0.16) 1px, transparent 1px)", backgroundSize: "22px 22px", maskImage: "linear-gradient(to bottom, black, transparent 85%)", WebkitMaskImage: "linear-gradient(to bottom, black, transparent 85%)" }}
/>
<div aria-hidden="true" className="pointer-events-none absolute -right-24 -top-24 h-96 w-96 rounded-full bg-orange-300/40 blur-3xl" />
<div className="relative mx-auto max-w-5xl">
<div className="flex items-center justify-between border-b border-stone-900/15 pb-4 text-xs font-medium uppercase tracking-widest text-stone-600">
<span>Fernhill Studio</span>
<span className="font-mono tabular-nums">
<span className="text-orange-700">{String(index + 1).padStart(2, "0")}</span> / {String(phrases.length).padStart(2, "0")}
</span>
</div>
<h1 className="mt-12 font-serif text-4xl leading-tight tracking-tight sm:mt-16 sm:text-6xl lg:text-7xl">
<span className="sr-only">Booking sites that fill the calendar for {phrases.join(", ").replace(/\./g, "")}.</span>
<span aria-hidden="true" className="block">
Booking sites that fill
<br />
the calendar for
</span>
<span aria-hidden="true" className="mt-1 block h-[1.25em] whitespace-nowrap italic text-orange-700">
{typed}
<span
className={`ml-1 inline-block h-[0.82em] w-[3px] translate-y-[0.1em] rounded-full bg-orange-600 sm:w-1 ${resting ? "typewriter-headline-with-cycling-phrases-caret" : ""}`}
/>
</span>
</h1>
<div className="mt-10 grid gap-8 sm:mt-14 sm:grid-cols-5 sm:items-end">
<div className="sm:col-span-3">
<p className="max-w-md text-lg leading-relaxed text-stone-700">
We build one thing: a small, fast site with a booking page that works at 11pm on a phone. Flat fee, live in ten days.
</p>
<div className="mt-7 flex flex-wrap items-center gap-4">
<a href="#" className="inline-flex h-12 items-center rounded-full bg-stone-900 px-6 text-sm font-semibold text-amber-50 transition hover:bg-orange-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-700 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-50">
See recent launches
</a>
<a href="#" className="inline-flex h-12 items-center text-sm font-semibold text-stone-900 underline decoration-orange-600 decoration-2 underline-offset-4 hover:text-orange-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-700 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-50">
Pricing from $1,900
</a>
</div>
</div>
<div className="sm:col-span-2">
<p className="text-xs font-medium uppercase tracking-widest text-stone-600">Jump to a trade</p>
<div className="mt-3 flex flex-wrap gap-2" role="group" aria-label="Choose the phrase to type">
{phrases.map((p, i) => (
<button
key={p}
type="button"
aria-pressed={i === index}
onClick={() => jumpTo(i)}
className={`h-10 rounded-full border px-4 text-sm font-medium transition duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-700 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-50 ${i === index ? "border-orange-700 bg-orange-700 text-white" : "border-stone-900/20 text-stone-700 hover:border-stone-900/50"}`}
>
{p.replace(".", "")}
</button>
))}
</div>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/typewriter-headline-with-cycling-phrases.json
The component keeps three pieces of state: which phrase is active, how many characters of it are visible, and a phase (typing, hold or deleting). One effect schedules a single timeout for the next step and clears it on every change, so there is never more than one timer running and nothing leaks when the component unmounts.
Each character gets a slightly different delay. The code adds a short extra beat after a space and a small repeating variation on other characters, which reads as a person typing instead of a metronome. Set the variation to zero in the typing branch if you want an even rhythm.
No. The typed line sits in its own block with a fixed height of `1.25em` and `whitespace-nowrap`, so the paragraph and buttons under it stay where they are whether the line is empty or full.
No. `useInView` reports when less than 30 percent of the section is visible and the effect stops scheduling timeouts until it returns.
Yes. In the hold branch of the effect, return early when `index` is the last phrase, so the deleting phase is never started. The caret will keep blinking on the final phrase.
Related: Rotating Word Headline with Spring Flip, Text Scramble Decode Headline, Shimmering Chrome Gradient Headline.
More text effects or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.