HTML 3 lines
<div class="scene">
<div class="clock"><span class="card"><b class="half top"><i></i></b><b class="half bottom"><i></i></b><b class="flap"><b class="half top front"><i></i></b><b class="half bottom back"><i></i></b></b></span><em>:</em><span class="card"><b class="half top"><i></i></b><b class="half bottom"><i></i></b><b class="flap"><b class="half top front"><i></i></b><b class="half bottom back"><i></i></b></b></span><em>:</em><span class="card"><b class="half top"><i></i></b><b class="half bottom"><i></i></b><b class="flap"><b class="half top front"><i></i></b><b class="half bottom back"><i></i></b></b></span></div>
</div>
CSS 100 lines
.scene {
/* one base unit: every length below is a multiple of it, so the clock is the same share of a
gallery card, the editor, a full screen and a recording canvas */
--u: 0.23vmin;
perspective: calc(700 * var(--u));
}
/* Hours, minutes and seconds on one line, as a flip clock has them. The line is about four times
wider than it is tall, so the clock is sized by its width (it is marked 'wide'): the unit puts it
near the width the gallery's other wide models have */
.clock {
display: flex;
align-items: center;
gap: calc(8 * var(--u));
font: 800 calc(56 * var(--u)) 'JetBrains Mono', ui-monospace, monospace;
font-variant-numeric: tabular-nums;
transform: rotateY(-14deg) rotateX(6deg);
transform-style: preserve-3d;
}
.clock em { color: #ffb547; font-style: normal; }
/* a card: two still halves behind, one flap in front */
.card {
position: relative;
width: calc(96 * var(--u));
height: calc(86 * var(--u));
border-radius: calc(12 * var(--u));
box-shadow: 0 calc(14 * var(--u)) calc(22 * var(--u)) calc(-12 * var(--u)) #000;
color: #fff;
transform-style: preserve-3d;
}
.half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
}
.half.top {
top: 0;
border-radius: calc(12 * var(--u)) calc(12 * var(--u)) 0 0;
background: #2a2f52;
box-shadow: inset 0 calc(-1.5 * var(--u)) #0c0e1d; /* the split line */
}
.half.bottom {
bottom: 0;
border-radius: 0 0 calc(12 * var(--u)) calc(12 * var(--u));
background: #1d2140;
}
/* the digits are the card's full height, pinned to the top or the bottom of their half */
.half i {
position: absolute;
left: 0;
right: 0;
height: calc(86 * var(--u));
font-style: normal;
line-height: calc(86 * var(--u));
text-align: center;
}
.half.top i { top: 0; }
.half.bottom i { bottom: 0; }
/* the flap: the top half's box, hinged on the middle line, a hair in front of the still halves
so it never shares their plane */
.flap {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 50%;
transform-origin: bottom;
transform-style: preserve-3d;
transform: translateZ(calc(0.5 * var(--u))) rotateX(0deg);
}
.flap .half {
inset: 0;
height: auto;
backface-visibility: hidden;
}
/* its back: turned over, so it lands as the bottom half the right way up */
.flap .back {
transform: rotateX(180deg);
}
.card.is-flipping .flap {
animation: flap 0.5s ease-in forwards;
}
@keyframes flap {
from { transform: translateZ(calc(0.5 * var(--u))) rotateX(0deg); }
to { transform: translateZ(calc(0.5 * var(--u))) rotateX(-180deg); }
}
JS 41 lines
const cards = [...document.querySelectorAll('.clock .card')].map((card) => ({
card,
top: card.querySelector(':scope > .top i'), // new value, top half (still)
bottom: card.querySelector(':scope > .bottom i'), // old value, bottom half (still)
front: card.querySelector('.flap .front i'), // old value, top half (falls)
back: card.querySelector('.flap .back i'), // new value, bottom half (lands)
value: '',
}));
// the flap has landed: every part shows the new value, and the flap goes back up over it
function settle(c) {
c.bottom.textContent = c.front.textContent = c.value;
c.card.classList.remove('is-flipping');
}
cards.forEach((c) => c.card.addEventListener('animationend', () => settle(c)));
// The site pauses a model by marking the frame's <html data-paused>: then the clock stands still,
// and catches up when it is un-paused. With reduced motion the digits change without the flip.
const calm = matchMedia('(prefers-reduced-motion: reduce)');
function tick() {
if (document.documentElement.hasAttribute('data-paused')) return;
const d = new Date();
[d.getHours(), d.getMinutes(), d.getSeconds()].forEach((n, i) => {
const text = String(n).padStart(2, '0');
const c = cards[i];
if (c.value === text) return;
const first = !c.value;
if (!first) settle(c); // a flip that never landed (paused) finishes first
c.value = text;
c.top.textContent = c.back.textContent = text;
if (first || calm.matches) return settle(c);
void c.card.offsetWidth; // force reflow so the animation restarts
c.card.classList.add('is-flipping');
});
}
tick();
setInterval(tick, 250);