Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 03:13:37Get Lifetime →
loopkit
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.