Blog//6 min read
Opening hours component in React with time zones
Build a React opening hours component that shows today, split shifts and a live open status in the business time zone, without a server.
MSMarco Schulz, founder of Facade UI
The first "Open now" badge I shipped for a client lied. The salon was in Zurich, the site was deployed on servers in the US, and I was testing from my laptop at home. It showed "Open" at 11 pm because somewhere in the chain a date was created in the wrong time zone. That bug is the reason the Opening hours component and the Open now badge in Facade compute everything in the business's own IANA time zone and never trust the raw clock. Here is how they work.
Why new Date() on the client is wrong
new Date() gives you the visitor's local time. A visitor in Los Angeles looking at a barber in New York gets a Date whose getHours() is three hours behind the shop. Compare that to the shop's hours and the badge is wrong for every out of town visitor, which on a site that ranks for the city name is most of them.
The server is no better. On Vercel or any cloud host the server clock is UTC or whatever region the function runs in. If you render "Open now" on the server, you render it in the server's zone, and the first paint in the browser disagrees with the hydrated one.
The rule that fixes both: the business has one time zone, it lives in your data, and every comparison happens in that zone. The visitor's zone and the server's zone are irrelevant.
Intl and the business time zone
You do not need a date library for this. Intl.DateTimeFormat accepts a timeZone option and formatToParts gives you the weekday, hour and minute in that zone as separate strings. Turn them into a weekday index and minutes since midnight and you have everything a comparison needs:
export function getZonedNow(date: Date, timeZone: string): { day: number; minutes: number } {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
weekday: "short",
hour: "numeric",
minute: "numeric",
hourCycle: "h23",
}).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value ?? "0";
const day = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(get("weekday"));
const minutes = (Number(get("hour")) % 24) * 60 + Number(get("minute"));
return { day, minutes };
}hourCycle: "h23" asks for 0 to 23 hours, and the % 24 guards against engines that still return "24" for midnight. The Date you pass in is the real current instant; only its presentation changes. This is the whole trick: one instant, interpreted in the shop's zone.
Both Facade components use exactly this function. The badge even exports it along with getOpenStatus, so you can compute the status for a schema.org openingHoursSpecification or a "Closes in 20 minutes" line elsewhere on the page.
Split shifts and closed days
Hours data is where most components give up. A salon opens 9 to 1 and 2 to 7. A restaurant closes Mondays. A clinic is open Saturday morning only. The data shape has to express all of that without special cases in the UI.
Facade uses a weekly map where each day holds an array of ranges. Missing, null or an empty array means closed. More than one range means a split shift:
import type { WeeklyHours } from "@/components/facade/opening-hours";
export const hours: WeeklyHours = {
mon: null,
tue: [{ open: "09:00", close: "13:00" }, { open: "14:00", close: "19:00" }],
wed: [{ open: "09:00", close: "13:00" }, { open: "14:00", close: "19:00" }],
thu: [{ open: "09:00", close: "20:00" }],
fri: [{ open: "09:00", close: "19:00" }],
sat: [{ open: "08:00", close: "16:00" }],
sun: null,
};Times are 24 hour strings. They get parsed into minutes since midnight once, then every comparison is integer math. The list renders "Closed" for a null day and joins the ranges of a split day with a comma. A range that closes after midnight, like a bar open until 02:00, is handled by treating a close time earlier than the open time as belonging to the next day.
Highlighting today
Visitors scan the list for one row: today. Highlighting it is the single most useful thing the component does, and it has to use the business zone too. At 11 pm in New York it is already Tuesday in Zurich, so "today" for the Zurich salon is Tuesday.
The component gets today's index from the same getZonedNow call and renders a soft pill behind that row. The pill is a motion.div with a layoutId, so if the page stays open past midnight it slides to the next row instead of jumping. The row also gets aria-current="date" so screen readers know which day the highlight means.
Before the component mounts there is no "today". The component keeps now as null on the first render, renders the plain list with a "Loading" status, and fills in today and the status in an effect. That is what makes the server HTML and the first client render identical, which is the hydration mismatch I ran into on that salon site.
The weekStartsOn prop switches between a Monday first and a Sunday first list. Europe reads Monday first, US customers often expect Sunday first.
Live open status without a server
The open status is derived, not fetched. Every 30 seconds an interval updates now, the component recomputes "is there a range on today that contains the current minute", and the badge flips when the shop opens or closes while the page is open. No API call, no server, no cron.
The standalone badge adds a third state. With closingSoonMinutes set, it turns amber and reads "Closing soon" during the last stretch before close. The default is 60 minutes. For a restaurant that stops seating at 9 pm but closes at 10, pass the seating time as the close and keep the default.
Both components are free and MIT licensed:
npx shadcn@latest add https://facadeui.com/r/opening-hours.json https://facadeui.com/r/open-now-badge.jsonAnd together on a page, sharing one hours object:
import { OpeningHours } from "@/components/facade/opening-hours";
import { OpenNowBadge } from "@/components/facade/open-now-badge";
import { hours } from "@/data/hours";
export function HoursSection() {
return (
<section className="grid gap-6 md:grid-cols-[1fr_320px]">
<div>
<OpenNowBadge hours={hours} timeZone="Europe/Zurich" closingSoonMinutes={45} size="md" />
<h2 className="mt-4 text-3xl font-semibold">Visit the salon</h2>
</div>
<OpeningHours
hours={hours}
timeZone="Europe/Zurich"
weekStartsOn="mon"
title="Hours"
note="Closed on public holidays."
/>
</section>
);
}The badge belongs in the hero next to the phone number and in the sticky mobile bar. The full list belongs in the contact section and the footer. I use this exact pairing on the salon page, where "Are you open Sunday?" is the question that decides whether someone calls.
FAQ
What about holidays?
Holidays are exceptions, not a schedule, so they do not belong in WeeklyHours. Keep a small list of dates with their override hours in your data, pick today's override in the business zone, and pass the result as hours. The note prop is the place to tell visitors that holiday hours differ.
Does it work for businesses in several locations?
Yes. Each location has its own hours object and its own timeZone string. Render one OpeningHours per location, or one badge per location card. Nothing is shared globally.
Why not render the status on the server for SEO?
Search engines do not index "Open now" as a ranking signal, and a server rendered status would be wrong by the time the page is crawled anyway. Put the schedule itself in openingHoursSpecification JSON-LD, which is what Google reads, and let the live status be a client concern.