Skip to content

Hours Poster Section

Opening hours set as a poster — days hanging off the edge, closed rows struck through, today banded in the one accent — with an open/closed line on the shop's clock.

FreeEditorialeditorialbrutalist
Category
Sections
Added
2026-09-25
Deps
none
Updated
2026-09-25
Preview

Opening hours

  1. Monday: 09:00 – 18:00
  2. Tuesday: 09:00 – 18:00
  3. Wednesday: 09:00 – 18:00
  4. Thursday: 09:00 – 20:00
  5. Friday: 09:00 – 18:00
  6. Saturday: 08:00 – 16:00
  7. Sunday: Closed
Closed bank holidays01632 960 118

Props

12-hour clock
No runtime dependencies
"use client";

import { useSyncExternalStore } from "react";

/**
 * HoursPoster — opening hours set like a poster, not a table.
 *
 * Seven fixed-height rows. The day hangs off the left edge in heavy two-letter
 * caps, the hours sit in tabular monospace so the column holds, and a closed
 * day is struck through the ENTIRE row, so the negative space says closed
 * before the word does. Today is the one place the accent appears: a full
 * band behind that row. The status line ("Open now · closes 18:00") is
 * computed on the shop's clock, not the visitor's.
 *
 * NEVER RENDER THE TIME ON THE SERVER. A static page is built once and served
 * for hours, so a server-rendered "today" is both wrong and a hydration
 * mismatch. The clock is a useSyncExternalStore whose server snapshot is 0:
 * before hydration no row is today and the status shows an ellipsis; after
 * it, the store ticks at minute boundaries (and immediately when the tab
 * comes back, since background tabs throttle timers).
 *
 * Days are indexed Monday = 0 … Sunday = 6. A day with no ranges is closed. A
 * range that closes at or before it opens runs overnight. Needs Tailwind v4
 * (container queries are built in).
 */

export type DayHours = {
  /** Open/close pairs as "HH:MM", 24-hour. None or empty = closed. */
  ranges?: [string, string][];
  /** "Walk-ins", "By appointment", "Kitchen till 21:30". */
  note?: string;
};

const DAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
const DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
const WEEK = 7 * 1440;
const HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;

function hex(v: string, fallback: string) {
  return HEX.test(v) ? v : fallback;
}
function rgba(colour: string, alpha: number) {
  let h = colour.slice(1);
  if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
  const n = parseInt(h, 16);
  return "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + alpha + ")";
}

/* A minute-resolution clock shared by every instance on the page. */
let minuteNow = 0;
function subscribeMinute(onChange: () => void) {
  let timer = 0;
  const tick = () => {
    minuteNow = Math.floor(Date.now() / 60000);
    onChange();
    window.clearTimeout(timer);
    // Aim just past the boundary; a timer that fires early reads the old minute.
    timer = window.setTimeout(tick, 60000 - (Date.now() % 60000) + 50);
  };
  const onVisible = () => {
    if (document.visibilityState === "visible") tick();
  };
  tick();
  document.addEventListener("visibilitychange", onVisible);
  return () => {
    window.clearTimeout(timer);
    document.removeEventListener("visibilitychange", onVisible);
  };
}
const readMinute = () => minuteNow;
const readMinuteServer = () => 0;

/** Weekday (Mon = 0) and minute of day on the shop's wall clock; null for a bad zone. */
function zoneNow(ms: number, timeZone: string): { day: number; minute: number } | null {
  try {
    const parts = new Intl.DateTimeFormat("en-GB", {
      timeZone,
      weekday: "short",
      hour: "2-digit",
      minute: "2-digit",
      hourCycle: "h23",
    }).formatToParts(new Date(ms));
    const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
    const day = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].indexOf(get("weekday"));
    if (day < 0) return null;
    return { day, minute: Number(get("hour")) * 60 + Number(get("minute")) };
  } catch {
    return null;
  }
}

function toMinutes(hhmm: string) {
  const [h, m] = hhmm.split(":").map(Number);
  return (h || 0) * 60 + (m || 0);
}

function fmt(min: number, hour12: boolean) {
  const m = ((min % 1440) + 1440) % 1440;
  const h = Math.floor(m / 60);
  const mm = String(m % 60).padStart(2, "0");
  if (!hour12) return String(h).padStart(2, "0") + ":" + mm;
  return (h % 12 || 12) + (m % 60 ? ":" + mm : "") + (h < 12 ? "am" : "pm");
}

function status(days: DayHours[], now: { day: number; minute: number }, hour12: boolean) {
  const spans: { s: number; e: number }[] = [];
  days.forEach((d, i) =>
    (d.ranges ?? []).forEach(([o, c]) => {
      const s = i * 1440 + toMinutes(o);
      let e = i * 1440 + toMinutes(c);
      if (e <= s) e += 1440; // overnight
      spans.push({ s, e });
    }),
  );
  const t = now.day * 1440 + now.minute;
  for (const sp of spans) {
    // A Sunday-night span that runs into Monday holds Monday's early minutes at t + WEEK.
    if ((t >= sp.s && t < sp.e) || (t + WEEK >= sp.s && t + WEEK < sp.e)) {
      return { open: true, text: "Open now · closes " + fmt(sp.e, hour12) };
    }
  }
  if (spans.length === 0) return { open: false, text: "By appointment" };
  let wait = Infinity;
  let at = 0;
  for (const sp of spans) {
    const d = (((sp.s - t) % WEEK) + WEEK) % WEEK;
    if (d < wait) {
      wait = d;
      at = sp.s;
    }
  }
  const day = Math.floor(at / 1440) % 7;
  const when =
    day === now.day && wait < 1440 ? "today" : day === (now.day + 1) % 7 && wait < 2880 ? "tomorrow" : DAY_NAMES[day];
  return { open: false, text: "Closed · opens " + when + " " + fmt(at, hour12) };
}

export function HoursPoster({
  days,
  timeZone = "Europe/London",
  title = "Opening hours",
  phone,
  note,
  accent = "#c8452c",
  paper = "#f4f2ee",
  ink = "#111111",
  hour12 = false,
  today,
  className = "",
}: {
  /** Seven entries, Monday first. */
  days: DayHours[];
  /** IANA zone the shop lives in. */
  timeZone?: string;
  title?: string;
  /** Rendered as a tel: link. */
  phone?: string;
  /** Small print under the rows. */
  note?: string;
  /** The band behind today. Hex only. */
  accent?: string;
  paper?: string;
  ink?: string;
  hour12?: boolean;
  /** Force which row is today (0 = Monday) — for screenshots and tests. */
  today?: number;
  className?: string;
}) {
  const minute = useSyncExternalStore(subscribeMinute, readMinute, readMinuteServer);
  const now = minute ? zoneNow(minute * 60000, timeZone) : null;
  const todayIndex = today ?? now?.day ?? -1;
  const st = now ? status(days, now, hour12) : null;

  const acc = hex(accent, "#c8452c");
  const pa = hex(paper, "#f4f2ee");
  const ik = hex(ink, "#111111");
  const line = rgba(ik, 0.14);

  return (
    <section
      className={"@container relative flex h-full flex-col overflow-hidden font-sans " + className}
      style={{ background: pa, color: ik }}
    >
      <div className="flex items-start justify-between gap-6 px-6 pt-6 @3xl:px-12 @3xl:pt-8">
        <h2 className="font-mono text-[11px] uppercase tracking-[0.18em]">{title}</h2>
        <p className="text-right font-mono text-[11px] uppercase tracking-[0.18em]" aria-live="polite">
          {st ? (
            <>
              <span
                aria-hidden="true"
                className="mr-2 inline-block size-1.5 rounded-full align-middle"
                style={{ background: st.open ? acc : ik, opacity: st.open ? 1 : 0.35 }}
              />
              {st.text}
            </>
          ) : (
            <span aria-hidden="true">…</span>
          )}
        </p>
      </div>

      <ol className="mt-5 list-none @3xl:mt-7">
        {DAYS.map((d, i) => {
          const day = days[i] ?? {};
          const ranges = day.ranges ?? [];
          const closed = ranges.length === 0;
          const isToday = i === todayIndex;
          const fg = isToday ? pa : ik;
          return (
            <li
              key={d}
              aria-current={isToday ? "date" : undefined}
              className="relative flex h-16 items-center border-t @3xl:h-[72px]"
              style={{
                borderColor: isToday ? "transparent" : line,
                background: isToday ? acc : "transparent",
                color: fg,
              }}
            >
              <span className="sr-only">{DAY_NAMES[i]}: </span>
              {/* The day hangs off the left edge on purpose. */}
              <span
                aria-hidden="true"
                className="w-[34%] shrink-0 text-[52px] leading-none font-black tracking-[-0.06em] uppercase select-none @3xl:w-[40%] @3xl:text-[66px]"
                style={{ marginLeft: "-0.09em" }}
              >
                {d}
              </span>
              <span className="font-mono text-[14px] tracking-[0.02em] tabular-nums @3xl:text-[16px]">
                {closed
                  ? "Closed"
                  : ranges.map(([o, c]) => fmt(toMinutes(o), hour12) + " – " + fmt(toMinutes(c), hour12)).join("  ·  ")}
              </span>
              {day.note && (
                <span className="ml-auto hidden pr-6 font-mono text-[11px] uppercase tracking-[0.14em] opacity-60 @3xl:block @3xl:pr-12">
                  {day.note}
                </span>
              )}
              {closed && (
                <span aria-hidden="true" className="absolute inset-x-0 top-1/2 h-[2px]" style={{ background: fg }} />
              )}
            </li>
          );
        })}
      </ol>

      <div
        className="mt-auto flex items-end justify-between gap-6 border-t px-6 py-5 font-mono text-[11px] uppercase tracking-[0.14em] @3xl:px-12"
        style={{ borderColor: line }}
      >
        <span className="opacity-60">{note}</span>
        {phone && (
          <a href={"tel:" + phone.replace(/[^+\d]/g, "")} className="underline-offset-4 hover:underline">
            {phone}
          </a>
        )}
      </div>
    </section>
  );
}
Notes

About this section

Opening hours are the most-read words on a small business site and nearly always the worst set: a two-column table, sometimes with green and red dots. This section treats them as a poster. Each day is a heavy two-letter cap hanging off the left edge of the page, the hours sit in tabular monospace so the column holds to the pixel, and a closed day is struck through across the entire row, so the empty space says closed before the word does. Today is the only place the accent appears, as a full band behind that one row. Above it all a single line says whether the shop is open right now and when that changes, computed on the shop's own clock in its own time zone rather than the visitor's, so a customer checking from abroad is not told the wrong thing. Nothing about the time is rendered on the server: the clock hydrates in and ticks at minute boundaries. Split shifts, late nights and overnight hours are all handled, and every row carries the full day name for screen readers behind the two-letter display.

Curator’s note

The question every local business gets phoned about is "are you open?". A two-column table answers it like a spreadsheet. A poster answers it from across the room.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

Featured
NewPro

The closing spread of a trade site: an oversized two-tier ask cropped by the edge, a two-field enquiry line with one filled button, and a ledger of the four things customers ring to check.

sectioncontactform
Featured
NewPro

The page ends, lifts, and the footer is already there underneath: a sticky footer revealed by the last section sliding over it, with the wordmark cropped by the bottom edge. No JavaScript in the reveal.

footerscrollsticky
Featured
NewPro

Live Hours Nav

components

A small-business header that says whether the shop is open right now — computed in the shop's timezone, with overnight hours and split shifts handled.

navigationheaderopening-hours