Chart entrance — streaming sparkline
Variant: Streaming sparkline
The one with no entrance and no exit: a live trace that steps one column left every 600ms — 168ms of travel, 432ms of standing still — because polling is discrete, and a sparkline that glides continuously claims a feed you do not have. The marker never moves sideways; the past slides out from under it, and it rides the shift’s own curve, so it tracks the incoming segment rather than cutting the corner to the next reading. The number above it is the same animated integer that places the dot, so the two can never disagree. The series repeats every eight columns and the loop travels exactly eight, which is what makes the wrap seamless.
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>Chart entrance — streaming sparkline</title>
<style>
/* A registered integer so the readout and the plotted point are literally
the same datum: one keyframe list sets both, on the same beats. */
@property --_v {
syntax: '<integer>';
initial-value: 260;
inherits: false;
}
: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;
--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)
);
--_muted: color-mix(in srgb, var(--slate) 45%, transparent);
--_area: color-mix(in srgb, var(--clay) 14%, transparent);
/* Clock — 4.8s is eight polls of 600ms: 168ms of travel and 432ms of
standing still. The stillness is not padding, it is the poll interval;
a sparkline that glides continuously claims a feed you do not have. */
--cycle: 4.8s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--slate);
}
.chart {
width: min(288px, 88vw);
display: grid;
gap: 6px;
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.label {
font-size: clamp(11px, 3.2vw, 12px);
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--_muted);
}
/* Reserved at three digits and right-aligned against the head marker, so
a reading of 170 and a reading of 410 occupy the same box. */
.value {
font-size: clamp(17px, 5.2vw, 20px);
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
min-width: 3ch;
text-align: right;
counter-reset: n var(--_v);
animation: reading var(--t) infinite;
}
.value::after {
content: counter(n);
}
svg {
width: 100%;
height: auto;
display: block;
}
.rule {
stroke: var(--oat);
stroke-width: 1;
}
/* Stop colours as CSS rather than presentation attributes — var() in a
stop-color attribute is not reliably resolved. */
.stop-solid {
stop-color: var(--ivory);
stop-opacity: 1;
}
.stop-clear {
stop-color: var(--ivory);
stop-opacity: 0;
}
.area {
fill: var(--_area);
}
.trace {
fill: none;
stroke: var(--clay);
stroke-width: 2.2;
stroke-linejoin: round;
}
/* The whole series is one group translated by exactly one column per beat.
Its data repeats every eight columns and the loop travels exactly eight,
so the wrap is seamless — there is no frame where the strip jumps. */
.stream {
animation: shift var(--t) infinite;
}
/* The marker never moves horizontally; the past slides out from under it.
It shares the shift's curve, which is what keeps it welded to the trace:
both read the same eased progress, so the dot rides the incoming segment
instead of cutting the corner. */
.marker {
fill: var(--clay-d);
stroke: var(--ivory);
stroke-width: 2;
animation: latest var(--t) infinite;
}
@keyframes shift {
0%,
9% {
transform: translateX(0px);
animation-timing-function: var(--ease-out);
}
12.5%,
21.5% {
transform: translateX(-30px);
animation-timing-function: var(--ease-out);
}
25%,
34% {
transform: translateX(-60px);
animation-timing-function: var(--ease-out);
}
37.5%,
46.5% {
transform: translateX(-90px);
animation-timing-function: var(--ease-out);
}
50%,
59% {
transform: translateX(-120px);
animation-timing-function: var(--ease-out);
}
62.5%,
71.5% {
transform: translateX(-150px);
animation-timing-function: var(--ease-out);
}
75%,
84% {
transform: translateX(-180px);
animation-timing-function: var(--ease-out);
}
87.5%,
96.5% {
transform: translateX(-210px);
animation-timing-function: var(--ease-out);
}
100% {
transform: translateX(-240px);
}
}
@keyframes latest {
0%,
9% {
transform: translateY(4px);
animation-timing-function: var(--ease-out);
}
12.5%,
21.5% {
transform: translateY(12px);
animation-timing-function: var(--ease-out);
}
25%,
34% {
transform: translateY(-12px);
animation-timing-function: var(--ease-out);
}
37.5%,
46.5% {
transform: translateY(2px);
animation-timing-function: var(--ease-out);
}
50%,
59% {
transform: translateY(-20px);
animation-timing-function: var(--ease-out);
}
62.5%,
71.5% {
transform: translateY(-6px);
animation-timing-function: var(--ease-out);
}
75%,
84% {
transform: translateY(10px);
animation-timing-function: var(--ease-out);
}
87.5%,
96.5% {
transform: translateY(20px);
animation-timing-function: var(--ease-out);
}
100% {
transform: translateY(4px);
}
}
@keyframes reading {
0%,
9% {
--_v: 260;
animation-timing-function: var(--ease-out);
}
12.5%,
21.5% {
--_v: 220;
animation-timing-function: var(--ease-out);
}
25%,
34% {
--_v: 360;
animation-timing-function: var(--ease-out);
}
37.5%,
46.5% {
--_v: 280;
animation-timing-function: var(--ease-out);
}
50%,
59% {
--_v: 410;
animation-timing-function: var(--ease-out);
}
62.5%,
71.5% {
--_v: 320;
animation-timing-function: var(--ease-out);
}
75%,
84% {
--_v: 230;
animation-timing-function: var(--ease-out);
}
87.5%,
96.5% {
--_v: 170;
animation-timing-function: var(--ease-out);
}
100% {
--_v: 260;
}
}
@media (prefers-reduced-motion: reduce) {
/* The shift is the data arriving, not a flourish laid over it, so it
stays — at a 9.1s loop, a poll every 1.14s. All three clocks scale by
the same 1.9, which is what keeps the marker on the trace and the
readout on the marker. */
.stream,
.marker,
.value {
animation-duration: calc(var(--t) * 1.9);
}
}
</style>
</head>
<body>
<div
class="chart"
role="img"
aria-label="A live sparkline: every poll the series steps one column to the left and a new reading arrives under a fixed marker at the right edge"
>
<div class="head">
<span class="label">req / s</span>
<span class="value"></span>
</div>
<svg viewBox="0 0 288 110" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="window">
<rect x="0" y="0" width="270" height="112" />
</clipPath>
<linearGradient id="fade" x1="0" x2="1" y1="0" y2="0">
<stop class="stop-solid" offset="0" />
<stop class="stop-clear" offset="1" />
</linearGradient>
</defs>
<g clip-path="url(#window)">
<g class="stream">
<path
class="area"
d="M0 76 L30 60 L60 68 L90 44 L120 58 L150 36 L180 50 L210 66 L240 76 L270 60 L300 68 L330 44 L360 58 L390 36 L420 50 L450 66 L480 76 L510 60 L510 104 L0 104 Z"
/>
<path
class="trace"
d="M0 76 L30 60 L60 68 L90 44 L120 58 L150 36 L180 50 L210 66 L240 76 L270 60 L300 68 L330 44 L360 58 L390 36 L420 50 L450 66 L480 76 L510 60"
/>
</g>
</g>
<!-- History dissolves rather than being cut. A hard left edge reads as
data being deleted; a fade reads as it falling out of the window. -->
<rect x="0" y="0" width="56" height="104" fill="url(#fade)" />
<line class="rule" x1="0" y1="104" x2="288" y2="104" />
<line class="rule" x1="270" y1="12" x2="270" y2="104" />
<circle class="marker" cx="270" cy="56" r="4" />
</svg>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.