DEV Community

Jeason Li
Jeason Li

Posted on

Building a Minimal Flip Clock in the Browser (Astro + React)

I wanted a big, clean clock I could keep on a second screen during meetings (and occasionally full-screen on a TV). Most clock sites are either too busy or not quite the style I wanted, so I built my own:

This post is a quick walkthrough of the technical approach and a few lessons learned while getting the flip animation to feel "right".

What I built

Uni Datetime is a minimal flip clock web page:

  • Large flip digits (HH:MM:SS)
  • Date above time
  • Light/Dark mode
  • 12/24-hour toggle
  • Fullscreen toggle (ESC exits fullscreen)

Time and timezone are derived from the browser/system timezone, so if you change your browser timezone (or simulate one in DevTools), the displayed time changes accordingly.

Stack

I kept it simple and static:

  • Astro for a fast, static build
  • React for the interactive clock "island"
  • Luxon for date/time formatting

Astro gives me a tiny HTML shell and bundles a React component for the clock UI and controls.

The flip digit: two halves + two animated layers

The flip effect comes from rendering four layers:

  1. upper (static top half)
  2. lower (static bottom half)
  3. flip-upper (animated top half that flips down)
  4. flip-lower (animated bottom half that flips in)

Visually, the key is: the flip uses the old value until the midpoint, then reveals the new value.

In React, each digit keeps current and next values, plus a play flag for the CSS animation:

const FLIP_MS = 600;

function FlipDigit({ value }: { value: string }) {
  const [play, setPlay] = useState(false);
  const [current, setCurrent] = useState(value);
  const [next, setNext] = useState(value);

  useLayoutEffect(() => {
    if (value === current) {
      setNext(value);
      setPlay(false);
      return;
    }

    // Start flip from current -> value
    setNext(value);
    setPlay(true);

    const t = window.setTimeout(() => {
      setCurrent(value);
      setPlay(false);
    }, FLIP_MS);

    return () => window.clearTimeout(t);
  }, [value, current]);

  return (
    <div className={`flip-digit${play ? " play" : ""}`}>
      <div className="upper"><span>{play ? next : current}</span></div>
      <div className="lower"><span>{current}</span></div>
      <div className="flip-upper"><span>{current}</span></div>
      <div className="flip-lower"><span>{next}</span></div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

And the CSS animation is split into two phases (top flips first, then bottom):

.flip-digit { perspective: 1000px; }

.flip-upper { transform-origin: bottom; }
.flip-lower { transform-origin: top; transform: rotateX(90deg); }

.flip-digit.play .flip-upper { animation: flipUpper 0.3s ease-in forwards; }
.flip-digit.play .flip-lower { animation: flipLower 0.3s ease-out 0.3s forwards; }

@keyframes flipUpper { to { transform: rotateX(-90deg); } }
@keyframes flipLower { to { transform: rotateX(0deg); } }
Enter fullscreen mode Exit fullscreen mode

Keeping it smooth (and avoiding "new value flips first")

The most common bug I hit: the digit briefly shows the new number on the top half before the flip starts, which ruins the illusion.

The fix was to:

  • Treat the currently-rendered value (current) as authoritative for the static lower half
  • Only update current after the flip finishes
  • Use a layout effect (useLayoutEffect) so DOM updates and animation class toggles happen without visible flicker

Preferences: theme + 12/24 format

Preferences are simple:

  • Store theme and format in localStorage
  • Apply theme via document.documentElement.dataset.theme
  • Format time via Luxon:
    • HHmmss for 24-hour
    • hhmmss + a for 12-hour + AM/PM

I also used tabular numbers (font-variant-numeric: tabular-nums) to prevent digit width jitter.

Fullscreen toggle

Fullscreen is handled with the standard Fullscreen API (requestFullscreen / exitFullscreen). ESC exits fullscreen by browser default; the app just listens for fullscreenchange to keep the button label in sync.

Accessibility and motion

Flip animations look great, but not everyone wants them:

  • If prefers-reduced-motion: reduce is set, the animated layers are hidden and the clock becomes a static digit update.

Small SEO baseline (for a single-page tool)

Even for a simple tool, it's worth adding:

  • A good <title> and meta description
  • A canonical URL
  • A sitemap.xml

Next on my list: OpenGraph/Twitter cards and JSON-LD (structured data) so links look better when shared.

Try it / feedback

If you have thoughts on the animation timing, typography, or anything you'd like to see added, I'd love feedback:

Top comments (0)