freeNew125 lines · framer-motion
Hero Word Rotator
One word changes. The pill morphs to fit it. A centred, text-driven hero whose headline cycles through a list of words inside a pill that morphs its width and position between words via a shared framer-motion layoutId, instead of leaning on a background effect.
- Use it for
- One product pitched at several audiences — sales teams, support, engineering — without building a separate hero per segment.
- Themes
- Light + dark
- License
- Commercial use
Live
One product, every team
Built forsales teams— out of the box.
One hero, every audience: the word changes, the product underneath doesn't.
tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, 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;
}
/**
* Centred hero whose headline cycles one word inside a pill that morphs its
* width between words via a shared `layoutId`, instead of a background
* effect. The simplest, most text-driven hero in the set.
*/
export interface HeroWordRotatorProps {
prefix?: string;
/** Comma-separated words that cycle inside the headline. */
words?: string;
suffix?: string;
subcopy?: string;
primaryCta?: string;
secondaryCta?: string;
/** Seconds each word stays before rotating (1–5). */
interval?: number;
className?: string;
}
export function HeroWordRotator({
prefix = "Built for",
words = "sales teams,support teams,engineering,finance ops",
suffix = "— out of the box.",
subcopy = "One hero, every audience: the word changes, the product underneath doesn't.",
primaryCta = "Get started",
secondaryCta = "See all use cases",
interval = 2.2,
className,
}: HeroWordRotatorProps) {
const reduce = useReducedMotionSafe();
const list = React.useMemo(() => words.split(",").map((w) => w.trim()).filter(Boolean), [words]);
const [index, setIndex] = React.useState(0);
React.useEffect(() => {
setIndex(0);
}, [words]);
React.useEffect(() => {
if (reduce || list.length < 2) return;
const t = setInterval(() => setIndex((i) => (i + 1) % list.length), Math.max(0.6, interval) * 1000);
return () => clearInterval(t);
}, [list.length, interval, reduce]);
const word = list[Math.min(index, list.length - 1)] ?? "";
return (
<section className={cn("relative isolate overflow-hidden bg-background", className)}>
<div className="relative mx-auto flex min-h-[28rem] max-w-3xl flex-col items-center px-4 pb-16 pt-20 text-center sm:px-6 sm:pt-24">
<motion.span
initial={reduce ? false : { opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="inline-flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground"
>
<span className="size-1.5 rounded-full bg-signal" aria-hidden />
One product, every team
</motion.span>
<motion.h1
initial={reduce ? false : { opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.55, delay: 0.1 }}
className="mt-7 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 font-display text-[clamp(2.2rem,6vw,4.4rem)] font-extrabold leading-[1.05] tracking-[-0.04em]"
>
<span>{prefix}</span>
<span className="relative inline-flex h-[1.25em] items-center overflow-hidden px-1" aria-live="polite">
<AnimatePresence mode="wait">
<motion.span
key={word}
layoutId={reduce ? undefined : "word-rotator-pill"}
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={reduce ? undefined : { opacity: 0 }}
transition={{ layout: { duration: 0.35, ease: [0.16, 1, 0.3, 1] }, opacity: { duration: 0.2 } }}
className="inline-block whitespace-nowrap rounded-xl bg-signal/15 px-3 py-0.5 text-signal"
>
{word}
</motion.span>
</AnimatePresence>
</span>
<span>{suffix}</span>
</motion.h1>
<motion.p
initial={reduce ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.25 }}
className="mt-5 max-w-lg text-lg leading-relaxed text-muted-foreground"
>
{subcopy}
</motion.p>
<motion.div
initial={reduce ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.35 }}
className="mt-8 flex flex-wrap items-center justify-center gap-3"
>
<a href="#" className="inline-flex h-12 items-center rounded-full bg-primary px-6 text-[15px] font-semibold text-primary-foreground transition hover:opacity-90">
{primaryCta} →
</a>
<a href="#" className="inline-flex h-12 items-center rounded-full border-2 border-foreground px-6 text-[15px] font-semibold transition hover:bg-foreground hover:text-background">
{secondaryCta}
</a>
</motion.div>
</div>
</section>
);
}
export default HeroWordRotator;
How it behaves
- →words is a comma-separated list; a setInterval advances the current index every interval seconds and wraps back to the start.
- →The current word is wrapped in a pill that shares a layoutId across renders, so framer-motion animates its width and position smoothly to fit each new word instead of jump-cutting; the word itself cross-fades in and out.
- →prefix and suffix frame the rotating word so the sentence still reads naturally at any width.
- →prefers-reduced-motion drops the layoutId and the interval entirely, freezing on the first word.
Accessibility
- ✓The rotating-word wrapper has `aria-live=polite`, so assistive tech announces each change the way a live region should, instead of silently swapping text.
- ✓Headline is a single <h1> built from plain text nodes plus the live pill, so it still reads as one coherent sentence to screen readers.
- ✓CTAs keep the design system's visible focus ring.
- ✓Honours prefers-reduced-motion.