Text cycle — marquee
Variant: Marquee
A metrics ticker, and the seam is the whole engineering problem: the strip holds two identical runs and travels exactly -50% of the pair, so there are no measured widths, no per-item arithmetic, and it stays seamless when the type reflows at another stage size. Each run carries a trailing gap of its own, which is what makes the joint between the two spaced like every other. Linear, always — a marquee that eases is visibly a slideshow, and the constant rate is the only thing making it readable. The aperture is masked at both edges rather than cut, because at a hard line every item pops in and out and the eye tracks the pop instead of the reading. Sized to travel about 54px a second on a desktop stage, which is a reading pace rather than a scroll.
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 — marquee</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 — one run per cycle. 16s over a run that is roughly 870px on a
desktop stage puts the strip at about 54px a second; the type clamps
down on a phone and the rate falls with it, which is the behaviour you
want, because there is less strip to read. */
--cycle: 16s;
--t: calc(var(--cycle) / var(--speed));
--_pad: clamp(16px, 3.8vw, 26px);
--_gap: clamp(14px, 3vw, 26px);
--_fade: clamp(22px, 7vw, 44px);
--_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) var(--_pad);
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);
}
/* The strip bleeds to the card's edges rather than stopping inside its
padding: a ticker that begins and ends in clear air is a list that
happens to be moving. The mask is what makes the edges an aperture —
without it every item pops in and out at a hard line, and the eye tracks
the pop instead of the reading. */
.ticker {
margin: clamp(10px, 2vw, 15px) calc(-1 * var(--_pad)) 0;
overflow: hidden;
mask-image: linear-gradient(
90deg,
transparent 0,
#000 var(--_fade),
#000 calc(100% - var(--_fade)),
transparent 100%
);
-webkit-mask-image: linear-gradient(
90deg,
transparent 0,
#000 var(--_fade),
#000 calc(100% - var(--_fade)),
transparent 100%
);
}
.track {
display: flex;
width: max-content;
animation: marquee var(--t) linear infinite;
}
/* Two identical runs, and the travel is exactly -50% of the pair. That is
the whole seam strategy: no measured widths, no per-item maths, and it
stays correct when the type reflows at another stage size. The trailing
padding makes each run's own width include the gap that follows its last
separator, so the joint between the runs is spaced like every other. */
.run {
display: flex;
align-items: center;
gap: var(--_gap);
padding-right: var(--_gap);
flex: none;
}
@keyframes marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
.item {
display: inline-flex;
align-items: baseline;
gap: 0.42em;
white-space: nowrap;
font-size: clamp(12.5px, 3vw, 16px);
flex: none;
}
.k {
color: var(--_read);
}
.v {
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.sep {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--clay);
flex: none;
}
@media (prefers-reduced-motion: reduce) {
/* Gentler, not absent — and a ticker is the one component whose travel
cannot simply be dropped, because the travel is the component. So the
pass slows to 2.6x, which puts it near 20px a second, and the fades
are widened to more than a third of the aperture so far less of the
strip is ever at full contrast at once. */
.track {
animation-duration: calc(var(--t) * 2.6);
}
.ticker {
--_fade: clamp(48px, 18vw, 120px);
}
}
</style>
</head>
<body>
<div class="card">
<p class="eyebrow">Fleet</p>
<div
class="ticker"
role="img"
aria-label="Live fleet metrics scrolling: api 142 milliseconds, queue 0, errors 0.02 percent, p95 310 milliseconds, builds 4, uptime 99.98 percent."
>
<div class="track">
<div class="run" aria-hidden="true">
<span class="item"><span class="k">api</span><span class="v">142ms</span></span>
<span class="sep"></span>
<span class="item"><span class="k">queue</span><span class="v">0</span></span>
<span class="sep"></span>
<span class="item"><span class="k">errors</span><span class="v">0.02%</span></span>
<span class="sep"></span>
<span class="item"><span class="k">p95</span><span class="v">310ms</span></span>
<span class="sep"></span>
<span class="item"><span class="k">builds</span><span class="v">4</span></span>
<span class="sep"></span>
<span class="item"><span class="k">uptime</span><span class="v">99.98%</span></span>
<span class="sep"></span>
</div>
<div class="run" aria-hidden="true">
<span class="item"><span class="k">api</span><span class="v">142ms</span></span>
<span class="sep"></span>
<span class="item"><span class="k">queue</span><span class="v">0</span></span>
<span class="sep"></span>
<span class="item"><span class="k">errors</span><span class="v">0.02%</span></span>
<span class="sep"></span>
<span class="item"><span class="k">p95</span><span class="v">310ms</span></span>
<span class="sep"></span>
<span class="item"><span class="k">builds</span><span class="v">4</span></span>
<span class="sep"></span>
<span class="item"><span class="k">uptime</span><span class="v">99.98%</span></span>
<span class="sep"></span>
</div>
</div>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.