# Opening hours

Weekly hours list with today highlighted, live open status, split shifts and closed days.

- Type: component
- Category: Local business
- Tier: Free
- Dependencies: lucide-react, motion

## Install

```bash
npx shadcn@latest add https://facadeui.com/r/opening-hours.json
```

## Usage

```tsx
import { OpeningHours } from "@/components/facade/opening-hours";

export default function Page() {
  return <OpeningHours />;
}
```

## Source

```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;
```
