Scroll-Driven Text Highlight Effects

Part of Scroll-Driven Animation Patterns in Modern View Transitions & Scroll APIs.

The problem

An editorial article wants key phrases to be “highlighted” with a marker stroke as the reader scrolls to them. The first attempt uses a pseudo-element with transform: scaleX(), which looks great on single-line phrases and breaks on phrases that wrap: the pseudo-element spans the bounding box of both lines and draws a solid block across the gap. A second effect fades each word of a pull quote in as it scrolls, and on some screens half the quote is faint while the reader is trying to read it.

Text highlights need a technique that follows line boxes, a range that finishes before the reader arrives, and restraint about readability.

Root cause analysis: inline boxes, not rectangles

Inline elements are fragmented across lines. An inline mark or span that wraps becomes several line fragments. A pseudo-element or a transformed child is a single box covering the element’s bounding rectangle, so it cannot follow the fragments. Backgrounds do follow fragments: an inline element’s background is painted per line fragment. With box-decoration-break: clone, each fragment gets its own full background, padding and rounded corners, so a highlight looks correct on every line.

background-size makes the stroke grow. A linear-gradient background with background-size: 0% 40% and background-repeat: no-repeat draws nothing; animating to 100% 40% draws a band that grows from left to right. With clone, each line fragment’s band grows at the same time. Without clone, the background is laid out as if the fragments were joined into one long line, so the first line fills before the second starts — which can be a nice sequential effect, or not, depending on design.

It is a paint animation. Changing background-size repaints the element’s line fragments each frame. For a phrase this costs very little; for whole paragraphs driven by scroll it adds up. Scroll-driven animations that animate paint properties are updated on the main thread, unlike transform and opacity, so they can lag behind fast scrolls on slow devices — covered in profiling scroll-driven animations in DevTools.

Range decides readability. A highlight that completes only when the text reaches the top of the viewport forces the reader to wait or reread. Finishing within the first half of the element’s passage — for example animation-range: entry 20% cover 45% — means the effect is done by the time the text is in a comfortable reading position.

Pseudo-element stroke against background highlight on wrapped textOnly per-fragment backgrounds follow the lines.Pseudo-element stroke against background highlight on wrapped text::after with scaleXOne box over the bounding rectangleSolid block across the line gapComposite, but visually wrongBreaks on wrapbackground-size + clonePainted per line fragmentEach line gets its own strokePaint per frame, small areaCorrect on every line
Only per-fragment backgrounds follow the lines.

Step-by-step resolution

A marker highlight that wrapsBackground per fragment, grown by scroll, finished early.A marker highlight that wraps1Wrap the phrase in mark with a gradient background and no-repeat.A band ready to grow2Add box-decoration-break: clone and a little inline padding.Each line fragment gets its own band3Animate background-size from 0% to 100% width.The stroke fills4Set animation-timeline: view() and animation-range: entry 20% cover 45%.Complete before reading position5Keep the text colour constant and fully opaque.Readable at every scroll position6Show the finished highlight under reduced motion.
Background per fragment, grown by scroll, finished early.

Production code pattern

.highlight {
  --stroke: color-mix(in oklch, var(--color-accent-300) 70%, transparent);
  background-image: linear-gradient(var(--stroke), var(--stroke));
  background-repeat: no-repeat;
  background-position: 0 85%;
  background-size: 100% 40%;                        /* final state is the base style */
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
  padding-inline: 0.15em;
  color: inherit;                                   /* text never fades */
}

@supports (animation-timeline: view()) {
  .highlight {
    animation: highlight-fill linear both;
    animation-timeline: view();
    animation-range: entry 20% cover 45%;
  }
  @keyframes highlight-fill {
    from { background-size: 0% 40%; }
  }
}

/* Pull quote: words brighten slightly, never become unreadable. */
.pull-quote .word {
  animation: word-in linear both;
  animation-timeline: view();
  animation-range: entry 10% cover 35%;
}
@keyframes word-in {
  from { opacity: 0.55; }                           /* readable minimum, not 0 */
}

@media (prefers-reduced-motion: reduce) {
  .highlight, .pull-quote .word { animation: none; }
}

Rendering Impact: paint for the highlight’s background-size, bounded to the phrase’s line fragments; composite for the words’ opacity. The highlight is updated on the main thread as the page scrolls, so keep it to phrases rather than paragraphs.

Putting the finished highlight in the base style and the animation inside @supports gives every browser a correct static result: unsupported browsers and reduced-motion users see the highlight already drawn, not an invisible band waiting for an animation that never runs.

Main-thread paint per scroll frameMid-range phone scrolling through an article.Main-thread paint per scroll framebudget 16.7 msThree highlighted phrases0.6 msTwelve highlighted phrases in view2.2 msWhole paragraph background animation7.9 msWord opacity pull quote (40 words)0.3 ms
Mid-range phone scrolling through an article.

Splitting words without harming accessibility

Per-word effects need each word in its own element. Splitting text into spans can make screen readers announce words separately or pause between them. Keep the original text accessible: put the full sentence in the element as normal text for assistive technology, and mark the split visual copy aria-hidden="true", or set aria-label on the container with the full quote. Better still, avoid per-word effects on body text entirely and keep them to short display quotes. For headlines that need more drama, animating the whole element with a composite property is cheaper and reads better.

Verification checklist

Constraints and trade-offs

  • background-size animation is paint; limit the number of animated phrases in view.
  • box-decoration-break still needs the -webkit- prefix in some browsers.
  • Very tall line heights can misplace the band; position it with background-position relative to the font.
  • Per-word splitting increases DOM size and can affect text selection.
  • Scroll-linked paint animations can lag on slow devices during fast scrolling.

Frequently asked questions

How do I animate a highlight that works across line breaks?

Use a gradient background on an inline element with box-decoration-break: clone and animate background-size. Backgrounds are painted per line fragment.

Why does my pseudo-element highlight look wrong on wrapped text?

A pseudo-element is one box covering the whole element’s bounding rectangle, so it cannot follow separate line fragments.

Is a background-size scroll animation expensive?

It repaints on each scroll frame. For a few phrases it is cheap; for large blocks of text it can take several milliseconds per frame.

Should words fade in from zero opacity?

No. Readers may be reading as the text scrolls in. Start from a readable minimum opacity or avoid per-word fades on body text.