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.
Step-by-step resolution
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.
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-sizeanimation is paint; limit the number of animated phrases in view.box-decoration-breakstill needs the-webkit-prefix in some browsers.- Very tall line heights can misplace the band; position it with
background-positionrelative 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.
Related
- Scroll-Driven Animation Patterns — the parent topic
- Named Animation Ranges Explained — entry, cover and the other ranges
- Text Rendering Costs During Animation — why text paint is expensive