Back to the library
Number ticker — split flap
Variant: Split flap
The indulgent one, and the only member of the set that earns its length by being seen rarely — a board that turns for a milestone, not for traffic. 130ms of fall on an ease-in curve and 130ms of rise on an ease-out: the one place ease-in is correct anywhere in this library, because the leaf is falling rather than entering and gravity accelerates. Each of the four surfaces advances its glyph only while it is hidden, and the leaves re-arm with a steps(1) snap mid-hold, so nothing ever changes a digit the viewer can see.
datacounterloop
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>Number ticker — split flap</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)
);
--_muted: color-mix(in srgb, var(--slate) 45%, transparent);
/* Clock — 4.8s, the same as the odometer sibling so the two are
directly comparable: one flap costs 260ms against the odometer's
264ms, and every millisecond of the difference is spent on the hinge
rather than on distance. */
--cycle: 4.8s;
--t: calc(var(--cycle) / var(--speed));
/* 72ms between digits, half again the odometer's 48ms. The mechanism is
heavier, and a board whose flaps all fire on one frame sounds like a
single clack instead of a run of them. */
--_lag: calc(72ms / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
/* The one ease-in in the library, and the only place it is correct: the
leaf is falling, not entering. Gravity accelerates. */
--ease-accent: cubic-bezier(0.55, 0, 1, 0.45);
}
* {
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);
}
.tile {
display: grid;
gap: 10px;
justify-items: start;
padding: 18px 22px 20px;
border: 1px solid var(--oat);
border-radius: calc(14px * var(--round));
background: var(--paper);
}
.label {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--_muted);
}
.value {
display: flex;
gap: 2px;
font-size: clamp(36px, 11vw, 46px);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* Perspective per flap rather than one vanishing point for the row: each
digit on a real board has its own axle, and a shared perspective makes
the outer flaps lean away from the viewer as they turn. */
.d {
position: relative;
width: 1.24ch;
height: 1.18em;
perspective: 340px;
background: var(--ivory);
border-radius: calc(3px * var(--round));
animation: fresh var(--t) infinite;
}
/* The hinge. Above the leaves in paint order, because on a real unit the
axle is in front of the card that swings from it. */
.d::after {
content: '';
position: absolute;
left: 0;
right: 0;
top: 50%;
z-index: 3;
height: 1px;
background: var(--oat);
}
.half,
.leaf {
position: absolute;
left: 0;
right: 0;
height: 0.59em;
overflow: hidden;
background: var(--ivory);
backface-visibility: hidden;
}
.top,
.fall {
top: 0;
border-radius: calc(3px * var(--round)) calc(3px * var(--round)) 0 0;
}
.bot,
.rise {
bottom: 0;
border-radius: 0 0 calc(3px * var(--round)) calc(3px * var(--round));
}
/* Origin at the seam for both leaves — that is the axle. The falling leaf
carries the outgoing digit's top half and drops away to reveal the
incoming one already sitting behind it; the rising leaf carries the
incoming bottom half and swings down over the outgoing one. */
.fall {
transform-origin: bottom;
animation: fall var(--t) infinite;
}
.rise {
transform-origin: top;
transform: rotateX(90deg);
animation: rise var(--t) infinite;
}
.strip {
display: grid;
animation-timing-function: steps(1, end);
animation-duration: var(--t);
animation-iteration-count: infinite;
}
.top .strip,
.rise .strip {
animation-name: step-lead;
}
.fall .strip {
animation-name: step-mid;
}
.bot .strip {
animation-name: step-late;
}
/* Half a cell up, so the window lands on the bottom of the glyph. A
margin rather than a second transform: the stepped translate below is
a percentage of the strip, and stacking the two would make every step
land half a digit off. */
.bot .strip,
.rise .strip {
margin-top: -0.59em;
}
.strip i {
display: block;
height: 1.18em;
line-height: 1.18em;
font-style: normal;
text-align: center;
}
.d:nth-child(1) {
--_i: 2;
}
.d:nth-child(2) {
--_i: 1;
}
.d:nth-child(3) {
--_i: 0;
}
.d,
.d .strip,
.d .leaf {
animation-delay: calc(var(--_i) * var(--_lag));
}
/* Each strip advances a cell while the surface showing it is hidden — the
lead pair behind the closed leaf, the falling leaf once it is edge-on,
the bottom half once the rising leaf has covered it. Nothing ever
changes a glyph the viewer can see. */
@keyframes step-lead {
0% {
transform: translateY(0);
}
18% {
transform: translateY(-33.3333%);
}
60% {
transform: translateY(-66.6666%);
}
}
@keyframes step-mid {
0% {
transform: translateY(0);
}
21.5% {
transform: translateY(-33.3333%);
}
63.5% {
transform: translateY(-66.6666%);
}
}
@keyframes step-late {
0% {
transform: translateY(0);
}
24.5% {
transform: translateY(-33.3333%);
}
66.5% {
transform: translateY(-66.6666%);
}
}
/* 130ms down, 130ms up. The leaves re-arm with a steps(1) snap mid-hold
rather than rotating back: at that instant each one is coincident with
a static half showing the identical glyph, so there is nothing to see. */
@keyframes fall {
0% {
transform: rotateX(0deg);
}
18% {
transform: rotateX(0deg);
animation-timing-function: var(--ease-accent);
}
20.7% {
transform: rotateX(-90deg);
animation-timing-function: steps(1, end);
}
40% {
transform: rotateX(0deg);
}
60% {
transform: rotateX(0deg);
animation-timing-function: var(--ease-accent);
}
62.7% {
transform: rotateX(-90deg);
animation-timing-function: steps(1, end);
}
82% {
transform: rotateX(0deg);
}
100% {
transform: rotateX(0deg);
}
}
@keyframes rise {
0% {
transform: rotateX(90deg);
}
20.7% {
transform: rotateX(90deg);
animation-timing-function: var(--ease-out);
}
23.4% {
transform: rotateX(0deg);
animation-timing-function: steps(1, end);
}
40% {
transform: rotateX(90deg);
}
62.7% {
transform: rotateX(90deg);
animation-timing-function: var(--ease-out);
}
65.4% {
transform: rotateX(0deg);
animation-timing-function: steps(1, end);
}
82% {
transform: rotateX(90deg);
}
100% {
transform: rotateX(90deg);
}
}
@keyframes fresh {
0%,
17.9% {
color: var(--slate);
animation-timing-function: steps(1, end);
}
18%,
23.4% {
color: var(--clay-d);
animation-timing-function: var(--ease-out);
}
31.7%,
59.9% {
color: var(--slate);
animation-timing-function: steps(1, end);
}
60%,
65.4% {
color: var(--clay-d);
animation-timing-function: var(--ease-out);
}
73.7%,
100% {
color: var(--slate);
}
}
@media (prefers-reduced-motion: reduce) {
/* The mechanism goes and the message stays. The leaves and the hinge
are dismissed, the bottom half is promoted to the whole flap, and its
three cells collapse into one grid track to cross-fade in place on
the same beats the flap would have turned on — at 7.7s, so the pair
of changes reads as two events rather than a stutter. */
.leaf,
.d::after {
display: none;
}
.top {
display: none;
}
.bot {
top: 0;
height: auto;
border-radius: calc(3px * var(--round));
}
.bot .strip {
margin-top: 0;
animation: none;
transform: none;
}
.bot .strip i {
grid-area: 1 / 1;
animation: cell-a calc(var(--t) * 1.6) var(--ease-out) infinite;
animation-delay: calc(var(--_i) * var(--_lag));
}
.bot .strip i:nth-child(2) {
animation-name: cell-b;
}
/* The third cell exists only to hide the loop's seam behind a forward
turn. With nothing turning there is no seam, so it stands down. */
.bot .strip i:nth-child(3) {
opacity: 0;
animation: none;
}
.d {
animation-duration: calc(var(--t) * 1.6);
}
@keyframes cell-a {
0%,
18% {
opacity: 1;
}
21%,
60% {
opacity: 0;
}
63%,
100% {
opacity: 1;
}
}
@keyframes cell-b {
0%,
18% {
opacity: 0;
}
21%,
60% {
opacity: 1;
}
63%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div
class="tile"
role="img"
aria-label="A split-flap metric board turning from 299 to 300 active sessions and back, units flap first"
>
<span class="label">Active sessions</span>
<span class="value">
<span class="d">
<span class="half top"
><span class="strip"><i>2</i><i>3</i><i>2</i></span></span
>
<span class="half bot"
><span class="strip"><i>2</i><i>3</i><i>2</i></span></span
>
<span class="leaf fall"
><span class="strip"><i>2</i><i>3</i><i>2</i></span></span
>
<span class="leaf rise"
><span class="strip"><i>2</i><i>3</i><i>2</i></span></span
>
</span>
<span class="d">
<span class="half top"
><span class="strip"><i>9</i><i>0</i><i>9</i></span></span
>
<span class="half bot"
><span class="strip"><i>9</i><i>0</i><i>9</i></span></span
>
<span class="leaf fall"
><span class="strip"><i>9</i><i>0</i><i>9</i></span></span
>
<span class="leaf rise"
><span class="strip"><i>9</i><i>0</i><i>9</i></span></span
>
</span>
<span class="d">
<span class="half top"
><span class="strip"><i>9</i><i>0</i><i>9</i></span></span
>
<span class="half bot"
><span class="strip"><i>9</i><i>0</i><i>9</i></span></span
>
<span class="leaf fall"
><span class="strip"><i>9</i><i>0</i><i>9</i></span></span
>
<span class="leaf rise"
><span class="strip"><i>9</i><i>0</i><i>9</i></span></span
>
</span>
</span>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.