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
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.