Skip to content

Components / Data

Open Status

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.

Live demo

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.

Measured

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

Baseline · widely available

every browser. Elsewhere: without JavaScript the fallback sentence shows, or nothing.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
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' },
  ]}
/>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/open-status.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.

OpenStatus.astro
---
/**
 * 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>

Props

PropTypeDefaultDescription
hours{ day, label, open?, close? }[]noneThe week; a day left out, or without times, is closed. Times in 24-hour HH:MM.
timezonestringnoneThe place's IANA time zone, e.g. Europe/London.
openTextstring'Open now, until {close}'The line while it is open.
closedTextstring'Closed now, opens {day} at {open}'The line while it is closed.
todayWordstring'today'The {day} of an opening later today.
tomorrowWordstring'tomorrow'The {day} of an opening tomorrow; further days take their label.
fallbackstringnoneWhat shows without JavaScript and before the script runs.

Reduced motion

The lit dot does not pulse.

With ClientRouter

Bound per element through the runtime; its timer stops when the page is left.

Why it is built this way

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.

every browserload

1,284

A number whose digits roll to the new value when it changes.

newly · Chrome 125 · Firefox 118 · Safari 15.4click

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown