9 CSS Text Highlight Animations, Copy-Paste
A css text highlight animation calls attention to one word or line with a highlighter stroke, an underline, or a strikethrough, without touching the letters
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Highlight marker
- 02 Marker overlap
- 03 Hand-drawn underline
- 04 Wavy underline
- 05 Strikethrough draw-on
- 06 Background fill
- 07 Dotted line growing
- 08 Color sweep
- 09 Text shadow pop
These nine aren't ranked by popularity; they're ordered by when each one actually gets reached for. 01 through 03 paint a key phrase inside a block of text. 04 and 05 mark a warning or a finished state. 06 and 07 lead a reader's eye along a link or a step. 08 and 09 close things out by making a badge or a single number pop for a beat. Each section below keeps only the lines that matter; the complete nine files, plus a React version of each, sit in the zip.
01Highlight marker
A yellow block sits behind the keyword and grows left to right with transform: scaleX(0→1), then mix-blend-mode: multiply lays that block over the letters so it reads as a highlighter stroke just laid down. Reach for it on a key phrase inside terms or a notice, or on the one word a tutorial wants a reader's eye to land on first.
.hl {
position: relative;
isolation: isolate;
&::before {
content: ""; position: absolute; inset: 12% -2% 6%;
background: $color; border-radius: $r-xs;
transform: scaleX(0); transform-origin: left center;
mix-blend-mode: multiply; z-index: -1;
animation: mark $duration $easing infinite;
}
}
@keyframes mark {
0% { transform: scaleX(0); }
20% { transform: scaleX(1); }
75% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
02Marker overlap
The same highlighter block appears twice, as ::before and ::after, each rotated a different couple of degrees and started on its own animation-delay so the two passes overlap unevenly. It suits the single most important word on a page — a product name, an FAQ's answer keyword — where two visible strokes carry more weight than one.
.hl::before, .hl::after {
content: ""; position: absolute; inset: 10% -2% 4%;
transform: scaleX(0); transform-origin: left center;
mix-blend-mode: multiply; z-index: -1;
}
.hl::before { background: $stage-yellow; rotate: -1.2deg; animation: mark $duration $easing infinite; }
.hl::after { background: $color; opacity: .55; rotate: 1deg; animation: mark $duration $easing infinite .3s; }
03Hand-drawn underline
An inline SVG path draws a slightly uneven curve under the word, and animating stroke-dashoffset from the path's own length down to zero traces that line in from the left exactly like a pen would. A pull-quote's key phrase or the emphasized word on an about page both benefit from the extra beat it takes to draw.
.hl__path {
fill: none; stroke: $color; stroke-width: 3; stroke-linecap: round;
stroke-dasharray: 140; stroke-dashoffset: 140;
animation: draw $duration $easing infinite;
}
@keyframes draw {
0% { stroke-dashoffset: 140; }
35% { stroke-dashoffset: 0; }
80% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -140; }
}
04Wavy underline
Native text-decoration-line: underline and text-decoration-style: wavy draw the line itself, while a <length> value registered through @property animates text-underline-offset back and forth so the wave appears to breathe rather than sit still. It reads naturally on a spell-check-style warning or any copy meant to keep pulling a reader's eye back.
@property --underline-offset {
syntax: "<length>"; inherits: true; initial-value: 1px;
}
.hl {
text-decoration-line: underline;
text-decoration-style: wavy;
text-decoration-color: $color;
text-underline-offset: var(--underline-offset);
animation: wave $duration linear infinite;
}
@keyframes wave {
0%, 100% { --underline-offset: 0px; }
50% { --underline-offset: 5px; }
}
05Strikethrough draw-on
A separate .price__strike bar sits at mid-text height and grows with transform: scaleX(0→1) from the left edge, staging the instant a price gets struck through in front of the reader. It fits a discounted price sitting next to its original, or a completed line in a to-do list.
.price__strike {
position: absolute; left: -2%; right: -2%; top: 52%; height: 2px;
background: $color;
transform: scaleX(0); transform-origin: left center;
animation: strike $duration $easing infinite;
}
@keyframes strike {
0% { transform: scaleX(0); }
30% { transform: scaleX(1); }
80% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
06Background fill
A linear-gradient sits behind a link and background-size grows from 0% 100% to 100% 100%, while background-position: left keeps the filled edge pinned so it never slides backward. Navigation links and text buttons inside a card both read the fill as color simply arriving from the left.
.nav {
background: linear-gradient(90deg, $color, $color);
background-repeat: no-repeat;
background-position: left center;
background-size: 0% 100%;
animation: fill $duration $easing infinite;
}
@keyframes fill {
0% { background-size: 0% 100%; }
25% { background-size: 100% 100%; }
75% { background-size: 100% 100%; }
100% { background-size: 0% 100%; }
}
07Dotted line growing
A thin bar painted with repeating-linear-gradient dots sits under the text and stretches out with transform: scaleX(0→1), so the dotted line looks like it's being laid down one dot at a time. A step-by-step guide's progress path or a footnote's connector line both benefit from a line that visibly grows rather than just appearing.
.hl__dots {
background-image: repeating-linear-gradient(90deg, $color 0 3px, transparent 3px 8px);
transform: scaleX(0); transform-origin: left center;
animation: grow $duration $easing infinite;
}
@keyframes grow {
0% { transform: scaleX(0); }
40% { transform: scaleX(1); }
80% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
08Color sweep
A diagonal gradient behind a badge spans 250% of the badge's own width. Sliding background-position from 150% down to -150% on a linear timing keeps one strip of light sweeping across with no visible seam. Promo badges and new-or-limited labels use the sweep to keep drawing a glance back without ever fully stopping.
.badge::after {
background: linear-gradient(115deg, transparent 30%, $color 48%, transparent 66%);
background-size: 250% 100%;
mix-blend-mode: overlay;
animation: sweep $duration linear infinite;
}
@keyframes sweep {
0% { background-position: 150% 0; }
100% { background-position: -150% 0; }
}
09Text shadow pop
A <length> value registered through @property drives the blur radius inside filter: drop-shadow() from 0 up to 10px, and pairing that with transform: scale(1→1.06→1) makes a keyword pop once rather than glow the whole time. It suits a pricing table's final number or a countdown digit that needs one moment of emphasis, not a constant loop.
@property --shadow-blur {
syntax: "<length>"; inherits: false; initial-value: 0px;
}
.hl {
filter: drop-shadow(0 0 var(--shadow-blur) $color);
animation: pop $duration $easing infinite;
}
@keyframes pop {
0% { transform: scale(1); --shadow-blur: 0px; }
40% { transform: scale(1.06); --shadow-blur: 10px; }
70% { transform: scale(1); --shadow-blur: 4px; }
100% { transform: scale(1); --shadow-blur: 0px; }
}
Where does the highlight actually break?
Two of these nine, 01 and 02, rendered as nothing at all the first time they ran. Both hide their highlighter block behind the word with z-index: -1, and without isolation: isolate declared on .hl itself, that negative index doesn't get a stacking context of its own. It falls back to whichever ancestor opens one first, which here was the demo stage's own background layer. The block painted underneath the entire stage instead of just behind its own letters, and render measurement caught it exactly: 0% of the frame ever changed on either item. Adding one line, isolation: isolate, on .hl gave the block its own stacking context, and the same measurement then read 0.81% and 1.12% once the highlight was actually sitting on top of the text. MDN's isolation reference covers why a negative z-index needs a stacking context of its own before it means anything at all.
Item 04 broke in a quieter way. The first pass animated text-underline-offset only between 1px and 3px, and that 2px swing landed under this project's per-pixel change threshold often enough that the render check counted just 4 of 23 sampled frames as moving. That fell short of the 6 needed to call it an animation rather than a still image. Widening the range to 0px–5px and switching the easing to linear fixed it outright, and the next pass read 23 of 23 frames as moving. Both text-underline-offset and its sibling text-decoration-thickness are safe to ship today: MDN's compatibility table for text-underline-offset puts support at Chrome and Edge 89, Safari 15, and Firefox 70, and MDN's table for text-decoration-thickness lists the same three numbers. But a range that's too subtle can still read as broken, even on a browser that supports the property fully. The full set, with both fixes already baked in, unlocks with hx9yh6mr exactly as it's spelled on this line, nothing capitalized and nothing added.
Accessibility
Every one of these nine still shows the emphasis itself under prefers-reduced-motion: reduce; only the motion getting there disappears. 01 and 02 lock their highlighter blocks at scaleX(1), already painted rather than caught mid-stroke. 04's wave settles on a fixed text-underline-offset instead of oscillating, and 09 keeps its drop-shadow blur steady at 4px with the bounce removed. 03 and 07 each add a decorative layer — an SVG path, a dotted bar — that carries no text of its own. Both mark that layer aria-hidden="true", so a screen reader skips the drawing and reads straight through to the sentence, which none of the nine ever touches. MDN's prefers-reduced-motion page lists the operating-system settings that flip this media query on.
More CSS-only text effects live on the CSS category hub; how this site is run, and what a zip actually contains, is on the about page.
FAQ
Can I use just one of these, not the whole set of nine?
Yes — every item is scoped to its own class with no shared parent state, so copying one item's SCSS along with its matching HTML span is enough on its own. 01's highlighter marker and 06's background fill are the simplest starting points if only one effect is needed on a page.
Does a css text highlight animation still read fine on a screen reader?
The letters themselves are never touched by any of the nine — only a background layer, an underline, or a shadow moves around them, so the sentence reads exactly as written underneath. Items 03 and 07 add their own SVG or dotted-bar layer and mark it aria-hidden="true", so that decoration gets skipped instead of announced.
Do any of these need a build step, or does plain CSS work?
Plain CSS carries all nine; nothing depends on Sass beyond variables such as $duration and $color at the top of each file, which compile down to fixed values either way. 04 and 09 lean on @property, which registers directly in CSS with no preprocessor step, so both keep working if the SCSS is swapped for its compiled output untouched.