Browse the library
Components / Local business
Opening hours
FreeWeekly hours list with today highlighted, live open status, split shifts and closed days.
components/facade/opening-hours.tsx
"use client";
import * as React from "react";
import { motion } from "motion/react";
import { Clock } from "lucide-react";
import { cn } from "@/lib/utils";
/* ------------------------------------------------------------------
Hours model (same shape as open-now-badge, duplicated so this
item installs on its own)
------------------------------------------------------------------- */
export type DayKey = "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat";
/** 24h "HH:mm". A close time earlier than (or equal to) open runs past midnight. */
export interface TimeRange {
open: string;
close: string;
}
/** Missing day, `null` or an empty array means closed. Multiple ranges = split hours. */
export type WeeklyHours = Partial<Record<DayKey, TimeRange[] | null>>;
const DAY_KEYS: DayKey[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
const SHORT_DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
function toMinutes(t: string): number {
const [h, m = "0"] = t.split(":");
return Number(h) * 60 + Number(m);
}
function formatClock(total: number): string {
const mins = ((total % 1440) + 1440) % 1440;
const h = Math.floor(mins / 60);
const m = mins % 60;
if (h === 0 && m === 0) return "12 AM";
const period = h < 12 ? "AM" : "PM";
const h12 = h % 12 || 12;
return m ? `${h12}:${String(m).padStart(2, "0")} ${period}` : `${h12} ${period}`;
}
function zonedNow(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";
return {
day: Math.max(0, SHORT_DAYS.indexOf(get("weekday"))),
minutes: (Number(get("hour")) % 24) * 60 + Number(get("minute")),
};
}
function isOpenAt(hours: WeeklyHours, day: number, minutes: number): boolean {
const today = hours[DAY_KEYS[day]] ?? [];
for (const r of today) {
const s = toMinutes(r.open);
let e = toMinutes(r.close);
if (e <= s) e += 1440;
if (minutes >= s && minutes < e) return true;
}
// Overnight spill from yesterday.
const yesterday = hours[DAY_KEYS[(day + 6) % 7]] ?? [];
for (const r of yesterday) {
const s = toMinutes(r.open);
const e = toMinutes(r.close);
if (e <= s && minutes < e) return true;
}
return false;
}
/* ------------------------------------------------------------------
Component
------------------------------------------------------------------- */
export interface OpeningHoursProps {
/** Weekly schedule. Defaults to a Monday to Saturday storefront example. */
hours?: WeeklyHours;
/** IANA time zone of the business, e.g. "America/Los_Angeles". Defaults to "America/New_York". */
timeZone?: string;
/** First row of the list. US convention is Monday or Sunday. */
weekStartsOn?: "mon" | "sun";
title?: string;
/** Optional footnote, e.g. holiday hours. */
note?: string;
className?: string;
}
const WEEKDAY_HOURS: TimeRange[] = [{ open: "09:00", close: "18:00" }];
const DEFAULT_HOURS: WeeklyHours = {
mon: WEEKDAY_HOURS,
tue: WEEKDAY_HOURS,
wed: WEEKDAY_HOURS,
thu: WEEKDAY_HOURS,
fri: WEEKDAY_HOURS,
sat: [{ open: "10:00", close: "16:00" }],
sun: null,
};
export function OpeningHours({
hours = DEFAULT_HOURS,
timeZone = "America/New_York",
weekStartsOn = "mon",
title = "Hours",
note,
className,
}: OpeningHoursProps) {
const uid = React.useId();
const [now, setNow] = React.useState<{ day: number; minutes: number } | null>(null);
React.useEffect(() => {
const tick = () => setNow(zonedNow(new Date(), timeZone));
tick();
const id = window.setInterval(tick, 30_000);
return () => window.clearInterval(id);
}, [timeZone]);
const order = weekStartsOn === "mon" ? [1, 2, 3, 4, 5, 6, 0] : [0, 1, 2, 3, 4, 5, 6];
const open = now ? isOpenAt(hours, now.day, now.minutes) : null;
const tzLabel = React.useMemo(() => {
try {
const part = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "short" })
.formatToParts(new Date())
.find((p) => p.type === "timeZoneName");
return part?.value ?? "";
} catch {
return "";
}
}, [timeZone]);
return (
<section
aria-label={title}
className={cn(
"w-full rounded-2xl border border-border bg-card p-5 text-card-foreground shadow-sm shadow-foreground/[0.03]",
className,
)}
>
<header className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<span className="grid size-8 place-items-center rounded-lg bg-muted text-muted-foreground">
<Clock className="size-4" aria-hidden />
</span>
<div>
<h3 className="font-display text-base font-semibold tracking-tight">{title}</h3>
{tzLabel && <p className="text-xs text-muted-foreground">Times in {tzLabel}</p>}
</div>
</div>
<span
className={cn(
"inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors",
open === null && "bg-muted text-transparent",
open === true && "bg-success/10 text-success",
open === false && "bg-muted text-muted-foreground",
)}
aria-live="polite"
>
<span
className={cn(
"size-1.5 rounded-full",
open ? "bg-success" : "bg-muted-foreground/50",
open === null && "bg-transparent",
)}
aria-hidden
/>
{open === null ? "Loading" : open ? "Open now" : "Closed now"}
</span>
</header>
<ul className="relative">
{order.map((d) => {
const ranges = hours[DAY_KEYS[d]] ?? [];
const isToday = now?.day === d;
return (
<li
key={d}
aria-current={isToday ? "date" : undefined}
className="relative flex items-start justify-between gap-4 px-3 py-2 text-sm"
>
{isToday && (
<motion.span
layoutId={`${uid}-today`}
className="absolute inset-0 rounded-lg bg-brand/[0.08] ring-1 ring-inset ring-brand/20"
transition={{ type: "spring", stiffness: 400, damping: 36 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
aria-hidden
/>
)}
<span
className={cn(
"relative flex items-center gap-2",
isToday ? "font-medium text-foreground" : "text-muted-foreground",
)}
>
<span className="sm:hidden">{SHORT_DAYS[d]}</span>
<span className="hidden sm:inline">{DAY_NAMES[d]}</span>
{isToday && (
<span className="rounded-full bg-brand px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-brand-foreground">
Today
</span>
)}
</span>
<span className="relative flex flex-col items-end gap-0.5 text-right">
{ranges.length === 0 ? (
<span className={cn(isToday ? "text-destructive" : "text-muted-foreground/70")}>Closed</span>
) : (
ranges.map((r) => {
const s = toMinutes(r.open);
const e = toMinutes(r.close);
const allDay = s === e;
return (
<span
key={`${r.open}-${r.close}`}
className={cn("font-mono text-[13px] tabular-nums", isToday ? "text-foreground" : "text-foreground/80")}
>
{allDay ? "Open 24 hours" : `${formatClock(s)} – ${formatClock(e)}`}
</span>
);
})
)}
</span>
</li>
);
})}
</ul>
{note && (
<p className="mt-4 border-t border-border pt-3 text-xs leading-relaxed text-muted-foreground">{note}</p>
)}
</section>
);
}
export default OpeningHours;app/page.tsx
"use client";
import { OpeningHours, type WeeklyHours } from "@/components/facade/opening-hours";
const hours: WeeklyHours = {
mon: null,
tue: [{ open: "11:30", close: "14:30" }, { open: "17:00", close: "22:00" }],
wed: [{ open: "11:30", close: "14:30" }, { open: "17:00", close: "22:00" }],
thu: [{ open: "11:30", close: "14:30" }, { open: "17:00", close: "22:00" }],
fri: [{ open: "11:30", close: "14:30" }, { open: "17:00", close: "23:30" }],
sat: [{ open: "10:00", close: "23:30" }],
sun: [{ open: "10:00", close: "15:00" }],
};
export default function Demo() {
return (
<div className="mx-auto w-full max-w-sm p-4">
<OpeningHours
title="Alder & Vine, Pearl District"
hours={hours}
timeZone="America/Los_Angeles"
note="Closed Thanksgiving Day. Brunch served Saturday and Sunday until 2 PM. Kitchen closes 30 minutes before close."
/>
</div>
);
}Installation
$
npx shadcn@latest add https://facadeui.com/r/opening-hours.jsonUsage
import { OpeningHours } from "@/components/facade/opening-hours";
export default function Page() {
return <OpeningHours />;
}Props
OpeningHoursProps is exported, so you can extend or wrap it. Every prop has a default; <OpeningHours /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| hours | WeeklyHours | Weekly schedule. Defaults to a Monday to Saturday storefront example. |
| timeZone | string | IANA time zone of the business, e.g. "America/Los_Angeles". Defaults to "America/New_York". |
| weekStartsOn | "mon" | "sun" | First row of the list. US convention is Monday or Sunday. |
| title | string | |
| note | string | Optional footnote, e.g. holiday hours. |
| className | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade opening hours to this page and adapt the copy to our business.