Layr Components

Components / Numbers and data

Hours Dial

Opening hours as a 24-hour ring: today's hours as arcs, a hand at the venue's own time, Open now or Closed, and the week beside it.

Props
#0E0E0D
#6E6E6B
#E6E5E1
#FF4A1C
#0E0E0D
#F5F4F1
400

Change the hours or the time zone in the props.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Hours Dial shows up in the Insert menu, and its controls appear in the right panel.
// Hours Dial · Layr Components
//
// Opening hours as a 24-hour ring: today's hours drawn as arcs, a hand at the
// venue's own time, "Open now" or "Closed" with when that changes, and the
// week listed beside it. Split hours and closing after midnight are handled.
// Paste into a new code file in Framer. Give it a width; its height follows.
// Below 560px wide the week list moves under the dial.

import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { startTransition, useEffect, useMemo, useState, type CSSProperties } from "react"

interface HoursDialProps {
    timeZone: string
    mon: string
    tue: string
    wed: string
    thu: string
    fri: string
    sat: string
    sun: string
    showList: boolean
    openLabel: string
    closedLabel: string
    textColor: string
    mutedColor: string
    lineColor: string
    arcColor: string
    handColor: string
    faceColor: string
    fontFamily: string
    displayFamily: string
    displayWeight: number
    style?: CSSProperties
}

type Range = [number, number] // minutes from midnight; the end may run past 1440
interface Status {
    open: boolean
    line: string
    detail: string
}

const NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
const SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]

const SHEET = `
.lyr-hd { container-type: inline-size; }
.lyr-hd .lyr-hd-grid { display: grid; grid-template-columns: minmax(0, 340px) minmax(0, 1fr); gap: clamp(24px, 6cqw, 72px); align-items: center; }
.lyr-hd .lyr-hd-grid.lyr-hd-solo { grid-template-columns: minmax(0, 1fr); }
.lyr-hd .lyr-hd-list { list-style: none; margin: 0; padding: 0; }
.lyr-hd .lyr-hd-list li { display: flex; justify-content: space-between; gap: 16px; padding: 13px 0; border-bottom: 1px solid var(--lyr-hd-line); font-size: 17px; line-height: 1.3; color: var(--lyr-hd-muted); font-variant-numeric: tabular-nums; }
.lyr-hd .lyr-hd-list li:first-child { border-top: 1px solid var(--lyr-hd-line); }
.lyr-hd .lyr-hd-list li[data-today="true"] { color: var(--lyr-hd-text); font-weight: 500; }
.lyr-hd .lyr-hd-list li span:last-child { text-align: right; }
@container (max-width: 560px) { .lyr-hd .lyr-hd-grid { grid-template-columns: minmax(0, 1fr); } .lyr-hd .lyr-hd-dial { max-width: 300px; margin: 0 auto; } }
`

function parse(text: string): Range[] {
    const out: Range[] = []
    String(text || "")
        .split(",")
        .forEach((bit) => {
            const m = bit.match(/(\d{1,2})[:.]?(\d{2})?\s*(?:[-–—]|to)\s*(\d{1,2})[:.]?(\d{2})?/i)
            if (!m) return
            const a = Number(m[1]) * 60 + Number(m[2] || 0)
            let b = Number(m[3]) * 60 + Number(m[4] || 0)
            if (b <= a) b += 1440
            out.push([a, b])
        })
    return out.sort((x, y) => x[0] - y[0])
}

const clock = (mins: number) => {
    const m = ((mins % 1440) + 1440) % 1440
    return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`
}

// The weekday (0 = Monday) and minutes past midnight in the venue's time zone.
// An empty or unknown zone falls back to the visitor's own clock.
function zoned(timeZone: string): { day: number; mins: number } {
    let parts: Intl.DateTimeFormatPart[]
    try {
        parts = new Intl.DateTimeFormat("en-GB", { timeZone, weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false }).formatToParts(new Date())
    } catch {
        parts = new Intl.DateTimeFormat("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false }).formatToParts(new Date())
    }
    const get = (t: string) => parts.find((p) => p.type === t)?.value || ""
    return { day: Math.max(0, SHORT.indexOf(get("weekday"))), mins: (Number(get("hour")) % 24) * 60 + Number(get("minute")) }
}

function statusOf(week: Range[][], day: number, mins: number, openLabel: string, closedLabel: string): Status {
    const today = week[day]
    const spill = week[(day + 6) % 7].filter((r) => r[1] > 1440 && mins < r[1] - 1440)
    if (spill.length) return { open: true, line: `${openLabel}, until ${clock(spill[0][1])}`, detail: `until ${clock(spill[0][1])}` }
    const now = today.find((r) => mins >= r[0] && mins < r[1])
    if (now) return { open: true, line: `${openLabel}, until ${clock(now[1])}`, detail: `until ${clock(now[1])}` }
    const later = today.find((r) => r[0] > mins)
    if (later) return { open: false, line: `${closedLabel}, opens ${clock(later[0])}`, detail: `opens ${clock(later[0])}` }
    for (let i = 1; i <= 7; i++) {
        const d = (day + i) % 7
        if (week[d].length) return { open: false, line: `${closedLabel}, opens ${i === 1 ? "tomorrow" : SHORT[d]} ${clock(week[d][0][0])}`, detail: `opens ${i === 1 ? "tomorrow" : SHORT[d]} ${clock(week[d][0][0])}` }
    }
    return { open: false, line: closedLabel, detail: "" }
}

/**
 * @framerIntrinsicWidth 760
 * @framerIntrinsicHeight 380
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight auto
 */
export default function HoursDial(props: HoursDialProps) {
    const {
        timeZone = "Europe/Lisbon",
        mon = "Closed",
        tue = "18:30-23:00",
        wed = "18:30-23:00",
        thu = "12:30-15:00, 18:30-23:00",
        fri = "12:30-15:00, 18:30-00:00",
        sat = "12:30-15:00, 18:30-00:00",
        sun = "13:00-17:00",
        showList = true,
        openLabel = "Open now",
        closedLabel = "Closed",
        textColor = "#1B1A17",
        mutedColor = "#8A8375",
        lineColor = "rgba(27,26,23,0.14)",
        arcColor = "#E5482B",
        handColor = "#1B1A17",
        faceColor = "#E8E0D0",
        fontFamily = "Inter",
        displayFamily = "",
        displayWeight = 400,
        style,
    } = props
    const isStatic = useIsStaticRenderer()
    const raw = [mon, tue, wed, thu, fri, sat, sun]
    const week = useMemo(() => [mon, tue, wed, thu, fri, sat, sun].map(parse), [mon, tue, wed, thu, fri, sat, sun])
    const [now, setNow] = useState<{ day: number; mins: number } | null>(null)

    // The clock is read after mount, so the server and the first paint show "Hours".
    useEffect(() => {
        if (isStatic) {
            setNow({ day: 4, mins: 20 * 60 + 10 })
            return
        }
        const tick = () => startTransition(() => setNow(zoned(timeZone)))
        tick()
        const id = window.setInterval(tick, 30000)
        return () => window.clearInterval(id)
    }, [timeZone, isStatic])

    const status = now ? statusOf(week, now.day, now.mins, openLabel, closedLabel) : null

    const C = 200
    const R = 150
    const at = (mins: number, r: number) => {
        const a = ((mins / 1440) * 360 - 90) * (Math.PI / 180)
        return [C + r * Math.cos(a), C + r * Math.sin(a)]
    }
    const arc = (a: number, b: number) => {
        const [x0, y0] = at(a, R)
        const [x1, y1] = at(Math.min(b, a + 1439), R)
        return `M ${x0.toFixed(2)} ${y0.toFixed(2)} A ${R} ${R} 0 ${b - a > 720 ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`
    }
    const day = now ? now.day : -1
    const todays = day >= 0 ? week[day] : []
    const family = `"${fontFamily}", "Inter", system-ui, sans-serif`
    const display = displayFamily ? `"${displayFamily}", ${family}` : family

    return (
        <div className="lyr-hd" style={{ ...style, position: "relative", width: "100%", fontFamily: family, color: textColor, "--lyr-hd-line": lineColor, "--lyr-hd-muted": mutedColor, "--lyr-hd-text": textColor } as CSSProperties}>
            <style dangerouslySetInnerHTML={{ __html: SHEET }} />
            <div className={showList ? "lyr-hd-grid" : "lyr-hd-grid lyr-hd-solo"}>
                <div className="lyr-hd-dial" style={{ position: "relative", width: "100%", aspectRatio: "1 / 1" }}>
                    <svg viewBox="0 0 400 400" width="100%" height="100%" role="img" aria-label={status ? status.line : "Opening hours"} style={{ display: "block", overflow: "visible" }}>
                        <circle cx={C} cy={C} r={R - 22} fill={faceColor} />
                        <circle cx={C} cy={C} r={R} fill="none" stroke={lineColor} strokeWidth={5} />
                        {todays.map((r, i) => (
                            <path key={i} d={arc(r[0], r[1])} fill="none" stroke={arcColor} strokeWidth={5} strokeLinecap="round" />
                        ))}
                        {Array.from({ length: 24 }, (_, h) => {
                            const [x0, y0] = at(h * 60, R + 14)
                            const [x1, y1] = at(h * 60, R + (h % 6 === 0 ? 24 : 19))
                            return <line key={h} x1={x0} y1={y0} x2={x1} y2={y1} stroke={h % 6 === 0 ? textColor : mutedColor} strokeOpacity={h % 6 === 0 ? 0.7 : 0.5} strokeWidth={1.2} />
                        })}
                        {[0, 6, 12, 18].map((h) => {
                            const [x, y] = at(h * 60, R + 40)
                            return (
                                <text key={h} x={x} y={y + 5} textAnchor="middle" fontSize={14} fontWeight={500} fill={mutedColor} fontFamily={family}>
                                    {String(h).padStart(2, "0")}
                                </text>
                            )
                        })}
                        {now ? (
                            <g style={{ transform: `rotate(${(now.mins / 1440) * 360}deg)`, transformOrigin: "200px 200px", transition: "transform 1s ease" }}>
                                <line x1={C} y1={C - R + 26} x2={C} y2={C - R - 12} stroke={handColor} strokeWidth={2.4} strokeLinecap="round" />
                                <circle cx={C} cy={C - R} r={6} fill={handColor} />
                            </g>
                        ) : null}
                    </svg>
                    <div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", textAlign: "center", pointerEvents: "none" }}>
                        <div>
                            <div style={{ display: "inline-flex", alignItems: "center", gap: 8, fontFamily: display, fontSize: "clamp(20px, 7cqw, 28px)", fontWeight: displayWeight, letterSpacing: "-0.025em", lineHeight: 1.1 }}>
                                <span style={{ width: 9, height: 9, borderRadius: "50%", background: status?.open ? "#3E9B52" : mutedColor, flex: "0 0 auto" }} />
                                {status ? (status.open ? openLabel : closedLabel) : "Hours"}
                            </div>
                            <div style={{ fontSize: 15, color: mutedColor, marginTop: 4, fontVariantNumeric: "tabular-nums" }}>{status ? status.detail : " "}</div>
                        </div>
                    </div>
                </div>
                {showList ? (
                    <ul className="lyr-hd-list">
                        {NAMES.map((name, i) => (
                            <li key={name} data-today={i === day ? "true" : "false"}>
                                <span>{name}</span>
                                <span>{week[i].length ? week[i].map((r) => `${clock(r[0])}–${clock(r[1])}`).join(", ") : raw[i] && !/\d/.test(raw[i]) ? raw[i] : closedLabel}</span>
                            </li>
                        ))}
                    </ul>
                ) : null}
            </div>
        </div>
    )
}

addPropertyControls(HoursDial, {
    timeZone: { type: ControlType.String, title: "Time zone", defaultValue: "Europe/Lisbon", description: "IANA name, for example Europe/Lisbon or America/New_York. Leave empty for the visitor's own clock." },
    mon: { type: ControlType.String, title: "Monday", defaultValue: "Closed", placeholder: "12:30-15:00, 18:30-23:00" },
    tue: { type: ControlType.String, title: "Tuesday", defaultValue: "18:30-23:00", placeholder: "12:30-15:00, 18:30-23:00" },
    wed: { type: ControlType.String, title: "Wednesday", defaultValue: "18:30-23:00", placeholder: "12:30-15:00, 18:30-23:00" },
    thu: { type: ControlType.String, title: "Thursday", defaultValue: "12:30-15:00, 18:30-23:00", placeholder: "12:30-15:00, 18:30-23:00" },
    fri: { type: ControlType.String, title: "Friday", defaultValue: "12:30-15:00, 18:30-00:00", placeholder: "12:30-15:00, 18:30-23:00" },
    sat: { type: ControlType.String, title: "Saturday", defaultValue: "12:30-15:00, 18:30-00:00", placeholder: "12:30-15:00, 18:30-23:00" },
    sun: { type: ControlType.String, title: "Sunday", defaultValue: "13:00-17:00", placeholder: "12:30-15:00, 18:30-23:00" },
    showList: { type: ControlType.Boolean, title: "Week list", defaultValue: true },
    openLabel: { type: ControlType.String, title: "Open label", defaultValue: "Open now" },
    closedLabel: { type: ControlType.String, title: "Closed label", defaultValue: "Closed" },
    textColor: { type: ControlType.Color, title: "Text", defaultValue: "#1B1A17" },
    mutedColor: { type: ControlType.Color, title: "Muted text", defaultValue: "#8A8375" },
    lineColor: { type: ControlType.Color, title: "Lines", defaultValue: "rgba(27,26,23,0.14)" },
    arcColor: { type: ControlType.Color, title: "Open hours", defaultValue: "#E5482B" },
    handColor: { type: ControlType.Color, title: "Hand", defaultValue: "#1B1A17" },
    faceColor: { type: ControlType.Color, title: "Face", defaultValue: "#E8E0D0" },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter" },
    displayFamily: { type: ControlType.String, title: "Display font", defaultValue: "", placeholder: "Same as font family", description: "Family for the large type. Leave empty to use the font family." },
    displayWeight: { type: ControlType.Number, title: "Display weight", defaultValue: 400, min: 300, max: 800, step: 100 },
})

In this preview

No pictures needed.