Word by Word Blur In Paragraph Reveal

A large statement paragraph on a deep green background. When it scrolls into view each word rises a few pixels and sharpens from a 12px blur, one after another, so the sentence appears to be read aloud. Once the last word has landed, three key phrases turn lime and are underlined in sequence. An author line, a replay button and a link sit underneath. The paragraph wraps naturally at every width because each word is its own inline block.

Code

import { useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";

// The paragraph, in pieces. Pieces with `mark: true` light up after the reveal finishes.
const segments: { text: string; mark?: boolean }[] = [
  { text: "We grow tea the slow way." },
  { text: "Every leaf on the Marlow estate is" },
  { text: "picked by hand before nine,", mark: true },
  { text: "withered in mountain air and rolled the same afternoon. Nothing is blended, nothing is rushed, and" },
  { text: "every tin names the row", mark: true },
  { text: "it came from, so the cup you pour tastes like" },
  { text: "one hillside on one morning.", mark: true },
];

const facts = [
  { label: "Altitude", value: "1,840 metres" },
  { label: "Harvest", value: "April to June" },
  { label: "Rows under leaf", value: "212" },
];

const STAGGER = 0.02; // seconds between words
const WORD_SECONDS = 0.5; // how long one word takes to come into focus

export default function WordByWordBlurInParagraphReveal() {
  const reduce = useReducedMotion();
  const ref = useRef<HTMLElement>(null);
  const inView = useInView(ref, { amount: 0.35 });
  const [run, setRun] = useState(0);

  // Flatten to words, remembering which marked phrase each belongs to.
  let markCount = 0;
  const words = segments.flatMap((s) => {
    const m = s.mark ? markCount++ : -1;
    return s.text.split(" ").map((w, j) => ({ w, m, j }));
  });
  const revealEnd = words.length * STAGGER + WORD_SECONDS;
  // On the server (and with reduced motion) render the finished paragraph, so the plain HTML is readable.
  const still = reduce || typeof window === "undefined";
  const show = still || inView;

  return (
    <section ref={ref} className="relative overflow-hidden bg-emerald-950 px-6 py-20 text-emerald-50 sm:py-28">
      <div aria-hidden="true" className="pointer-events-none absolute -left-40 top-0 h-96 w-96 rounded-full bg-emerald-500/20 blur-3xl" />
      <div aria-hidden="true" className="pointer-events-none absolute -bottom-40 right-0 h-96 w-96 rounded-full bg-lime-300/10 blur-3xl" />

      <div className="relative mx-auto grid max-w-6xl gap-10 lg:grid-cols-12">
        <div className="flex items-center justify-between lg:col-span-3 lg:block">
          <p className="text-xs font-medium uppercase tracking-widest text-emerald-300">Marlow Estate</p>
          <p className="text-sm text-emerald-200/70 lg:mt-3">How we make it</p>
          <dl className="mt-10 hidden space-y-4 border-t border-emerald-50/15 pt-6 text-sm lg:block">
            {facts.map((f) => (
              <div key={f.label}>
                <dt className="text-emerald-200/70">{f.label}</dt>
                <dd className="mt-0.5 font-medium text-emerald-50">{f.value}</dd>
              </div>
            ))}
          </dl>
        </div>

        <div className="lg:col-span-9">
          <p key={run} className="text-2xl font-medium leading-snug tracking-tight sm:text-4xl sm:leading-tight lg:text-5xl lg:leading-tight">
            {words.map(({ w, m, j }, i) => {
              const marked = m >= 0;
              const lightAt = revealEnd + 0.1 + m * 0.45 + j * 0.06; // phrases in turn, words within a phrase left to right
              return (
                <span key={i}>
                  <motion.span
                    className="relative inline-block will-change-transform"
                    initial={still ? false : { opacity: 0, y: 10, filter: "blur(12px)" }}
                    animate={show ? { opacity: 1, y: 0, filter: "blur(0px)" } : { opacity: 0, y: 10, filter: "blur(12px)" }}
                    transition={{ duration: WORD_SECONDS, delay: show ? i * STAGGER : 0, ease: [0.2, 0.65, 0.3, 1] }}
                  >
                    {marked ? (
                      <motion.span
                        initial={still ? false : { color: "rgb(236 253 245)" }}
                        animate={show ? { color: "rgb(190 242 100)" } : { color: "rgb(236 253 245)" }}
                        transition={{ duration: 0.4, delay: show ? lightAt : 0 }}
                      >
                        {w}
                      </motion.span>
                    ) : (
                      w
                    )}
                    {marked && (
                      <motion.span
                        aria-hidden="true"
                        className="absolute -inset-x-[0.14em] bottom-0 h-px origin-left bg-lime-300/70 sm:h-0.5"
                        initial={still ? false : { scaleX: 0 }}
                        animate={show ? { scaleX: 1 } : { scaleX: 0 }}
                        transition={{ duration: 0.3, delay: show ? lightAt : 0, ease: "linear" }}
                      />
                    )}
                  </motion.span>{" "}
                </span>
              );
            })}
          </p>

          <div className="mt-10 flex flex-wrap items-center justify-between gap-5 border-t border-emerald-50/15 pt-6">
            <div className="flex items-center gap-3">
              <span className="flex h-10 w-10 items-center justify-center rounded-full bg-lime-300 text-sm font-semibold text-emerald-950">IM</span>
              <div className="text-sm">
                <p className="font-semibold text-emerald-50">Ines Marlow</p>
                <p className="text-emerald-200/70">Third generation grower</p>
              </div>
            </div>
            <div className="flex items-center gap-3">
              <button
                type="button"
                onClick={() => setRun((r) => r + 1)}
                className="inline-flex h-11 items-center gap-2 rounded-full border border-emerald-50/25 px-5 text-sm font-semibold text-emerald-50 transition hover:border-emerald-50/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-lime-300 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-950"
              >
                <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4">
                  <path d="M4 10a6 6 0 1 0 2-4.5" />
                  <path d="M4 3.5v3h3" />
                </svg>
                Replay
              </button>
              <a href="#" className="inline-flex h-11 items-center rounded-full bg-lime-300 px-5 text-sm font-semibold text-emerald-950 transition hover:bg-lime-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-lime-300 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-950">
                Shop this harvest
              </a>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/word-by-word-blur-in-paragraph-reveal.json

When to use it

How to customize it

Accessibility

Questions

How does the word by word reveal work?

The paragraph is split on spaces and every word is wrapped in a `motion.span` set to `inline-block`. Each one animates opacity, vertical offset and a blur filter with a delay of its position multiplied by `STAGGER`, which produces the left to right wave. Inline blocks are needed because transforms do not apply to plain inline text.

How do the highlighted phrases know when to start?

The component works out when the last word finishes (`words.length * STAGGER + WORD_SECONDS`) and gives each marked phrase a delay after that point, 0.45 seconds apart, with a further 0.06 seconds per word so each underline draws from left to right. Change that number to tighten or spread the sequence.

Does it replay every time I scroll past?

Yes. `useInView` is used without the `once` option, so the paragraph hides when it leaves the screen and plays again when it returns. Pass `once: true` to play a single time. The Replay button remounts the paragraph by changing its `key`.

Is animating a blur filter expensive?

Blur is heavier than opacity or transform, so the effect is kept short and applied to small elements. A paragraph of this length is comfortable on phones. For several hundred words, reveal by line instead or drop the blur.

Can I reveal by character instead of by word?

Yes. Split each word into characters and animate those spans, but keep the outer word wrapper as an inline block with `whitespace-nowrap` so words do not break across lines.

Related: Text Scramble Decode Headline, Shimmering Chrome Gradient Headline, Count Up Stats Row with Rolling Digits.

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