Highlighter Mark
A marker stroke sweeps behind the key phrase as the sentence comes into view, pointing the eye at what matters.
Good motion is felt before it is seen.
Create a highlighter sweep behind a phrase in a sentence, triggered when it enters the viewport. Wrap the key phrase in a <mark> with a transparent background colour and a background-image of linear-gradient(accent at 45% opacity) sized 0% 40% and positioned at left 85%, with background-repeat: no-repeat and box-decoration-break: clone so it works across line breaks. When an IntersectionObserver reports the sentence at 50% visibility, set a data attribute that animates background-size to 100% 40% over 800ms with cubic-bezier(0.16, 1, 0.3, 1) after a 200ms delay. Add a Replay button that removes and re-adds the attribute. Under prefers-reduced-motion, show the highlight already filled. The sentence reads "Good motion is felt before it is seen." with "felt before it is seen" highlighted.
Source
'use client'
import { useEffect, useRef } from 'react'
import s from './demo.module.css'
export default function HighlighterMark({ labels }: { labels: Record<string, string> }) {
const ref = useRef<HTMLParagraphElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const io = new IntersectionObserver(
([e]) => {
if (!e.isIntersecting) return
el.setAttribute('data-on', '')
io.disconnect()
},
{ threshold: 0.5 },
)
io.observe(el)
return () => io.disconnect()
}, [])
const replay = () => {
const el = ref.current
if (!el) return
el.removeAttribute('data-on')
void el.offsetWidth
el.setAttribute('data-on', '')
}
return (
<div className={s.wrap}>
<p ref={ref} className={s.text}>
{labels.before} <mark className={s.mark}>{labels.mark}</mark>
{labels.after}
</p>
<button type="button" className={s.replay} onClick={replay}>
{labels.replay}
</button>
</div>
)
}
.wrap {
display: grid;
justify-items: center;
gap: 16px;
max-width: 300px;
}
.text {
margin: 0;
font: 600 var(--text-xl) / 1.4 var(--font-display);
text-align: center;
color: var(--stage-ink);
}
.mark {
background-color: transparent;
background-image: linear-gradient(color-mix(in oklch, var(--stage-accent) 45%, transparent), color-mix(in oklch, var(--stage-accent) 45%, transparent));
background-repeat: no-repeat;
background-position: left 85%;
background-size: 0% 40%;
color: inherit;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
.text[data-on] .mark {
animation: sweep 800ms var(--ease-out) 200ms forwards;
}
@keyframes sweep {
to { background-size: 100% 40%; }
}
.replay {
height: 32px;
padding: 0 12px;
border: 1px solid var(--stage-rule);
border-radius: var(--radius-sm);
background: var(--stage-surface);
color: var(--stage-ink);
font: 500 var(--text-sm) / 1 var(--font-body);
white-space: nowrap;
cursor: pointer;
}
.replay:hover { border-color: var(--stage-accent); }
.replay:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.mark { background-size: 100% 40%; }
.text[data-on] .mark { animation: none; }
}