Web InnoventixFreeCode

Scroll Text Highlight

Original · free

A statement whose words light up one by one as you scroll through the section.

byWeb InnoventixReact + Tailwind
scrolltexthighlight
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/scroll-text-highlight.json
scroll-text-highlight.tsx
"use client";

import { useRef, type ReactNode } from "react";
import {
  motion,
  useScroll,
  useSpring,
  useTransform,
  type MotionValue,
} from "motion/react";

type WordProps = {
  children: ReactNode;
  progress: MotionValue<number>;
  start: number;
  end: number;
};

function Word({ children, progress, start, end }: WordProps) {
  const opacity = useTransform(progress, [start, end], [1, 0]);
  const revealed = useTransform(progress, [start, end], [0, 1]);
  const blur = useTransform(revealed, [0, 1], [6, 0]);
  const filter = useTransform(blur, (b: number) => `blur(${b}px)`);
  const y = useTransform(revealed, [0, 1], [4, 0]);

  return (
    <span className="relative mr-[0.28em] inline-block leading-[1.18]">
      {/* muted resting layer */}
      <motion.span
        aria-hidden
        style={{ opacity }}
        className="text-slate-300 dark:text-slate-700"
      >
        {children}
      </motion.span>

      {/* highlighted layer that lights up on scroll */}
      <motion.span
        aria-hidden
        style={{ opacity: revealed, filter, y }}
        className="absolute left-0 top-0 bg-gradient-to-br from-indigo-500 via-violet-500 to-fuchsia-500 bg-clip-text text-transparent dark:from-indigo-300 dark:via-violet-300 dark:to-fuchsia-300"
      >
        {children}
      </motion.span>
    </span>
  );
}

export default function ScrollTextHighlight() {
  const targetRef = useRef<HTMLDivElement>(null);

  const { scrollYProgress } = useScroll({
    target: targetRef,
    offset: ["start start", "end end"],
  });

  const smooth = useSpring(scrollYProgress, {
    stiffness: 90,
    damping: 26,
    mass: 0.4,
  });

  const barScaleX = useSpring(scrollYProgress, {
    stiffness: 120,
    damping: 30,
    mass: 0.3,
  });

  const statement =
    "We design and build websites that turn quiet traffic into paying customers — fast to load, easy to find, and impossible to ignore.";
  const words = statement.split(" ");

  // Reserve the last slice of scroll so the sentence finishes fully lit
  // before the section unpins.
  const revealWindow = 0.82;
  const perWord = revealWindow / words.length;

  const percent = useTransform(smooth, (v: number) =>
    Math.round(Math.min(1, v / revealWindow) * 100)
  );

  return (
    <section className="relative w-full bg-white dark:bg-slate-950">
      <style>{`
        @keyframes szh-hint {
          0%, 100% { transform: translateY(0); opacity: 0.55; }
          50% { transform: translateY(6px); opacity: 1; }
        }
        @keyframes szh-orb {
          0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
          50% { transform: translate3d(2%, -3%, 0) scale(1.08); }
        }
        .szh-hint { animation: szh-hint 1.8s ease-in-out infinite; }
        .szh-orb-a { animation: szh-orb 14s ease-in-out infinite; }
        .szh-orb-b { animation: szh-orb 18s ease-in-out infinite reverse; }
        @media (prefers-reduced-motion: reduce) {
          .szh-hint, .szh-orb-a, .szh-orb-b { animation: none !important; }
        }
      `}</style>

      {/* tall scroll track — provides room for the word-by-word reveal */}
      <div ref={targetRef} className="relative h-[320vh]">
        {/* pinned stage */}
        <div className="sticky top-0 flex h-screen items-center overflow-hidden">
          {/* ambient background */}
          <div aria-hidden className="pointer-events-none absolute inset-0">
            <div className="szh-orb-a absolute -left-24 top-1/4 h-72 w-72 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20" />
            <div className="szh-orb-b absolute -right-16 bottom-1/4 h-80 w-80 rounded-full bg-fuchsia-300/40 blur-3xl dark:bg-fuchsia-600/20" />
            <div
              className="absolute inset-0 opacity-[0.4] dark:opacity-[0.25]"
              style={{
                backgroundImage:
                  "radial-gradient(circle at 1px 1px, rgb(148 163 184 / 0.25) 1px, transparent 0)",
                backgroundSize: "28px 28px",
              }}
            />
          </div>

          <div className="relative mx-auto w-full max-w-6xl px-6 py-16 sm:px-10 lg:px-16">
            {/* eyebrow + live counter */}
            <div className="mb-10 flex flex-wrap items-center justify-between gap-4">
              <div className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-4 py-1.5 text-xs font-medium uppercase tracking-[0.2em] text-slate-500 backdrop-blur dark:border-slate-800 dark:bg-slate-900/70 dark:text-slate-400">
                <span className="relative flex h-2 w-2">
                  <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
                  <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
                </span>
                Our Promise
              </div>

              <div className="inline-flex items-baseline gap-1 font-mono text-sm text-slate-400 dark:text-slate-500">
                <motion.span className="tabular-nums text-slate-700 dark:text-slate-200">
                  {percent}
                </motion.span>
                <span aria-hidden>%</span>
                <span className="sr-only">of statement revealed</span>
              </div>
            </div>

            {/* the statement that lights up word-by-word */}
            <h2 className="max-w-5xl text-balance text-3xl font-semibold tracking-tight sm:text-4xl md:text-5xl lg:text-6xl">
              <span className="sr-only">{statement}</span>
              <span aria-hidden className="flex flex-wrap">
                {words.map((word, i) => {
                  const start = i * perWord;
                  const end = start + perWord * 2.4;
                  return (
                    <Word
                      key={`${word}-${i}`}
                      progress={smooth}
                      start={start}
                      end={Math.min(end, 1)}
                    >
                      {word}
                    </Word>
                  );
                })}
              </span>
            </h2>

            {/* progress rail */}
            <div className="mt-14 flex items-center gap-4">
              <div className="h-[3px] flex-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
                <motion.div
                  aria-hidden
                  style={{ scaleX: barScaleX }}
                  className="h-full origin-left rounded-full bg-gradient-to-r from-indigo-500 via-violet-500 to-fuchsia-500"
                />
              </div>
              <span className="szh-hint hidden shrink-0 items-center gap-2 text-sm font-medium text-slate-400 sm:inline-flex dark:text-slate-500">
                Keep scrolling
                <svg
                  aria-hidden
                  viewBox="0 0 24 24"
                  fill="none"
                  className="h-4 w-4"
                  stroke="currentColor"
                  strokeWidth="2"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                >
                  <path d="M12 5v14" />
                  <path d="m19 12-7 7-7-7" />
                </svg>
              </span>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →
Scroll Progress Reveal

Scroll Progress Reveal

Original

A section that reveals its content in sequence as you scroll, with a sticky scroll-progress bar showing how far through it you are.

Parallax Layers

Parallax Layers

Original

A layered depth scene where background, midground and foreground drift at different speeds, driven by scroll position.

Pinned Scroll Steps

Pinned Scroll Steps

Original

A pinned, sticky panel whose visual and copy change step by step as you scroll past it, a classic scrollytelling section.

Horizontal Scroll Gallery

Horizontal Scroll Gallery

Original

Vertical scrolling pans a horizontal rail of cards sideways while the section is pinned, then releases.

Scroll Reveal Timeline

Scroll Reveal Timeline

Original

A vertical timeline whose connecting line fills and whose milestones fade in as they enter the viewport.

Count-up Stats Band

Count-up Stats Band

Original

A metrics band whose numbers count up from zero the moment it scrolls into view.

Clip-path Image Reveal

Clip-path Image Reveal

Original

Panels that unmask with a clip-path wipe as they enter the viewport, revealing the media beneath.

Stacking Cards

Stacking Cards

Original

A deck of cards that pin and scale as you scroll, each settling behind the next.

Scroll Zoom Hero

Scroll Zoom Hero

Original

A hero whose backdrop scales and brightens while the headline parallaxes upward as you scroll.

Spotlight Hero

Spotlight Hero

Original

A centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero

Split Hero

Original

A two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero

Gradient Spotlight Hero

Original

A minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.