Text cycle — scramble to resolve
Variant: Scramble to resolve
A value arriving from somewhere else, settling one column at a time from the left. Two layers share a single clip edge driven by one steps(12) — the resolved string clipped from the right, the churning one clipped as its exact complement — which is why a character can never be drawn twice or dropped on the handover frame. The churn runs on its own 0.72s clock, deliberately not a divisor of the main one, so the noise never lands on the same glyph each time the front passes a column. Colour does the state: unresolved columns sit in clay because they are still in flight, and each one goes slate as the edge crosses it, so the boundary is legible without a marker drawn on it. Reach for it on an id, a hash or a token that was computed rather than typed — the churn is the claim that work happened, and the resolve tells the eye where to start reading.
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 cycle — scramble to resolve</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.2s. The resolve itself is 1.1s of it: twelve characters at
92ms, which is slow enough that the eye can watch the front advance
and fast enough that nobody is waiting on a value they can already
half read. */
--cycle: 5.2s;
--t: calc(var(--cycle) / var(--speed));
/* The churn runs on its own clock and is deliberately not a divisor of
the main one — a scramble that lands on the same glyph every time the
front passes a column stops looking like noise. */
--_churn: 0.72s;
--_read: color-mix(in srgb, var(--slate) 55%, transparent);
--_pending: color-mix(in srgb, var(--clay) 62%, 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;
color: var(--slate);
}
.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: var(--_read);
}
/* Twelve characters wide in ch, so the clip edge that both layers share
lands on a glyph boundary rather than through one. A proportional face
would put the boundary inside a letter at almost every step and the
resolve would read as a wipe. */
.value {
position: relative;
display: inline-block;
margin-top: clamp(8px, 1.6vw, 13px);
width: 12ch;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: clamp(17px, 5.4vw, 28px);
line-height: 1.35;
letter-spacing: 0;
animation: reset var(--t) infinite;
}
.real {
position: relative;
display: block;
white-space: pre;
color: var(--slate);
clip-path: inset(0 100% 0 0);
animation: resolve var(--t) infinite;
}
.noise {
position: absolute;
inset: 0;
overflow: hidden;
color: var(--_pending);
clip-path: inset(0 0 0 0);
animation: dissolve var(--t) infinite;
}
/* Twenty glyphs behind a twelve-glyph window, stepped a column at a time.
The eight offsets are all the variety a churn needs: nobody is tracking
which character came back, only that the column has not settled. */
.churn {
display: block;
white-space: pre;
animation: churn calc(var(--_churn) / var(--speed)) steps(8) infinite;
}
@keyframes churn {
from {
transform: translateX(0);
}
to {
transform: translateX(-8ch);
}
}
/* The two clips are exact complements driven by the same steps(12), which
is the entire reason a character can never be drawn twice or dropped on
the handover frame — there is one edge in this file, and both layers are
reading it. jump-start so the first column settles on the frame the
resolve begins rather than a character-time later. */
@keyframes resolve {
0% {
clip-path: inset(0 100% 0 0);
}
10% {
clip-path: inset(0 100% 0 0);
animation-timing-function: steps(12, jump-start);
}
31.15%,
94% {
clip-path: inset(0 0 0 0);
}
94.1%,
100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes dissolve {
0% {
clip-path: inset(0 0 0 0);
}
10% {
clip-path: inset(0 0 0 0);
animation-timing-function: steps(12, jump-start);
}
31.15%,
94% {
clip-path: inset(0 0 0 100%);
}
94.1%,
100% {
clip-path: inset(0 0 0 0);
}
}
/* The value goes to zero between 91% and 96.5% and both clips snap back
inside that window, so the resolve never plays in reverse. The return
is a fade rather than a cut because a value re-entering unresolved is
the honest opening frame — it has not arrived yet. */
@keyframes reset {
0% {
opacity: 1;
animation-timing-function: ease;
}
88% {
opacity: 1;
animation-timing-function: ease;
}
91%,
96.5% {
opacity: 0;
animation-timing-function: ease;
}
100% {
opacity: 1;
}
}
.meta {
margin-top: clamp(6px, 1.2vw, 10px);
font-size: clamp(11.5px, 2.6vw, 13.5px);
color: var(--_read);
}
@media (prefers-reduced-motion: reduce) {
/* Gentler, not absent. The resolve stays — it is the meaning — but the
churn drops to a third of its rate, because a glyph changing every
90ms is a flicker before it is an animation and this is the audience
that setting exists for. */
.value {
animation-duration: calc(var(--t) * 1.7);
}
.real,
.noise {
animation-duration: calc(var(--t) * 1.7);
}
.churn {
animation-duration: calc(var(--_churn) * 3 / var(--speed));
}
}
</style>
</head>
<body>
<div class="card">
<p class="eyebrow">Commit</p>
<div class="value" role="img" aria-label="Commit 8f2c41ae9d3b resolving.">
<span class="real" aria-hidden="true">8f2c41ae9d3b</span>
<span class="noise" aria-hidden="true">
<span class="churn">3b7f0a9e4c1d6528af07</span>
</span>
</div>
<p class="meta">main · signed · 2 minutes ago</p>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.