CSS + JS Data & tools #generated #wide

Flip clock in CSS + JavaScript

Real time needs JS. Each pair that changes re-triggers a CSS rotateX flip.

  • 100 lines of CSS
  • 3 lines of HTML
  • 41 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. CSS has no idea what time it is — JS reads the clock and writes the digits.
  2. Each card is a real split flap: four halves. Behind, the top half of the new value and the bottom half of the old one stand still. In front, a flap hinged on the middle line (transform-origin: bottom) carries the old top half on its face and the new bottom half on its back (rotateX(180deg), both sides backface-visibility: hidden).
  3. A half is a box with overflow: hidden and half the height of the card; the digits inside it are the full height of the card, pinned to its top or to its bottom, so each half shows exactly its half of them.
  4. The flip is one CSS keyframe, rotateX(0) → rotateX(-180deg): the old top falls towards you and lands as the new bottom. Midway it is edge-on, and the still halves behind it are what you see, as on a real board.
  5. When it lands (animationend), JS writes the new value on the parts that still showed the old one and takes the class off, so the flap is back up, showing the same digits it covers. To replay the animation: remove the class, force a reflow (void el.offsetWidth), add it back.
  6. Only the pairs whose value actually changed are flipped.
  7. Hours, minutes and seconds stand on one line, HH : MM : SS, as on a real flip clock. That line is about four times wider than it is tall, so the clock is sized by its width, like the other wide models in the gallery, not by its height: it is marked wide in its gallery entry.
  8. Every length is a multiple of one base unit, --u, tied to the canvas, so the digits and their cards are the same share of a gallery card, the editor and a recording canvas.

Key techniques

  • setInterval for the time
  • animation restart via reflow
  • rotateX flip keyframes

Copy-paste code

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);
Standalone snippet · plain CSS, no build step, no dependencies.