Highlighter draw
A headline lands, then one key phrase gets a marker stroke that draws in once from left to right.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useInView } from "@/lib/motion";
import styles from "./demo.module.css";
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const inView = useInView(root);
const [started, setStarted] = useState(false);
const [run, setRun] = useState(0);
useEffect(() => {
if (inView) setStarted(true);
}, [inView]);
return (
<div ref={root} className={styles.root}>
<div key={run} className={`${styles.copy} ${started ? styles.play : ""}`}>
<p className={styles.eyebrow}>For villa owners</p>
<h2 className={styles.title}>
Turn the empty nights between bookings into <mark className={styles.hl}>three night stays that pay</mark>.
</h2>
</div>
<button type="button" className={styles.replay} onClick={() => setRun((r) => r + 1)}>
Replay
</button>
</div>
);
}
demo.module.css
.root { container-type: size; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; padding: 20px; text-align: center; }
.copy { display: grid; gap: 12px; justify-items: center; }
.eyebrow { font-size: 13px; font-weight: 600; color: var(--accent-text); }
.title { max-width: 17em; font-size: clamp(22px, min(6.4cqi, 10cqh), 50px); line-height: 1.14; letter-spacing: -0.03em; font-weight: 650; text-wrap: balance; }
.hl {
color: inherit;
background: linear-gradient(var(--accent-soft), var(--accent-soft)) no-repeat 0 88% / 0% 0.42em;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
padding: 0 0.06em;
margin: 0 -0.04em;
}
.copy:not(.play) > * { opacity: 0; }
.play > * { animation: land 0.7s var(--out) both; }
.play .title { animation-delay: 0.08s; }
.play .hl { animation: draw 0.9s var(--out) 0.6s forwards; }
@keyframes land { from { opacity: 0; translate: 0 0.4em; } }
@keyframes draw { to { background-size: 100% 0.42em; } }
.replay { min-height: 44px; padding: 0 18px; border-radius: 999px; border: 1px solid var(--line); background: var(--white); font-size: 14px; font-weight: 600; cursor: pointer; transition: border-color 0.18s; }
.replay:hover { border-color: var(--ink); }
.replay:active { scale: 0.97; }
@media (prefers-reduced-motion: reduce) {
.copy > *, .play > * { opacity: 1; animation: none; }
.hl, .play .hl { animation: none; background-size: 100% 0.42em; }
}
Use it for
- The single promise in a hero headline
- Pricing or feature lines where one phrase carries the value
Avoid
- More than one highlight per screen, it stops meaning anything
- Body copy, where a moving mark pulls the eye away from reading
How it works
The phrase is a mark element with a flat linear-gradient background sized 0% wide and 0.42em tall, sitting low behind the letters. A keyframe animation grows background-size to 100% after a short delay, so it starts once the headline has landed. box-decoration-break: clone gives every wrapped line its own stroke. Replay remounts the copy with a new key.
Accessibility
The text is a real heading and mark element, so screen readers get it once. Reduced motion skips both animations and shows the full stroke. The stroke uses a light tint so the text keeps its contrast.