Stitch — cross tally
Variant: Cross tally
A determinate meter for work that arrives in units. Eight empty guide cells are declared before anything lands, because progress means nothing without a denominator, and each cross-stitch arrives as two 91ms flicks of the needle 116ms apart — a cross is two passes, and collapsing them into one appearance would make it a checkbox. The whole cadence is one registered progress number that every stroke reads its own start time out of, and the count underneath is stepped off the same clock, its jumps tuned to the exact frames the second strokes finish, so the numeral and the cloth cannot disagree. The ink flips to clay only on the eighth cross: a count in progress is a datum, a full count is a state. Use it for batch jobs, sync queues, N-of-M anything — where a smooth bar would claim a continuity the work does not have.
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>Stitch — cross tally</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: eight crosses at a 291ms cadence over the first 44%,
then the finished tally holds for well over two seconds, because a
meter's resting state is the one people actually read. */
--cycle: 5.6s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
}
/* One registered progress number drives all sixteen strokes: each reads
its own start time out of it, so the cadence is arithmetic rather than
sixteen tuned keyframe blocks. */
@property --_p {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
@property --_n {
syntax: '<integer>';
inherits: true;
initial-value: 0;
}
* {
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);
}
.scene {
display: grid;
justify-items: center;
gap: 10px;
animation:
prog var(--t) linear infinite,
tally var(--t) infinite,
scene-reset var(--t) infinite;
}
.row {
width: min(280px, 86vw);
height: auto;
display: block;
}
/* The guide cells are declared before any stitch lands: progress means
nothing without a denominator, and the eight empty frames are the
honest version of a track. */
.cell {
fill: none;
stroke: var(--oat);
stroke-width: 2;
rx: calc(3px * var(--round));
}
.xs {
fill: none;
stroke: var(--slate);
stroke-width: 3;
stroke-linecap: round;
/* 103 while waiting, so the round cap parks clear of the path instead
of showing as a dot in every empty cell. Each stroke draws over 2.5
progress units — a 91ms flick, because a cross-stitch is two quick
passes of the needle, not two little entrances. */
stroke-dasharray: 100 200;
stroke-dashoffset: calc(103 - 103 * clamp(0, (var(--_p) - var(--_i)) / 2.5, 1));
}
/* The numeral is stepped off the same clock as the stitches — the jumps
land on the exact frames the second strokes finish, so the count and
the cloth can never disagree. Tabular figures, so eight updates do not
breathe the layout. */
.num {
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.06em;
counter-reset: n var(--_n);
animation: num-ink var(--t) infinite;
}
.num::before {
content: counter(n) ' / 8';
}
@keyframes prog {
0% {
--_p: 0;
}
65%,
93% {
--_p: 100;
animation-timing-function: steps(1, end);
}
94%,
100% {
--_p: 0;
}
}
/* steps(8, end) across a window tuned so each jump lands as a cross
completes: increment j fires at 2.4% + 5.2j, and cross k finishes at
7.6% + 5.2k. Same arithmetic, zero drift. */
@keyframes tally {
0%,
2.4% {
--_n: 0;
animation-timing-function: steps(8, end);
}
44%,
93% {
--_n: 8;
animation-timing-function: steps(1, end);
}
94%,
100% {
--_n: 0;
}
}
/* The ink flips only when the eighth cross lands: a count in progress is
a datum, a count that is full is a state. */
@keyframes num-ink {
0%,
43.9% {
color: var(--slate);
}
44%,
94% {
color: var(--clay-d);
}
100% {
color: var(--slate);
}
}
/* Progress and count rewind inside this dark window, so the loop reads
as the next batch starting rather than this one unravelling. */
@keyframes scene-reset {
0%,
88% {
opacity: 1;
}
93%,
97% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
/* The tally survives at 1.8x — each cross is two near-instant flicks
either present or absent, nothing sweeps — and all three clocks
scale together so the count stays welded to the stitches. */
.scene,
.num {
animation-duration: calc(var(--t) * 1.8);
}
}
</style>
</head>
<body>
<div class="scene">
<svg
class="row"
viewBox="0 0 260 76"
role="img"
aria-label="Eight empty guide cells fill with cross-stitches one at a time, each cross landing as two quick strokes, while a count beneath steps from zero to eight in lockstep"
>
<g>
<rect class="cell" x="31" y="29" width="18" height="18" />
<rect class="cell" x="57" y="29" width="18" height="18" />
<rect class="cell" x="83" y="29" width="18" height="18" />
<rect class="cell" x="109" y="29" width="18" height="18" />
<rect class="cell" x="135" y="29" width="18" height="18" />
<rect class="cell" x="161" y="29" width="18" height="18" />
<rect class="cell" x="187" y="29" width="18" height="18" />
<rect class="cell" x="213" y="29" width="18" height="18" />
</g>
<g>
<path class="xs" style="--_i: 6" pathLength="100" d="M 33 45 L 47 31" />
<path class="xs" style="--_i: 9.2" pathLength="100" d="M 33 31 L 47 45" />
<path class="xs" style="--_i: 14" pathLength="100" d="M 59 45 L 73 31" />
<path class="xs" style="--_i: 17.2" pathLength="100" d="M 59 31 L 73 45" />
<path class="xs" style="--_i: 22" pathLength="100" d="M 85 45 L 99 31" />
<path class="xs" style="--_i: 25.2" pathLength="100" d="M 85 31 L 99 45" />
<path class="xs" style="--_i: 30" pathLength="100" d="M 111 45 L 125 31" />
<path class="xs" style="--_i: 33.2" pathLength="100" d="M 111 31 L 125 45" />
<path class="xs" style="--_i: 38" pathLength="100" d="M 137 45 L 151 31" />
<path class="xs" style="--_i: 41.2" pathLength="100" d="M 137 31 L 151 45" />
<path class="xs" style="--_i: 46" pathLength="100" d="M 163 45 L 177 31" />
<path class="xs" style="--_i: 49.2" pathLength="100" d="M 163 31 L 177 45" />
<path class="xs" style="--_i: 54" pathLength="100" d="M 189 45 L 203 31" />
<path class="xs" style="--_i: 57.2" pathLength="100" d="M 189 31 L 203 45" />
<path class="xs" style="--_i: 62" pathLength="100" d="M 215 45 L 229 31" />
<path class="xs" style="--_i: 65.2" pathLength="100" d="M 215 31 L 229 45" />
</g>
</svg>
<div class="num" aria-hidden="true"></div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.