# Countdown Flip (Moonarc)

A flip clock counting down to a date: days, hours, minutes, seconds, each a card whose top half folds down over the hinge when the number changes. The only script is one timer a second, set to the moment the next second begins, that writes the digits and stops while the tab is hidden; the flip is CSS. The server renders the time left at build for readers without JavaScript, or the done state when the target has already passed.

- Import: `import CountdownFlip from '@moonarc/core/CountdownFlip'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/countdown-flip.json`
- Tier C · category data · trigger load
- Readout: `<CountdownFlip to={launch} done="Launched.">`
- Browser support: widely (Chrome 55 · Firefox 54 · Safari 13.1)
- Measured cost: 1.1 kB raw JS · 649 B gzip · + runtime (with dependencies 2.8 kB raw; CSS 6.0 kB raw)
- Page: https://moonarc.dev/components/countdown-flip/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `to` | `string | number` | none | Target: an ISO date string or a timestamp in ms. |
| `as` | `HTMLTag` | `'div'` | Element to render. |
| `units` | `('days' | 'hours' | 'minutes' | 'seconds')[]` | `all four` | Units to show, in order. |
| `labels` | `Partial<Record<Unit, string | [one: string, other: string]>>` | none | Labels under the cards; default the unit names. A [one, other] pair gives the spoken name its singular ("1 day", "2 days"); the card always shows `other`, and `one` is used when the number is exactly 1. A plain string is used for both. |
| `done` | `string` | none | Text shown once the countdown reaches zero, and the timer's accessible name from then on. When the target has passed before the build, the server renders it (no script needed). |

## Usage

```astro
---
// next 1 January, 00:00 UTC: relative to the build, so the example never runs out. A real launch is an ISO date with an
// offset (or a timestamp) from your config or CMS: the build prints the time left and the browser keeps it current
const launch = Date.UTC(new Date().getUTCFullYear() + 1, 0, 1);
---
<CountdownFlip to={launch} class="text-4xl" done="Launched." />
<CountdownFlip to={launch} labels={{ days: ['Tag', 'Tage'], hours: ['Stunde', 'Stunden'], minutes: ['Minute', 'Minuten'], seconds: ['Sekunde', 'Sekunden'] }} done="Gestartet." />

<!-- a short timer: without a days card, hours wrap at 24 -->
<CountdownFlip to={Date.now() + 90 * 60 * 1000} units={['hours', 'minutes', 'seconds']} labels={{ hours: 'h', minutes: 'm', seconds: 's' }} />
```

## Reduced motion

Digits change in place; no card folds. Read live, so the site's motion switch applies on the next tick.

## With ClientRouter

Binds through the shared runtime: the timer is cleared before the swap and started on the new page.

## Craft

- Time is the one thing CSS cannot know, so the script is exactly that: read the clock, write four numbers, once a second. Everything visible is a CSS animation on a data attribute.
- A flip clock is two half-turns, not one: the old top folds down on an ease-in (gravity), then the new bottom unfolds on an ease-out (landing). Each takes half the preset duration.
- Four layers per card and no cloning: the falling flap and the bottom half carry the old number, the top half and the rising flap carry the new one. The card is a translucent tint (--ma-edge), so a covered layer would show through. Each half shows one layer at a time: the new top waits under the falling flap until it is edge-on, the old bottom goes as the new one unfolds, and the unfolded flap stays the bottom half. Before the first flip the flaps are hidden.
- The timer stops when the tab is hidden and catches up when it returns, because nobody sees a countdown in a background tab.
- Each tick is a timeout to the next whole second left, with a 20 ms margin. A flat 1000 ms interval keeps the phase of the moment it started, so the card turns up to a second late, and a phase that sits on a boundary lets timer jitter skip a second or show one twice.
- role="timer" with a spoken label the script keeps current ("1 day, 2 hours", singular from the `one` form at one extra comparison a second); the cards and the done text are aria-hidden, and at zero the label becomes the done text.
- When the countdown finishes, the cards step back to `--ma-dim`, the level secondary text uses, and the labels stay where they were. Decoration may go fainter than that; digits may not.
- Cards are 1.75em wide for two digits and grow with their padded digits past 99 days, so a third digit widens the card instead of spilling over its edges.

## Replaces

- CountdownTimer (Hover.dev)
- flip clock libraries (FlipClock.js, flipdown)

## Source

```astro
---
/**
 * CountdownFlip — a flip clock counting down to a date. The one thing CSS
 * cannot do is know the time, so one timer a second, set to the next whole
 * second, writes the numbers and nothing else; the flip is CSS — the old top
 * half folds down over the hinge, the new bottom half unfolds beneath it.
 * The timer stops while the tab is hidden. The server renders the time left
 * at build as the no-JS fallback, or the done state when the target has
 * already passed.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

type Unit = 'days' | 'hours' | 'minutes' | 'seconds';
/** A label, or a [one, other] pair: the card shows `other`; the spoken name says `one` when the number is exactly 1. */
type Label = string | readonly [one: string, other: string];

interface Props extends HTMLAttributes<'div'> {
  /** Target: an ISO date string or a timestamp in ms. */
  to: string | number;
  /** Element to render. */
  as?: HTMLTag;
  /** Units to show, in order. */
  units?: Unit[];
  /** Labels under the cards; default the unit names. A [one, other] pair gives the spoken name its singular ("1 day"). */
  labels?: Partial<Record<Unit, Label>>;
  /** Text shown once the countdown reaches zero, and the timer's accessible name from then on. */
  done?: string;
}

const { to, as: Tag = 'div', units = ['days', 'hours', 'minutes', 'seconds'], labels = {}, done, class: className, ...rest } = Astro.props;
const target = typeof to === 'number' ? to : Date.parse(to);
const left = Math.max(0, Math.floor((target - Date.now()) / 1000));
// the target passed before the build: the page ships finished, script or not
const over = left === 0;
const value: Record<Unit, number> = { days: Math.floor(left / 86400), hours: Math.floor(left / 3600) % 24, minutes: Math.floor(left / 60) % 60, seconds: left % 60 };
const text = (u: Unit) => (u === 'days' && value[u] > 99 ? String(value[u]) : String(value[u]).padStart(2, '0'));
const forms = (u: Unit): readonly [string, string] => {
  const l = labels[u];
  return typeof l === 'string' ? [l, l] : (l ?? [u.slice(0, -1), u]);
};
const spoken = over && done ? done : units.map((u) => `${value[u]} ${forms(u)[value[u] === 1 ? 0 : 1]}`).join(', ');
---

<Tag class:list={['ma-cd', className]} data-ma-cd data-to={new Date(target).toISOString()} data-done={over ? '' : undefined} role="timer" aria-label={spoken} {...rest}>
  {units.map((u) => {
    const [one, other] = forms(u);
    return (
      <span class="ma-cd__unit">
        <span class="ma-cd__card" data-unit={u} data-one={one === other ? undefined : one} aria-hidden="true">
          <b class="ma-cd__top">{text(u)}</b>
          <b class="ma-cd__bot">{text(u)}</b>
          <b class="ma-cd__flap-a">{text(u)}</b>
          <b class="ma-cd__flap-b">{text(u)}</b>
        </span>
        <span class="ma-cd__label" aria-hidden="true">{other}</span>
      </span>
    );
  })}
  {done && <span class="ma-cd__done" hidden={!over} aria-hidden="true">{done}</span>}
</Tag>

<style is:global>
  @layer components {
    :where(.ma-cd) {
      display: inline-flex;
      gap: 0.5em;
      font-variant-numeric: tabular-nums;
    }
    :where(.ma-cd__unit) {
      display: grid;
      gap: 0.35em;
      justify-items: center;
    }
    :where(.ma-cd__card) {
      position: relative;
      display: grid;
      /* two digits fit 1.75em; a third (more than 99 days) widens the card through the padded digits instead of clipping */
      min-width: 1.75em;
      height: 1.5em;
      line-height: 1.5em;
      font-weight: 600;
      text-align: center;
      perspective: 8em;
    }
    :where(.ma-cd__card)::after {
      content: '';
      position: absolute;
      inset: 50% 0 auto 0;
      height: 1px;
      background: var(--ma-glow);
    }
    :where(.ma-cd__top),
    :where(.ma-cd__bot),
    :where(.ma-cd__flap-a),
    :where(.ma-cd__flap-b) {
      grid-area: 1 / 1;
      padding-inline: 0.2em;
      font-weight: inherit;
      border-radius: 0.15em;
      background: var(--ma-edge);
    }
    :where(.ma-cd__top),
    :where(.ma-cd__flap-a) {
      clip-path: inset(0 0 50% 0);
    }
    :where(.ma-cd__bot),
    :where(.ma-cd__flap-b) {
      clip-path: inset(50% 0 0 0);
    }
    /* the flaps exist for a flip only: at rest each half shows one layer, so a translucent card (--ma-edge is a tint by
       default) is one tint deep in both halves and no digit shows through another */
    :where(.ma-cd__flap-a),
    :where(.ma-cd__flap-b) {
      transform-origin: 50% 50%;
      backface-visibility: hidden;
      visibility: hidden;
    }
    /* set by the script every second: the old top folds down, then the new bottom unfolds. The layers are tints, not
       opaque cards, so a flap covers nothing: the new top waits under the falling flap until it is edge-on, and the old
       bottom goes when the new one starts to unfold and stays gone (the flap is the bottom half from then on) */
    :where(.ma-cd__card[data-flip] .ma-cd__flap-a) {
      visibility: visible;
      animation: ma-cd-fold calc(var(--ma-duration) / 2) var(--ma-ease-in) both;
    }
    :where(.ma-cd__card[data-flip] .ma-cd__flap-b) {
      visibility: visible;
      animation: ma-cd-unfold calc(var(--ma-duration) / 2) var(--ma-ease-out) calc(var(--ma-duration) / 2) both;
    }
    :where(.ma-cd__card[data-flip] .ma-cd__top) {
      animation: ma-cd-hide calc(var(--ma-duration) / 2);
    }
    :where(.ma-cd__card[data-flip] .ma-cd__bot) {
      animation: ma-cd-hide calc(var(--ma-duration) / 2) calc(var(--ma-duration) / 2) forwards;
    }
    :where(.ma-cd__label) {
      font-size: 0.5em;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      opacity: var(--ma-dim, 0.7);
    }
    /* finished: the cards step back to secondary text, no further (the labels already sit at --ma-dim) */
    :where(.ma-cd[data-done] .ma-cd__card) {
      opacity: var(--ma-dim, 0.7);
    }
    :where(.ma-cd__done) {
      align-self: center;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-cd__card[data-flip] .ma-cd__flap-a),
      :where(.ma-cd__card[data-flip] .ma-cd__flap-b),
      :where(.ma-cd__card[data-flip] .ma-cd__top),
      :where(.ma-cd__card[data-flip] .ma-cd__bot) {
        animation: none;
      }
    }
  }

  /* a constant: hidden for as long as it runs (visibility between hidden and visible would step to visible at once) */
  @keyframes ma-cd-hide {
    from,
    to {
      visibility: hidden;
    }
  }
  @keyframes ma-cd-fold {
    from {
      transform: rotateX(0);
    }
    to {
      transform: rotateX(-90deg);
    }
  }
  @keyframes ma-cd-unfold {
    from {
      transform: rotateX(90deg);
    }
    to {
      transform: rotateX(0);
    }
  }
</style>

<script>
  import { onMount, prefersReducedMotion } from '../lib/runtime';

  type Unit = 'days' | 'hours' | 'minutes' | 'seconds';

  onMount<HTMLElement>('[data-ma-cd]', (el, { signal }) => {
    const target = Date.parse(el.dataset.to ?? '');
    const cards = Array.from(el.querySelectorAll<HTMLElement>('.ma-cd__card'));
    const done = el.querySelector<HTMLElement>('.ma-cd__done');
    let timer: number | undefined;

    const tick = () => {
      const left = Math.max(0, Math.floor((target - Date.now()) / 1000));
      const value: Record<Unit, number> = { days: Math.floor(left / 86400), hours: Math.floor(left / 3600) % 24, minutes: Math.floor(left / 60) % 60, seconds: left % 60 };
      const reduce = prefersReducedMotion();
      const spoken: string[] = [];
      for (const card of cards) {
        const unit = card.dataset.unit as Unit;
        const n = value[unit];
        const text = unit === 'days' && n > 99 ? String(n) : String(n).padStart(2, '0');
        const [top, bot, a, b] = Array.from(card.children) as HTMLElement[];
        // the label under the card is the `other` form; the `one` form rides on the card when it differs
        spoken.push(`${n} ${(n === 1 && card.dataset.one) || (card.nextElementSibling?.textContent ?? unit)}`);
        if (top!.textContent === text) continue;
        if (reduce) {
          top!.textContent = bot!.textContent = a!.textContent = b!.textContent = text;
          continue;
        }
        // the old value stays on the bottom half and the falling flap; the new one is under the flap and on the rising half
        bot!.textContent = a!.textContent = top!.textContent;
        top!.textContent = b!.textContent = text;
        card.removeAttribute('data-flip');
        void card.offsetWidth; // restart the flap animations
        card.setAttribute('data-flip', '');
      }
      const over = left <= 0;
      el.setAttribute('aria-label', over && done ? done.textContent! : spoken.join(', '));
      if (over) {
        stop();
        el.dataset.done = '';
        if (done) done.hidden = false;
      }
    };
    const stop = () => {
      clearTimeout(timer);
      timer = undefined;
    };
    // each tick is timed to the next whole second left, plus a margin for a timer that fires early. A flat 1000 ms
    // interval kept the phase of the moment it started: the card turned up to a second late, and when that phase sat on
    // a boundary, timer jitter skipped a second or showed one twice. An invalid target waits a second, never 0 ms. Armed
    // before the tick, so the tick that reaches zero clears it through stop()
    const start = () => {
      stop();
      timer = window.setTimeout(start, ((target - Date.now()) % 1000 || 1000) + 20);
      tick();
    };
    // no ticking in a hidden tab; catch up when it is shown again
    document.addEventListener('visibilitychange', () => (document.hidden ? stop() : start()), { signal });
    signal.addEventListener('abort', stop);
    start();
  });
</script>

```
