Kinetic Headline That Builds Word by Word from the Centre

A centred hero headline that assembles itself. The first word fades up in the middle of the line. The next slides in from the right out of a soft blur, and because the line stays centred the first word glides left to make room. When the phrase is complete it holds, lifts away, and the next phrase starts. The last word of each phrase takes the accent colour. Around it is a full hero: a status pill, a paragraph and two buttons on deep indigo. The headline keeps a fixed height so nothing below it moves, and the type drops to a smaller size on phones.

Code

// Adapted from Animata (https://animata.design/docs/text/kinetic-center-build)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";

const phrases = [
  ["Plan", "the", "week"],
  ["Share", "one", "calendar"],
  ["Skip", "the", "meeting"],
  ["Finish", "by", "Friday"],
];
const WORD_MS = 430; // gap between words arriving
const HOLD_MS = 1500; // how long the finished line stays
const EXIT_MS = 300;
const EASE = [0.2, 0.8, 0.2, 1] as const;

export default function KineticHeadlineThatBuildsWordByWordFromTheCentre() {
  const reduce = useReducedMotion();
  const [phrase, setPhrase] = useState(0);
  const [count, setCount] = useState(1);
  const words = phrases[phrase];
  const shown = reduce ? words : words.slice(0, count);

  useEffect(() => {
    if (reduce) {
      const timer = window.setTimeout(() => setPhrase((current) => (current + 1) % phrases.length), 3200);
      return () => window.clearTimeout(timer);
    }
    const done = count >= words.length;
    const timer = window.setTimeout(
      () => {
        if (done) {
          setPhrase((current) => (current + 1) % phrases.length);
          setCount(0);
        } else {
          setCount((current) => current + 1);
        }
      },
      done ? HOLD_MS : count === 0 ? EXIT_MS + 120 : WORD_MS
    );
    return () => window.clearTimeout(timer);
  }, [count, phrase, reduce, words.length]);

  return (
    <section className="relative overflow-hidden bg-indigo-950 px-4 py-20 sm:px-6 sm:py-28">
      <div aria-hidden="true" className="pointer-events-none absolute left-1/2 top-0 h-80 w-[720px] -translate-x-1/2 rounded-full bg-fuchsia-500/20 blur-3xl" />
      <div className="relative mx-auto flex max-w-4xl flex-col items-center text-center">
        <p className="inline-flex items-center gap-2 rounded-full border border-indigo-700 bg-indigo-900/60 px-4 py-1.5 text-sm font-medium text-indigo-100">
          <span className="h-2 w-2 rounded-full bg-emerald-400" aria-hidden="true" />
          Tempo for small teams
        </p>

        <h2 className="mt-8 flex h-16 w-full items-center justify-center text-[2.6rem] font-semibold leading-none tracking-tight text-white sm:h-24 sm:text-7xl">
          <span className="sr-only">{phrases.map((list) => list.join(" ")).join(". ")}.</span>
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={phrase}
              aria-hidden="true"
              className="flex items-center justify-center gap-[0.26em] whitespace-nowrap"
              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -10, filter: "blur(4px)", transition: { duration: EXIT_MS / 1000, ease: [0.4, 0, 0.2, 1] } }}
            >
              {shown.map((word, index) => (
                <motion.span
                  key={word + index}
                  layout={reduce ? false : "position"}
                  initial={reduce ? false : index === 0 ? { opacity: 0, y: 10, filter: "blur(4px)" } : { opacity: 0, x: 88, scale: 0.98, filter: "blur(5px)" }}
                  animate={{ opacity: 1, x: 0, y: 0, scale: 1, filter: "blur(0px)" }}
                  transition={{ duration: WORD_MS / 1000, ease: EASE, layout: { duration: WORD_MS / 1000, ease: EASE } }}
                  className={`inline-block ${index === words.length - 1 ? "text-fuchsia-300" : ""}`}
                >
                  {word}
                </motion.span>
              ))}
            </motion.span>
          </AnimatePresence>
        </h2>

        <p className="mt-6 max-w-xl text-lg leading-relaxed text-indigo-100">
          One shared plan for the week, built in ten minutes on Monday. Everyone sees what matters, what moved and what is
          finished.
        </p>
        <div className="mt-9 flex flex-wrap items-center justify-center gap-3">
          <a
            href="#"
            className="inline-flex h-12 items-center rounded-xl bg-white px-6 text-sm font-semibold text-indigo-950 transition hover:bg-indigo-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fuchsia-300 focus-visible:ring-offset-2 focus-visible:ring-offset-indigo-950"
          >
            Start a free week
          </a>
          <a
            href="#"
            className="inline-flex h-12 items-center rounded-xl border border-indigo-600 px-6 text-sm font-semibold text-white transition hover:border-indigo-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fuchsia-300 focus-visible:ring-offset-2 focus-visible:ring-offset-indigo-950"
          >
            Watch the two-minute tour
          </a>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/kinetic-headline-that-builds-word-by-word-from-the-centre.json

When to use it

How to customize it

Accessibility

Questions

What makes the earlier words slide aside?

The words sit in a centred flex row and each one has framer-motion's `layout="position"`. When a new word is added the row gets wider, every existing word's position changes, and framer-motion animates each from its old place to its new one.

Why does the new word come from the right?

Its `initial` state offsets it by 88 pixels on the x axis with zero opacity and a blur. It animates to its natural place at the same time as the others shift left, so the line appears to grow outward from the centre.

How is the timing controlled?

One effect holds a single timer. It adds a word every `WORD_MS` until the phrase is complete, waits `HOLD_MS`, then moves to the next phrase and starts from zero words. There are no chained animation callbacks to keep in sync.

Can phrases have different numbers of words?

Yes. The effect reads the length of the current phrase, so two-word and five-word phrases can be mixed. Only the width of the longest one matters for small screens.

The text looks soft while it moves. Why?

Words enter through a short blur filter, which is part of the effect. Once a word lands the filter is `blur(0px)`. If you see softness at rest, remove `filter` from both `initial` and `animate`.

Adapted from Kinetic Center Build in Animata, used under its open-source licence. Licences and credits

Related: Rotating Word Headline with Spring Flip, Typewriter Headline with Cycling Phrases, Morphing Text Headline with Liquid Blur.

More text effects or all free UI components. Start for free to have a full site built around it.

More in Text Effects

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.

Product

Who It's For

Features

Resources

Company

View Sitemap