Introduction
This clock shows the time as six mechanical-looking cards — hours, minutes, seconds, each split into a top and bottom half — and every time a digit changes, a small leaf tips forward on a hinge and lands on the new number, the way a real airport departure board does. There is no canvas, no SVG animation library, and no per-frame JavaScript: the fall is a single CSS transition on transform, and the only script involved decides when to start that transition and when to swap the text underneath it.
The trick rests on three ideas: showing a digit as two independently clipped halves so the same glyph can be split cleanly at its middle, giving each digit a rotating leaf with a front face and a pre-rotated back face so a single CSS transform reveals two different numbers depending on the angle, and timing a plain setTimeout to swap the static bottom half at the exact moment the leaf becomes edge-on and invisible — the one moment a swap can happen with nothing visible to give it away.
How overflow: hidden plus a doubled-height glyph produces a clean top half and bottom half from one digit — how backface-visibility: hidden and a static rotateX(180deg) child let one rotating leaf carry two different faces — how rotateX(0deg) to rotateX(-180deg) with transform-origin: bottom reproduces the actual physical fold of a flap board — how a setTimeout fired at half the transition's duration hides the bottom-half content swap inside the leaf's blind spot — and how one tick() function, called once and then every 1000ms, is the only thing that ever touches the DOM.
Step 1 — Clipping One Glyph Into Top and Bottom
Each digit has a .top and a .bottom, both height: 50% with overflow: hidden. Inside each sits a .glyph sized to the digit's full height, so only half of the character is ever visible. The top half needs no adjustment — it is already showing the upper portion. The bottom half's glyph is shifted up by exactly half the card height, so overflow hides the top and only the lower portion of the character remains on screen.
.half {
position: absolute;
height: 50%;
overflow: hidden;
}
.glyph {
position: absolute;
height: var(--h); /* full card height, not half */
line-height: var(--h);
width: 100%;
text-align: center;
}
.top .glyph { top: 0; }
.bottom .glyph { top: calc(var(--h) * -0.5); }Because both halves reference the same --h custom property used to size the card itself, resizing the whole clock is a one-variable change — the glyph offsets stay correct at any size without touching the JavaScript.
Step 2 — One Rotating Element, Two Faces
The moving piece sits directly on top of the static top half. It is a single .leaf div with transform-style: preserve-3d, containing two children: .leaf-face.front, which shows the digit that is about to disappear, and .leaf-face.back, which is permanently rotated rotateX(180deg) and shows the digit that is about to appear. Both faces have backface-visibility: hidden, so each is only rendered while it happens to be facing the viewer.
.leaf {
transform-origin: bottom center;
transform: rotateX(0deg);
}
.leaf.turning {
transition: transform .42s cubic-bezier(.6, .04, .4, 1);
transform: rotateX(-180deg);
}
.leaf-face {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.leaf-face.back {
transform: rotateX(180deg); /* pre-rotated, so it "arrives" upright */
}At 0 degrees the front face faces the viewer and the back face faces away. Past 90 degrees the geometry reverses, and the pre-rotated back face is the one left facing forward — no JavaScript decides which face shows; the angle alone does.
Step 3 — Hiding the Swap Inside the Blind Spot
The static .bottom half never animates — it just changes its text content once, instantly, mid-flip. That swap has to land while the leaf sitting above it is edge-on to the viewer, since an edge-on plane renders as an invisible sliver regardless of what is behind it. That moment is reliably halfway through the transition's declared duration, so a setTimeout scheduled for FLIP_MS / 2 is enough — no animationend listener or frame-by-frame check is needed.
const FLIP_MS = 420;
function setDigit(d, value) {
if (value === d.current) return;
d.front.textContent = d.current; // outgoing number
d.back.textContent = value; // incoming number
d.leaf.classList.remove('turning');
void d.leaf.offsetWidth; // force reflow so the transition restarts cleanly
d.leaf.classList.add('turning');
setTimeout(() => {
d.bottom.textContent = value; // swapped while the leaf is edge-on
}, FLIP_MS / 2);
}Step 4 — Putting the Leaf Back at Zero
Once the leaf finishes its fall, it is sitting at rotateX(-180deg), showing the back face — which is now visually identical to the static top half below it, since both display the new value. A second, later setTimeout removes the .turning class (snapping the leaf back to 0 degrees with no transition, because nothing is animating at that instant) and simultaneously updates .top, .front, and .back to the new value, so the leaf is fully reset and ready to fall again on the next tick.
setTimeout(() => {
d.leaf.classList.remove('turning'); // instant snap back to rotateX(0)
d.top.textContent = value;
d.front.textContent = value;
d.back.textContent = value;
d.current = value;
}, FLIP_MS);The reset must happen with no transition in effect, or the leaf visibly rotates backward from -180deg to 0deg. Removing the class instantly (rather than toggling it) is what keeps that reset invisible.
Step 5 — A Single Loop Drives Six Digits
Every digit unit is built once from a small buildDigit() factory and stored by name (hours, minutes, seconds). A single tick() function reads new Date() once per call, formats each unit to two zero-padded digits, and calls setDigit() for each of the six positions — setDigit() itself is a no-op whenever the incoming character matches what is already showing, so nine of a clock's ten seconds-digit ticks touch the DOM while the other stays untouched.
function tick() {
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
setDigit(units.hours[0], hh[0]);
setDigit(units.hours[1], hh[1]);
setDigit(units.minutes[0], mm[0]);
setDigit(units.minutes[1], mm[1]);
setDigit(units.seconds[0], ss[0]);
setDigit(units.seconds[1], ss[1]);
}
tick();
setInterval(tick, 1000);Step 6 — Making the Flip Usable, Not Just Decorative
The whole board is aria-hidden, since a screen reader announcing "3... 3... 3... 4" once a second would be unusable. A visually hidden paragraph instead holds a single, human-readable line built with Intl.DateTimeFormat, and is only updated when the date string itself changes — so it stays informative without becoming noisy. A prefers-reduced-motion query removes the leaf transition entirely, so the clock still updates correctly, just without the fall.
@media (prefers-reduced-motion: reduce) {
.leaf.turning { transition: none; }
}Tuning Reference
| Property | Example | Effect |
|---|---|---|
| --w / --h | 78px / 112px | Size of each digit card; every glyph offset is derived from --h |
| FLIP_MS | 420 | Total fall duration in ms; also fixes the midpoint swap timing at FLIP_MS / 2 |
| perspective | 260px | How pronounced the 3D fold looks; lower values exaggerate the fold |
| transform-origin | bottom center | Hinge point of the leaf; must match the physical edge being folded |
| cubic-bezier(.6, .04, .4, 1) | — | Gives the fall a quick drop and a soft catch, rather than a linear rotation |
| --amber / --amber-glow | #ffb238 / rgba(255,178,56,.35) | Digit color and glow; swap for a different board mood |
Full Source Code
The complete component is a single self-contained HTML document — the digit markup generated by buildDigit(), every leaf and half rule, and the six-line tick loop, and nothing else. No build step, no external assets beyond two Google Fonts.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/split-flap-clock.html