Text edit — redline resolved
Variant: Redline resolved
The tracked-changes beat: a sentence carrying both its readings — the old word struck in rust, the replacement underlined in olive — resolves into clean copy. Order does the arguing. The strike draws linear, a pen crossing the word at hand speed, before the underline endorses the replacement; the struck word finishes fading before the gap closes, because a document closes the space of a word that is gone, never one still being read; and the olive ink takes 560ms to settle into the body’s own ink only after it has landed, since becoming ordinary is what acceptance means. The tail travels exactly 7ch — the piece is set in monospace because proportional type cannot promise where a tail lands. Reach for it where a review flow shows a proposal and its resolution on one surface.
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>Text edit — redline resolved</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 — 5.6s: the proposal is readable by 16%, the edit executes
between 40% and 64%, and the seam hides in a dark window at
93.5–96.5%. */
--cycle: 5.6s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--_read: color-mix(in srgb, var(--slate) 62%, transparent);
--_struck: color-mix(in srgb, var(--rust) 58%, transparent);
--_olive-ink: color-mix(in oklab, var(--olive) 72%, var(--slate));
}
* {
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);
color: var(--slate);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
}
.card {
width: min(360px, 92vw);
padding: clamp(15px, 3.6vw, 24px) clamp(16px, 3.8vw, 26px);
border: 1px solid var(--oat);
border-radius: calc(14px * var(--round));
background: var(--paper);
}
.eyebrow {
font-size: clamp(11px, 2.4vw, 12.5px);
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: color-mix(in srgb, var(--slate) 55%, transparent);
}
/* Monospace on purpose, as in the splice piece: the resolution slides
the tail left by exactly the struck word plus its space — 7ch — and
only a mono face can promise that 7ch is where the tail lands. */
.line {
margin-top: clamp(10px, 2vw, 15px);
white-space: pre;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: clamp(14px, 4vw, 18px);
line-height: 1.6;
color: var(--_read);
animation: seam var(--t) infinite;
}
.old,
.new,
.shift {
position: relative;
display: inline-block;
}
.old {
animation: old-ink var(--t) infinite;
}
/* The strike sits at x-height middle and draws left to right, linear —
it is a pen crossing the word at the hand's speed, and easing it puts a
deceleration in the ink no hand put there. */
.old::after {
content: '';
position: absolute;
left: -0.08em;
right: -0.08em;
top: 50%;
height: max(2px, 0.085em);
transform: translateY(-50%);
border-radius: 999px;
background: var(--rust);
clip-path: inset(0 100% 0 0);
animation: strike var(--t) infinite;
}
.new {
animation: new-ink var(--t) infinite;
}
/* The proposal's underline is olive and arrives on the underline family
curve — ease-out, because a rule under a word arrives at the end of
that word rather than passing through it. */
.new::after {
content: '';
position: absolute;
left: -0.06em;
right: -0.06em;
bottom: -0.14em;
height: max(2px, 0.08em);
border-radius: 999px;
background: var(--olive);
clip-path: inset(0 100% 0 0);
animation: rule var(--t) infinite;
}
.shift {
animation: close-gap var(--t) infinite;
}
/* 0–16%: the proposal marks itself. The strike takes 336ms and the
underline follows — deletion is claimed before the replacement is
endorsed, which is the reading order of every redline. */
@keyframes strike {
0%,
4% {
clip-path: inset(0 100% 0 0);
animation-timing-function: linear;
}
10%,
94% {
clip-path: inset(0 0 0 0);
}
95%,
100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes old-ink {
0%,
4% {
color: var(--_read);
opacity: 1;
animation-timing-function: ease;
}
10%,
40% {
color: var(--_struck);
opacity: 1;
animation-timing-function: ease;
}
43%,
94% {
color: var(--_struck);
opacity: 0;
}
95%,
100% {
color: var(--_read);
opacity: 1;
}
}
@keyframes rule {
0%,
10% {
clip-path: inset(0 100% 0 0);
animation-timing-function: var(--ease-out);
}
15.5%,
54% {
clip-path: inset(0 0 0 0);
animation-timing-function: var(--ease-out);
}
58.5%,
94% {
/* It leaves by its trailing edge travelling right — wiped off
toward the word's end, not un-drawn: withdrawing the mark must
not read as withdrawing the endorsement. */
clip-path: inset(0 0 0 100%);
}
95%,
100% {
clip-path: inset(0 100% 0 0);
}
}
/* The replacement opens at 55% opacity so the line never reads as a
duplicated-word typo, then takes full olive ink as its underline
endorses it. It settles to the body's own ink over 560ms only after
it has landed in place — becoming ordinary is what acceptance means,
and it is the slowest beat in the file on purpose. */
@keyframes new-ink {
0%,
10% {
color: var(--_olive-ink);
opacity: 0.55;
animation-timing-function: ease;
}
15.5%,
54% {
color: var(--_olive-ink);
opacity: 1;
animation-timing-function: ease;
}
64%,
94% {
color: var(--_read);
opacity: 1;
}
95%,
100% {
color: var(--_olive-ink);
opacity: 0.55;
}
}
/* The gap closes only after the struck word has finished fading — a
document closes the space of a word that is gone, never one still
being read. 448ms on the in-out curve: the tail is already on screen
and merely moving. */
@keyframes close-gap {
0%,
44% {
transform: translateX(0);
animation-timing-function: var(--ease-in-out);
}
52%,
94% {
transform: translateX(-7ch);
}
95%,
100% {
transform: translateX(0);
}
}
/* The loop's rewind happens inside a dark window: the line dims out at
90%, every element snaps home at 95%, and the proposal is already
standing when the light comes back. */
@keyframes seam {
0%,
90% {
opacity: 1;
animation-timing-function: ease;
}
93.5%,
96.5% {
opacity: 0;
animation-timing-function: ease;
}
100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
/* Gentler, not absent: the travel goes — the tail snaps home inside a
single frame instead of sliding — and the marks fade in place rather
than drawing. The colour story, which carries the meaning, stays. */
.line,
.old,
.new,
.old::after,
.new::after,
.shift {
animation-duration: calc(var(--t) * 1.7);
}
.old::after {
clip-path: none;
animation-name: strike-rm;
}
.new::after {
clip-path: none;
animation-name: rule-rm;
}
.shift {
animation-name: close-gap-rm;
}
@keyframes strike-rm {
0%,
4% {
opacity: 0;
animation-timing-function: ease;
}
10%,
94% {
opacity: 1;
}
95%,
100% {
opacity: 0;
}
}
@keyframes rule-rm {
0%,
10% {
opacity: 0;
animation-timing-function: ease;
}
15.5%,
54% {
opacity: 1;
animation-timing-function: ease;
}
58.5%,
94% {
opacity: 0;
}
95%,
100% {
opacity: 0;
}
}
@keyframes close-gap-rm {
0%,
51.9% {
transform: translateX(0);
}
52%,
94% {
transform: translateX(-7ch);
}
95%,
100% {
transform: translateX(0);
}
}
}
</style>
</head>
<body>
<div class="card">
<p class="eyebrow">Tracked changes</p>
<p
class="line"
role="img"
aria-label="The sentence 'Ship it on Friday.' with Friday struck through and replaced by Monday."
><span class="head">Ship it on </span><span class="old">Friday</span> <span class="shift"><span class="new">Monday</span><span class="tail">.</span></span></p>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.