Count Up
TextCounts a number up from zero to its value when it scrolls into view, with thousands separators, decimals, prefix and suffix.
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.
launch window
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
JavaScript of its own
1.1 kB raw
649 B gzip.
Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.8 kB raw.
CSS 6.0 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 55 · Firefox 54 · Safari 13.1; needs clip-path.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import CountdownFlip from '@moonarc/core/CountdownFlip';
// 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' }} />Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/countdown-flip.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.
---
/**
* 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>| 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). |
Digits change in place; no card folds. Read live, so the site's motion switch applies on the next tick.
ClientRouterBinds through the shared runtime: the timer is cleared before the swap and started on the new page.
Replaces: CountdownTimer (Hover.dev) · flip clock libraries (FlipClock.js, flipdown). See the migration table.
Counts a number up from zero to its value when it scrolls into view, with thousands separators, decimals, prefix and suffix.
1,284
A number whose digits roll to the new value when it changes.
A departures board: every character spins through the alphabet and locks, one cell after another, when the board scrolls into view.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown