Text cycle — glitch
Variant: Glitch
The loudest thing in the library and the one with the narrowest brief: 464ms of disturbance inside a 5.2s loop, a 9% duty cycle, on a label whose entire message is that a connection is intermittent. Three decisions keep it from being decoration. The bursts are two different lengths and the gaps between them are 35% and 62% of the loop, because interference that arrives on the beat stops being interference and becomes a component. Every frame is a steps(1) hold, so nothing is ever caught mid-travel — a glitch that interpolates is a wobble, and the read comes from each frame being a discrete wrong state. And the two ghosts cut at four frames against five, on separate phases, so they come apart rather than reading as one displaced copy with a colour fringe. The word underneath never moves more than a fiftieth of an em: the ghosts are colour and can be as loud as they like, but the base is the thing being read. Never put it on body copy, and never on anything seen more than a few times a session.
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 — glitch</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 carrying 464ms of disturbance, a 9% duty cycle. The two
bursts are 290ms and 174ms and the gaps between them are 35% and 62%
of the loop, because interference that arrives on the beat stops being
interference and becomes a component. */
--cycle: 5.2s;
--t: calc(var(--cycle) / var(--speed));
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--_read: color-mix(in srgb, var(--slate) 55%, 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);
}
.glitch {
position: relative;
display: inline-block;
margin-top: clamp(8px, 1.6vw, 13px);
font-size: clamp(18px, 5.4vw, 30px);
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1.2;
white-space: nowrap;
animation: jolt var(--t) infinite;
}
/* Both ghosts are the same string as the word beneath them, taken from one
attribute — there is no second copy in the markup to fall out of sync
with the first. Multiply is what stops them reading as three words
stacked: where a ghost lands on the base it darkens it, and only where
it has been displaced does it show as its own colour. */
.glitch::before,
.glitch::after {
content: attr(data-text);
position: absolute;
inset: 0;
opacity: 0;
mix-blend-mode: multiply;
pointer-events: none;
}
.glitch::before {
color: var(--clay);
animation: ghost-a var(--t) infinite;
}
.glitch::after {
color: var(--olive);
animation: ghost-b var(--t) infinite;
}
/* Every stop holds until the next one — steps(1, jump-end) throughout, so
nothing in a burst is ever caught mid-travel. A glitch that interpolates
is a wobble; the whole read comes from each frame being a discrete
wrong state and the next one being a different wrong state. */
@keyframes ghost-a {
0% {
opacity: 0;
transform: translate(0, 0);
clip-path: inset(0 0 100% 0);
animation-timing-function: steps(1, jump-end);
}
26% {
opacity: 1;
transform: translate(-0.17em, -0.03em);
clip-path: inset(6% 0 63% 0);
animation-timing-function: steps(1, jump-end);
}
27.115% {
transform: translate(0.15em, 0.02em);
clip-path: inset(46% 0 24% 0);
animation-timing-function: steps(1, jump-end);
}
28.23% {
transform: translate(-0.23em, 0);
clip-path: inset(21% 0 51% 0);
animation-timing-function: steps(1, jump-end);
}
29.346% {
transform: translate(0.11em, -0.035em);
clip-path: inset(68% 0 6% 0);
animation-timing-function: steps(1, jump-end);
}
30.46% {
transform: translate(-0.08em, 0.015em);
clip-path: inset(33% 0 42% 0);
animation-timing-function: steps(1, jump-end);
}
31.577% {
opacity: 0;
transform: translate(0, 0);
clip-path: inset(0 0 100% 0);
animation-timing-function: steps(1, jump-end);
}
61% {
opacity: 1;
transform: translate(0.18em, 0.03em);
clip-path: inset(14% 0 55% 0);
animation-timing-function: steps(1, jump-end);
}
62.115% {
transform: translate(-0.13em, -0.02em);
clip-path: inset(52% 0 20% 0);
animation-timing-function: steps(1, jump-end);
}
63.23% {
transform: translate(0.09em, 0);
clip-path: inset(28% 0 46% 0);
animation-timing-function: steps(1, jump-end);
}
64.346%,
100% {
opacity: 0;
transform: translate(0, 0);
clip-path: inset(0 0 100% 0);
}
}
/* The second ghost runs four frames against the first's five, on its own
phase. Two layers cutting on the same frame read as one displaced copy
with a colour fringe; two layers cutting at rates that do not divide
into each other read as a signal coming apart. */
@keyframes ghost-b {
0% {
opacity: 0;
transform: translate(0, 0);
clip-path: inset(0 0 100% 0);
animation-timing-function: steps(1, jump-end);
}
26.5% {
opacity: 1;
transform: translate(0.21em, 0.035em);
clip-path: inset(30% 0 38% 0);
animation-timing-function: steps(1, jump-end);
}
27.9% {
transform: translate(-0.14em, -0.025em);
clip-path: inset(62% 0 12% 0);
animation-timing-function: steps(1, jump-end);
}
29.3% {
transform: translate(0.1em, 0.015em);
clip-path: inset(10% 0 66% 0);
animation-timing-function: steps(1, jump-end);
}
30.7% {
transform: translate(-0.19em, 0);
clip-path: inset(48% 0 30% 0);
animation-timing-function: steps(1, jump-end);
}
31.577% {
opacity: 0;
transform: translate(0, 0);
clip-path: inset(0 0 100% 0);
animation-timing-function: steps(1, jump-end);
}
61.4% {
opacity: 1;
transform: translate(-0.16em, -0.025em);
clip-path: inset(38% 0 34% 0);
animation-timing-function: steps(1, jump-end);
}
62.9% {
transform: translate(0.12em, 0.02em);
clip-path: inset(8% 0 68% 0);
animation-timing-function: steps(1, jump-end);
}
64.346%,
100% {
opacity: 0;
transform: translate(0, 0);
clip-path: inset(0 0 100% 0);
}
}
/* The word itself moves for two frames of the first burst and one of the
second, and never more than a twentieth of an em. The ghosts can be as
loud as they like because they are colour; the base is the thing being
read, and a word that leaves its position is a word nobody can read. */
@keyframes jolt {
0% {
transform: translateX(0);
animation-timing-function: steps(1, jump-end);
}
28.23% {
transform: translateX(0.05em);
animation-timing-function: steps(1, jump-end);
}
29.346% {
transform: translateX(-0.035em);
animation-timing-function: steps(1, jump-end);
}
30.46% {
transform: translateX(0);
animation-timing-function: steps(1, jump-end);
}
62.115% {
transform: translateX(0.03em);
animation-timing-function: steps(1, jump-end);
}
63.23%,
100% {
transform: translateX(0);
}
}
.meta {
margin-top: clamp(6px, 1.2vw, 10px);
font-size: clamp(11.5px, 2.6vw, 13.5px);
color: var(--_read);
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
/* This is the one piece in the library where reduced motion has to take
the whole idea away: stepped displacement at 58ms a frame is the exact
shape of animation that setting exists to suppress. What survives is
the meaning — the label is intermittent — carried by a slow breath
between full and 42% over twice the cycle. */
.glitch::before,
.glitch::after {
display: none;
}
.glitch {
animation-name: fade-rm;
animation-duration: calc(var(--t) * 2);
animation-timing-function: var(--ease-in-out);
}
@keyframes fade-rm {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.42;
}
}
}
</style>
</head>
<body>
<div class="card">
<p class="eyebrow">Websocket</p>
<p class="glitch" data-text="RECONNECTING">RECONNECTING</p>
<p class="meta">attempt 3 · next retry in 4s</p>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.