Copied!
Text

Word read highlight

Words in a paragraph light up one by one as it scrolls through the viewport.

← Library

Element · Vanilla JS + CSSUpdated November 5, 2026

TextElementVanilla JS + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Word read highlight" for my site using the Enrichr Library element at https://enrichr.dev/elements/word-read-highlight.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/word-read-highlight.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- text: Words light up as you read, so the page follows your attention instead of asking for it.
- dim: 0.2
- manual: 0
- color: #ffffff

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/word-read-highlight.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<p class="ef-read"></p>

CSS

.ef-read { margin: 0; font-size: clamp(1.4rem, 3.6vw, 2.4rem); font-weight: 600; line-height: 1.3; letter-spacing: -0.01em; }
.ef-read__w { opacity: var(--ef-dim, 0.2); transition: opacity 200ms linear; }
.ef-read__w.is-lit { opacity: 1; }
@media (prefers-reduced-motion: reduce) { .ef-read__w { opacity: 1; transition: none; } }

JavaScript

// Word read highlight: words light up one by one as the paragraph scrolls through the viewport.
// mount(root, { text, dim (0-1 opacity of unread words), color, manual (0 = follow scroll, 1-100 = fixed progress) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var p = root.querySelector(".ef-read");
  var opts = {}, words = [], raf = 0, lit = -1;

  function render() {
    p.textContent = "";
    p.style.color = opts.color;
    p.style.setProperty("--ef-dim", String(opts.dim));
    words = opts.text.split(/\s+/).map(function (w) {
      var s = document.createElement("span");
      s.className = "ef-read__w";
      s.textContent = w;
      p.appendChild(s);
      p.appendChild(document.createTextNode(" "));
      return s;
    });
    lit = -1;
    paint();
  }
  function progress() {
    if (opts.manual > 0) return opts.manual / 100;
    var r = p.getBoundingClientRect(), vh = window.innerHeight || 800;
    var span = r.height + vh * 0.35;
    return Math.min(1, Math.max(0, (vh * 0.85 - r.top) / span));
  }
  function paint() {
    raf = 0;
    var n = reduce ? words.length : Math.round(progress() * words.length);
    if (n === lit) return;
    lit = n;
    words.forEach(function (w, i) { w.classList.toggle("is-lit", i < n); });
  }
  function schedule() { if (!raf) raf = requestAnimationFrame(paint); }
  function update(next) {
    opts = Object.assign({ text: "Words light up as you read, so the page follows your attention.", dim: 0.2, color: "#ffffff", manual: 0 }, opts, next || {});
    render();
  }
  update(o);
  if (!reduce) {
    window.addEventListener("scroll", schedule, { passive: true });
    window.addEventListener("resize", schedule);
  }
  return {
    update: update,
    destroy: function () { cancelAnimationFrame(raf); window.removeEventListener("scroll", schedule); window.removeEventListener("resize", schedule); }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"text":"Words light up as you read, so the page follows your attention instead of asking for it.","dim":0.2,"manual":0,"color":"#ffffff"});

How it works

Progress is the paragraph's position in the viewport, mapped from 0 to 1 over its height plus a margin. The number of lit words is progress times word count, and a class is toggled only for words whose state changed. Scroll events are coalesced into one requestAnimationFrame.

Accessibility and performance

All text stays in the DOM at normal contrast once read; unread words are dimmed but present for screen readers. Reduced motion lights every word immediately. Check that the unread opacity still meets your contrast needs.

One passive scroll listener and one rAF per scroll burst; only changed words touch the DOM. No layout reads besides one getBoundingClientRect per frame.

SectionText
TechniquesText animation, Scroll-driven
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Scroll velocity skewText that leans and splits into two colour fringes in proportion to how fast the page scrolls. Scroll spring headingAn oversized heading slides sideways with scroll progress through a spring, lagging and settling. Marquee tickerEndless horizontal text that pauses on hover, with speed, gap and direction controls.

FAQ

Does it need a smooth scroll library?

No. It reads the native scroll position, so it also works with Lenis or any other smooth scroll.

Is dimmed text an accessibility problem?

Unread words are still real text for screen readers. Keep the dim value high enough to remain legible, and with reduced motion everything is lit.

Can I drive it without scrolling?

Yes. Set the fixed progress value, or call update with a manual percentage from your own timeline.