Emphasis — highlighter sweep
Variant: Highlighter sweep
A translucent clay wash crossing behind the phrase in 330ms, wider and taller than the word because a marker is broader than a glyph and nobody stops the pen on the last letter. Linear, and that is the argument that separates it from the underline sibling: a rule decelerates into the end of a word, a highlighter does not — the pen crosses at the hand’s speed and the mark ends where it was lifted, so easing puts a deceleration in the ink no hand put there. It leaves as a fade rather than a second wipe, since ink does not un-draw and a wash retreating the way it came would claim the pass was being undone. The mix stops at a third: past that, slate stops being readable through it.
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Emphasis — highlighter sweep</title>
<style>
:root {
/* Neutrals — fixed, never remixed. */
--ivory: #faf9f5;
--paper: #ffffff;
--oat: #e3dacc;
--slate: #141413;
/* Knobs — the remix API. A knob exists iff declared here and used below. */
--hue: 38.8;
--lift: 0;
--chroma: 1;
--round: 1;
--speed: 1;
/* Accents — derived from the knobs with the family's offsets baked in,
so one hue roll moves them together. Defaults reproduce the original hex. */
--clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
--clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
--olive: oklch(
calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
);
--rust: oklch(
calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
);
/* Clock — 4.4s, shared across the emphasis family. The mark begins at
14% in every sibling and is fully released by 88%. */
--cycle: 4.4s;
--t: calc(var(--cycle) / var(--speed));
--_read: color-mix(in srgb, var(--slate) 78%, transparent);
/* A marker lays down translucent ink, so the wash is a mix rather than
a flat tint: slate has to stay legible through it, which rules out
anything much past a third. */
--_ink: color-mix(in srgb, var(--clay) 32%, transparent);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
padding: clamp(12px, 3vw, 28px);
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
}
.doc {
width: min(360px, 92vw);
padding: clamp(14px, 3.4vw, 22px) clamp(15px, 3.6vw, 24px);
border: 1px solid var(--oat);
border-radius: calc(14px * var(--round));
background: var(--paper);
color: var(--_read);
}
.eyebrow {
font-size: clamp(11.5px, 2.5vw, 13px);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.55;
}
.copy {
margin-top: clamp(7px, 1.4vw, 11px);
display: grid;
justify-items: start;
}
.line {
white-space: nowrap;
font-size: clamp(13px, 3.1vw, 18px);
line-height: 1.5;
letter-spacing: -0.005em;
}
/* z-index 0 makes the term its own stacking context, which is what keeps
the negative layer below underneath the glyphs and still above the
card. Without it the wash paints behind the card's background and
disappears entirely. */
.term {
position: relative;
z-index: 0;
color: var(--slate);
font-weight: 500;
}
/* The wash is wider and taller than the word it covers — a marker is
broader than a glyph and nobody stops the pen on the last letter. The
ends stay squared off rather than pill-shaped, because a chisel tip
leaves a blunt edge. */
.term::before {
content: '';
position: absolute;
z-index: -1;
inset: -0.17em -0.3em;
border-radius: calc(3px * var(--round));
background: var(--_ink);
clip-path: inset(0 100% 0 0);
animation: wash var(--t) infinite;
}
/* 330ms across, linear. The underline sibling eases out because a rule
arrives at the end of a word; a highlighter does not — the pen crosses
at the hand's speed and the mark stops where it was lifted. Easing it
puts a deceleration in the ink that no hand put there.
It leaves as a fade rather than a second wipe: ink does not un-draw,
and a wash retreating the way it came would claim the pass was being
undone instead of the emphasis being dropped. The clip resets at 88.1%
under zero opacity, so the rearm is never on screen. */
@keyframes wash {
0%,
14% {
opacity: 1;
clip-path: inset(0 100% 0 0);
animation-timing-function: linear;
}
21.5%,
78% {
opacity: 1;
clip-path: inset(0 0 0 0);
animation-timing-function: ease;
}
88% {
opacity: 0;
clip-path: inset(0 0 0 0);
}
88.1%,
100% {
opacity: 0;
clip-path: inset(0 100% 0 0);
}
}
@media (prefers-reduced-motion: reduce) {
/* No travelling edge: the wash is full width from the start and only
its opacity moves. The colour is the message and it stays, on a
clock stretched 1.7x. */
.term::before {
clip-path: none;
animation-name: wash-flat;
animation-duration: calc(var(--t) * 1.7);
}
@keyframes wash-flat {
0%,
14% {
opacity: 0;
animation-timing-function: ease;
}
24%,
78% {
opacity: 1;
animation-timing-function: ease;
}
90%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div class="doc">
<p class="eyebrow">Planner notes</p>
<div class="copy">
<p class="line">Rows come back in index order,</p>
<p class="line"><span class="term">not heap order</span>, so the seek</p>
<p class="line">still pays for every fetch.</p>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.