Browse the library
Components / Local business
Open now badge
FreeLive open or closed pill computed from weekly hours in the business's own time zone.
View as Markdownmotion
components/facade/open-now-badge.tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "@/lib/utils";
/* ------------------------------------------------------------------
Hours model
------------------------------------------------------------------- */
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. Use "24:00" for 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>>;
export interface OpenStatus {
isOpen: boolean;
/** True when open and closing within `closingSoonMinutes`. */
closingSoon: boolean;
/** Short label for the next change, e.g. "closes 6 PM" or "opens 9 AM tomorrow". */
detail: string;
/** Minutes until the next open/close change, null if never. */
minutesUntilChange: number | null;
}
const DAY_KEYS: DayKey[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
const SHORT_DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const WEEK = 7 * 1440;
function toMinutes(t: string): number {
const [h, m = "0"] = t.split(":");
return Number(h) * 60 + Number(m);
}
export function formatClock(totalMinutes: number): string {
const mins = ((totalMinutes % 1440) + 1440) % 1440;
const h = Math.floor(mins / 60);
const m = mins % 60;
if (h === 0 && m === 0) return "midnight";
if (h === 12 && m === 0) return "noon";
const period = h < 12 ? "AM" : "PM";
const h12 = h % 12 || 12;
return m ? `${h12}:${String(m).padStart(2, "0")} ${period}` : `${h12} ${period}`;
}
/** Weekday index (0 = Sunday) and minutes since midnight in the given IANA time zone. */
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 = Math.max(0, SHORT_DAYS.indexOf(get("weekday")));
const minutes = (Number(get("hour")) % 24) * 60 + Number(get("minute"));
return { day, minutes };
}
function weekIntervals(hours: WeeklyHours): Array<[number, number]> {
const out: Array<[number, number]> = [];
DAY_KEYS.forEach((key, d) => {
for (const r of hours[key] ?? []) {
const s = toMinutes(r.open);
let e = toMinutes(r.close);
if (e <= s) e += 1440;
out.push([d * 1440 + s, d * 1440 + e]);
}
});
return out.sort((a, b) => a[0] - b[0]);
}
export function getOpenStatus(
hours: WeeklyHours,
timeZone: string,
date: Date = new Date(),
closingSoonMinutes = 60,
): OpenStatus {
const intervals = weekIntervals(hours);
if (intervals.length === 0) {
return { isOpen: false, closingSoon: false, detail: "temporarily closed", minutesUntilChange: null };
}
const { day, minutes } = getZonedNow(date, timeZone);
const now = day * 1440 + minutes;
/** Remaining minutes of the interval covering week-minute `t`, or null. */
const remainingAt = (t: number): number | null => {
for (const [s, e] of intervals) {
if (t >= s && t < e) return e - t;
// Saturday night ranges that spill into Sunday morning.
if (t + WEEK >= s && t + WEEK < e) return e - (t + WEEK);
}
return null;
};
const first = remainingAt(now);
if (first !== null) {
// Follow back-to-back ranges (e.g. 24h days) to find the real close time.
let end = now + first;
for (let i = 0; i < 14; i++) {
const more = remainingAt(end % WEEK);
if (more === null || more <= 0) break;
end += more;
if (end - now >= WEEK) {
return { isOpen: true, closingSoon: false, detail: "open 24 hours", minutesUntilChange: null };
}
}
const until = end - now;
const dayDiff = Math.floor(end / 1440) - day;
const when = formatClock(end);
const detail =
dayDiff <= 0 || (dayDiff === 1 && end % 1440 <= 360)
? `closes ${when}`
: `closes ${SHORT_DAYS[(day + dayDiff) % 7]} ${when}`;
return { isOpen: true, closingSoon: until <= closingSoonMinutes, detail, minutesUntilChange: until };
}
let best: number | null = null;
for (const [s] of intervals) {
const delta = (((s - now) % WEEK) + WEEK) % WEEK || WEEK;
if (best === null || delta < best) best = delta;
}
const opensAt = now + (best ?? 0);
const dayDiff = Math.floor(opensAt / 1440) - day;
const when = formatClock(opensAt);
const detail =
dayDiff === 0
? `opens ${when}`
: dayDiff === 1
? `opens ${when} tomorrow`
: `opens ${SHORT_DAYS[(day + dayDiff) % 7]} ${when}`;
return { isOpen: false, closingSoon: false, detail, minutesUntilChange: best };
}
/* ------------------------------------------------------------------
Component
------------------------------------------------------------------- */
export interface OpenNowBadgeProps {
hours: WeeklyHours;
/** IANA time zone of the business, e.g. "America/Chicago". */
timeZone: string;
/** Show the amber "Closing soon" state this many minutes before close. */
closingSoonMinutes?: number;
size?: "sm" | "md";
className?: string;
}
export function OpenNowBadge({
hours,
timeZone,
closingSoonMinutes = 60,
size = "md",
className,
}: OpenNowBadgeProps) {
// Time depends on the visitor's clock, so only compute after mount.
const [now, setNow] = React.useState<Date | null>(null);
React.useEffect(() => {
setNow(new Date());
const id = window.setInterval(() => setNow(new Date()), 30_000);
return () => window.clearInterval(id);
}, []);
const status = React.useMemo(
() => (now ? getOpenStatus(hours, timeZone, now, closingSoonMinutes) : null),
[now, hours, timeZone, closingSoonMinutes],
);
const tone = !status ? "idle" : status.isOpen ? (status.closingSoon ? "soon" : "open") : "closed";
const headline = !status ? "" : status.isOpen ? (status.closingSoon ? "Closing soon" : "Open now") : "Closed";
return (
<span
role="status"
aria-live="polite"
className={cn(
"inline-flex items-center gap-2 rounded-full border border-border bg-card text-card-foreground shadow-sm",
"shadow-foreground/[0.03]",
size === "sm" ? "h-7 px-2.5 text-xs" : "h-8 px-3 text-sm",
className,
)}
>
<span className="relative flex size-2 shrink-0" aria-hidden>
{(tone === "open" || tone === "soon") && (
<span
className={cn(
"absolute inset-0 rounded-full animate-ping-slow motion-reduce:animate-none",
tone === "open" ? "bg-success/70" : "bg-warning/70",
)}
/>
)}
<span
className={cn(
"relative size-2 rounded-full transition-colors duration-500",
tone === "open" && "bg-success",
tone === "soon" && "bg-warning",
tone === "closed" && "bg-muted-foreground/50",
tone === "idle" && "bg-muted-foreground/25",
)}
/>
</span>
<AnimatePresence mode="wait" initial={false}>
{status ? (
<motion.span
key={`${headline}-${status.detail}`}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
className="flex items-center gap-1.5 whitespace-nowrap"
>
<span
className={cn(
"font-medium",
tone === "open" && "text-success",
tone === "soon" && "text-warning",
tone === "closed" && "text-foreground",
)}
>
{headline}
</span>
<span className="text-muted-foreground/60" aria-hidden>
·
</span>
<span className="text-muted-foreground">{status.detail}</span>
</motion.span>
) : (
<span key="placeholder" className="flex items-center">
<span
aria-hidden
className="h-3 w-36 rounded-full bg-[linear-gradient(90deg,var(--muted)_0%,var(--border)_50%,var(--muted)_100%)] bg-[length:200%_100%] animate-shimmer motion-reduce:animate-none"
/>
<span className="sr-only">Checking hours</span>
</span>
)}
</AnimatePresence>
</span>
);
}
export default OpenNowBadge;app/page.tsx
"use client";
import { MapPin, Star } from "lucide-react";
import { OpenNowBadge, type WeeklyHours } from "@/components/facade/open-now-badge";
const coffee: WeeklyHours = {
mon: [{ open: "06:30", close: "19:00" }],
tue: [{ open: "06:30", close: "19:00" }],
wed: [{ open: "06:30", close: "19:00" }],
thu: [{ open: "06:30", close: "19:00" }],
fri: [{ open: "06:30", close: "20:00" }],
sat: [{ open: "07:30", close: "20:00" }],
sun: [{ open: "07:30", close: "16:00" }],
};
const dental: WeeklyHours = {
mon: [{ open: "08:00", close: "17:00" }],
tue: [{ open: "08:00", close: "17:00" }],
wed: [{ open: "08:00", close: "12:00" }, { open: "13:00", close: "19:00" }],
thu: [{ open: "08:00", close: "17:00" }],
fri: [{ open: "08:00", close: "14:00" }],
};
const tacos: WeeklyHours = {
mon: [{ open: "11:00", close: "23:00" }],
tue: [{ open: "11:00", close: "23:00" }],
wed: [{ open: "11:00", close: "23:00" }],
thu: [{ open: "11:00", close: "01:00" }],
fri: [{ open: "11:00", close: "03:00" }],
sat: [{ open: "10:00", close: "03:00" }],
sun: [{ open: "10:00", close: "22:00" }],
};
const places = [
{
name: "Ember & Rye Coffee",
kind: "Coffee shop",
where: "South Congress, Austin",
rating: "4.8",
hours: coffee,
tz: "America/Chicago",
initials: "ER",
},
{
name: "Cherry Creek Family Dental",
kind: "Dentist",
where: "Cherry Creek, Denver",
rating: "4.9",
hours: dental,
tz: "America/Denver",
initials: "CC",
},
{
name: "La Pequeña Taquería",
kind: "Mexican restaurant",
where: "Wynwood, Miami",
rating: "4.7",
hours: tacos,
tz: "America/New_York",
initials: "LP",
},
];
export default function Demo() {
return (
<div className="mx-auto w-full max-w-xl space-y-3 p-4">
{places.map((p) => (
<article
key={p.name}
className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 text-card-foreground sm:flex-row sm:items-center"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand/10 font-display text-sm font-semibold text-brand">
{p.initials}
</div>
<div className="min-w-0">
<h3 className="truncate font-medium tracking-tight">{p.name}</h3>
<p className="mt-0.5 flex items-center gap-1.5 truncate text-xs text-muted-foreground">
<Star className="size-3 fill-warning text-warning" aria-hidden />
<span className="font-mono text-foreground">{p.rating}</span>
<span aria-hidden>·</span>
{p.kind}
<span aria-hidden>·</span>
<MapPin className="size-3" aria-hidden />
{p.where}
</p>
</div>
</div>
<OpenNowBadge hours={p.hours} timeZone={p.tz} size="sm" />
</article>
))}
</div>
);
}Installation
$
npx shadcn@latest add https://facadeui.com/r/open-now-badge.jsonUsage
import { OpenNowBadge } from "@/components/facade/open-now-badge";
export default function Page() {
return <OpenNowBadge />;
}Props
OpenNowBadgeProps is exported, so you can extend or wrap it. Every prop has a default; <OpenNowBadge /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| hours* | WeeklyHours | |
| timeZone* | string | IANA time zone of the business, e.g. "America/Chicago". |
| closingSoonMinutes | number | Show the amber "Closing soon" state this many minutes before close. |
| size | "sm" | "md" | |
| className | string |
* required
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade open now badge to this page and adapt the copy to our business.