Countdown Flip
DataA 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.
One line that says whether a place is open now and until when, or when it opens next, read from the week's hours in the place's own time zone, whatever the reader's clock says. A dot beside it is lit while it is open, and the words say it either way. A day may run past midnight (a close before its open). A small script writes the line on load and every half minute while the page is open; without JavaScript the fallback sentence stands in its place, or nothing does. The hours are checked at build: a time that is not HH:MM or a zone Intl does not know stops it.
Open Monday to Saturday, from 8:00
The hours of a practice in London, read in London's time
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
902 B raw
608 B gzip.
Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.6 kB raw.
CSS 4.7 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
every browser. Elsewhere: without JavaScript the fallback sentence shows, or nothing.
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 OpenStatus from '@moonarc/core/OpenStatus';
---
<OpenStatus
timezone="Europe/London"
fallback="Open Monday to Friday, 8:00 to 18:00"
hours={[
{ day: 'mon', label: 'Monday', open: '08:00', close: '18:00' },
{ day: 'tue', label: 'Tuesday', open: '08:00', close: '18:00' },
{ day: 'wed', label: 'Wednesday', open: '08:00', close: '20:00' },
{ day: 'thu', label: 'Thursday', open: '08:00', close: '18:00' },
{ day: 'fri', label: 'Friday', open: '08:00', close: '16:00' },
]}
/>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/open-status.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.
---
/**
* OpenStatus — one line that says whether a place is open now and until when, or when it opens next, read from the
* week's hours in the place's own time zone (Intl), whatever the reader's clock says. A dot beside it is lit while it is
* open; the words say it either way. A small script writes the line on load and every half minute while the page is
* open. Without JavaScript the `fallback` sentence stands in its place, or nothing does.
*/
import type { HTMLAttributes } from 'astro/types';
type Day = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';
interface Hours {
day: Day;
/** The day's name, as "opens {day}" reads it. */
label: string;
/** "08:00" and "18:00" in 24-hour time; both unset for a closed day. A close before the open runs past midnight. */
open?: string;
close?: string;
}
interface Props extends HTMLAttributes<'p'> {
/** The week, any order; a day left out is closed. */
hours: Hours[];
/** The place's IANA time zone, e.g. 'Europe/London'. */
timezone: string;
/** The line's words: {close}, {open} and {day} are filled in. */
openText?: string;
closedText?: string;
todayWord?: string;
tomorrowWord?: string;
/** What shows without JavaScript, or before the script has run: "Open Monday to Friday, 8:00 to 18:00". */
fallback?: string;
}
const {
hours, timezone, openText = 'Open now, until {close}', closedText = 'Closed now, opens {day} at {open}', todayWord = 'today', tomorrowWord = 'tomorrow',
fallback, class: className, ...rest
} = Astro.props;
const order: Day[] = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
const time = /^([01]\d|2[0-3]):[0-5]\d$/;
for (const h of hours) {
if (!order.includes(h.day)) throw new Error(`OpenStatus: "${h.day}" is not a day; it takes ${order.join(', ')}.`);
if (!!h.open !== !!h.close) throw new Error(`OpenStatus: ${h.day} sets ${h.open ? 'open' : 'close'} alone; a day sets both or neither.`);
for (const t of [h.open, h.close]) if (t && !time.test(t)) throw new Error(`OpenStatus: ${h.day} has the time "${t}"; write 24-hour HH:MM, such as 08:30.`);
}
try { new Intl.DateTimeFormat('en-GB', { timeZone: timezone }); } catch { throw new Error(`OpenStatus: "${timezone}" is not an IANA time zone, such as Europe/London.`); }
const week = order.map((d) => hours.find((h) => h.day === d));
const data = JSON.stringify(week.map((h) => (h?.open && h.close ? [h.open, h.close] : 0)));
const names = JSON.stringify(week.map((h, i) => h?.label ?? order[i]));
---
<p class:list={['ma-open', className]} data-ma-open data-tz={timezone} data-hours={data} data-days={names} data-open-text={openText} data-closed-text={closedText} data-today={todayWord} data-tomorrow={tomorrowWord} hidden={!fallback} {...rest}>
<span class="ma-open__dot" aria-hidden="true"></span><span class="ma-open__text">{fallback}</span>
</p>
<script>
import { onMount } from '../lib/runtime';
onMount<HTMLElement>('[data-ma-open]', (el, { signal }) => {
const d = el.dataset;
const hours: (0 | [string, string])[] = JSON.parse(d.hours!);
const days: string[] = JSON.parse(d.days!);
const text = el.querySelector<HTMLElement>('.ma-open__text')!;
const fmt = new Intl.DateTimeFormat('en-GB', { timeZone: d.tz, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
const tick = () => {
const p: Record<string, string> = {};
for (const { type, value } of fmt.formatToParts()) p[type] = value;
const today = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].indexOf(p.weekday!);
const now = `${p.hour}:${p.minute}`;
// open today, or still open from yesterday's late hours (a close before its open runs past midnight)
const h = hours[today];
const y = hours[(today + 6) % 7];
const late = !!y && y[1] < y[0] && now < y[1];
const open = late || (!!h && (h[1] < h[0] ? now >= h[0] : now >= h[0] && now < h[1]));
let line = '';
if (open) line = d.openText!.replace('{close}', ((late ? y : h) as [string, string])[1]);
else
for (let k = 0; k < 8; k++) {
const x = hours[(today + k) % 7];
if (x && (k || now < x[0])) {
line = d.closedText!.replace('{day}', k === 0 ? d.today! : k === 1 ? d.tomorrow! : days[(today + k) % 7]!).replace('{open}', x[0]);
break;
}
}
el.toggleAttribute('data-open', open);
text.textContent = line;
el.hidden = !line;
};
tick();
const timer = setInterval(tick, 30000);
signal.addEventListener('abort', () => clearInterval(timer));
});
</script>
<style is:global>
@layer components {
:where(.ma-open) { --ma-open-on: oklch(0.62 0.16 150); display: flex; align-items: center; gap: 0.55rem; margin: 0; font-weight: 550; }
:where(.ma-open[hidden]) { display: none; }
:where(.ma-open__dot) { flex: none; inline-size: 0.6rem; block-size: 0.6rem; border-radius: 999px; box-shadow: inset 0 0 0 2px currentColor; opacity: 0.6; }
:where(.ma-open[data-open] .ma-open__dot) { background: var(--ma-open-on); box-shadow: 0 0 0 0.2rem color-mix(in oklab, var(--ma-open-on) 25%, transparent); opacity: 1; animation: ma-open-pulse 2.4s var(--ma-ease-out, ease-out) 3; }
@keyframes ma-open-pulse { 50% { box-shadow: 0 0 0 0.45rem color-mix(in oklab, var(--ma-open-on) 0%, transparent); } }
@media (prefers-reduced-motion: reduce) {
:where(.ma-open[data-open] .ma-open__dot) { animation: none; }
}
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
hours | { day, label, open?, close? }[] | none | The week; a day left out, or without times, is closed. Times in 24-hour HH:MM. |
timezone | string | none | The place's IANA time zone, e.g. Europe/London. |
openText | string | 'Open now, until {close}' | The line while it is open. |
closedText | string | 'Closed now, opens {day} at {open}' | The line while it is closed. |
todayWord | string | 'today' | The {day} of an opening later today. |
tomorrowWord | string | 'tomorrow' | The {day} of an opening tomorrow; further days take their label. |
fallback | string | none | What shows without JavaScript and before the script runs. |
The lit dot does not pulse.
ClientRouterBound per element through the runtime; its timer stops when the page is left.
Replaces: opening-hours widgets · Google Business open now badge. See the migration table.
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.
1,284
A number whose digits roll to the new value when it changes.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown