Emphasis — rule drawn under
Variant: Rule drawn under
A 2px rule growing left to right under the phrase in 308ms — the quietest mark in the family that is still a mark, and the right answer for a glossary term or a footnote anchor seen many times a page. It eases out because a rule under a word is arriving at the end of that word rather than passing through it; the one thing an underline must never look like is a stroke that kept going. It leaves with both edges travelling right rather than retreating, so the mark reads as wiped off instead of un-drawn, and the reset is invisible because the rule has zero width at both ends of it. Every dimension is in em, so it stays a hairline on a phone instead of a bar.
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 — rule drawn under</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));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--_read: color-mix(in srgb, var(--slate) 78%, 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;
}
.term {
position: relative;
color: var(--slate);
font-weight: 500;
}
/* Every dimension is in em, so the rule keeps its proportion to the type
at all four stage widths rather than turning into a heavy bar on a
phone. The 2px floor is there because a hairline under 13px copy
disappears on a low-density display. */
.term::after {
content: '';
position: absolute;
left: -0.06em;
right: -0.06em;
bottom: -0.17em;
height: max(2px, 0.085em);
border-radius: 999px;
background: var(--clay);
clip-path: inset(0 100% 0 0);
animation: rule var(--t) infinite;
}
/* 308ms to draw, ease-out. A rule under a word is arriving at the end of
that word, not passing through it — the one thing an underline must
never look like is a stroke that kept going. It leaves the way it came
in, both edges travelling right, so the mark reads as being wiped off
rather than un-drawn; the reset at 84.6% is invisible because the rule
has zero width at both ends of it. */
@keyframes rule {
0%,
14% {
clip-path: inset(0 100% 0 0);
animation-timing-function: var(--ease-out);
}
21%,
78% {
clip-path: inset(0 0 0 0);
animation-timing-function: var(--ease-out);
}
84.5% {
clip-path: inset(0 0 0 100%);
}
84.6%,
100% {
clip-path: inset(0 100% 0 0);
}
}
@media (prefers-reduced-motion: reduce) {
/* The travelling edge is the thing being removed. The rule is present
at full width the whole time and only its opacity moves, which keeps
the mark saying the same thing on a slower clock. */
.term::after {
clip-path: none;
animation-name: rule-flat;
animation-duration: calc(var(--t) * 1.7);
}
@keyframes rule-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.