Components / Booking and forms
Table Times
A booking picker: a strip of the next days with closed days struck through, and grids of lunch and dinner times. One tap picks each.
Props
#0E0E0D
#6E6E6B
#E6E5E1
#FF4A1C
#FFFFFF
Pick a day and a time.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Table Times shows up in the Insert menu, and its controls appear in the right panel.
// Table Times · Layr Components
//
// The day and the time for a booking: a strip of the next days (closed days
// struck through) and grids of lunch and dinner times. One tap picks each.
// Dates are worked out in the venue's time zone, not the visitor's.
// Paste into a new code file in Framer and place it inside a form: it submits
// the date and the time as two hidden fields. Give it a width; its height follows.
import { addPropertyControls, ControlType } from "framer"
import { startTransition, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"
interface TableTimesProps {
daysAhead: number
closedDays: string
lunchTimes: string
dinnerTimes: string
timeZone: string
dayLabel: string
lunchLabel: string
dinnerLabel: string
dateField: string
timeField: string
textColor: string
mutedColor: string
lineColor: string
accentColor: string
accentTextColor: string
fontFamily: string
style?: CSSProperties
}
interface Day {
iso: string
top: string
num: string
month: string
long: string
closed: boolean
}
const SHEET = `
.lyr-tm button { font: inherit; color: inherit; }
.lyr-tm .lyr-tm-head { display: flex; justify-content: space-between; align-items: baseline; margin: 0 0 12px; font-size: 14px; color: var(--lyr-tm-muted); }
.lyr-tm .lyr-tm-head b { font-weight: 500; }
.lyr-tm .lyr-tm-days { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; }
.lyr-tm .lyr-tm-days::-webkit-scrollbar { display: none; }
.lyr-tm .lyr-tm-days.lyr-tm-more { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 44px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 44px), transparent); }
.lyr-tm .lyr-tm-day { flex: 1 0 52px; display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 12px 0 13px; border-radius: 14px; border: 1px solid var(--lyr-tm-line); background: transparent; cursor: pointer; line-height: 1; transition: background .2s ease, border-color .2s ease, color .2s ease; font-variant-numeric: tabular-nums; }
.lyr-tm .lyr-tm-day small { font-size: 13px; color: var(--lyr-tm-muted); transition: color .2s ease; }
.lyr-tm .lyr-tm-day span { font-size: 19px; }
.lyr-tm .lyr-tm-times { display: grid; grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); gap: 6px; }
.lyr-tm .lyr-tm-time { padding: 12px 0; border-radius: 12px; border: 1px solid var(--lyr-tm-line); background: transparent; cursor: pointer; font-size: 15px; line-height: 1.15; text-align: center; transition: background .2s ease, border-color .2s ease, color .2s ease; font-variant-numeric: tabular-nums; }
.lyr-tm .lyr-tm-day:hover:not(:disabled), .lyr-tm .lyr-tm-time:hover { border-color: var(--lyr-tm-text); }
.lyr-tm .lyr-tm-on, .lyr-tm .lyr-tm-on:hover:not(:disabled) { background: var(--lyr-tm-accent); border-color: var(--lyr-tm-accent); color: var(--lyr-tm-on-accent); }
.lyr-tm .lyr-tm-day.lyr-tm-on small { color: var(--lyr-tm-on-accent); opacity: .8; }
.lyr-tm .lyr-tm-day:disabled { opacity: .32; cursor: default; }
.lyr-tm .lyr-tm-day:disabled span { text-decoration: line-through; }
.lyr-tm button:focus-visible { outline: 2px solid var(--lyr-tm-accent); outline-offset: 2px; }
`
const list = (s: string) =>
String(s || "")
.split(",")
.map((x) => x.trim())
.filter(Boolean)
// The next days as the venue sees them, not as the visitor's clock does.
function upcoming(count: number, timeZone: string, closed: string[]): Day[] {
const out: Day[] = []
let fmt: Intl.DateTimeFormat
try {
fmt = new Intl.DateTimeFormat("en-GB", { timeZone, weekday: "short", day: "numeric", month: "short", year: "numeric" })
} catch {
fmt = new Intl.DateTimeFormat("en-GB", { weekday: "short", day: "numeric", month: "short", year: "numeric" })
}
const shut = closed.map((c) => c.slice(0, 3).toLowerCase())
const now = Date.now()
for (let i = 0; i < count; i++) {
const parts = fmt.formatToParts(new Date(now + i * 86400000))
const get = (t: string) => parts.find((p) => p.type === t)?.value || ""
const wd = get("weekday")
out.push({
iso: `${get("weekday")} ${get("day")} ${get("month")} ${get("year")}`,
top: i === 0 ? "Today" : wd,
num: get("day"),
month: get("month"),
long: `${wd} ${get("day")} ${get("month")}`,
closed: shut.includes(wd.toLowerCase()),
})
}
return out
}
/**
* @framerIntrinsicWidth 480
* @framerIntrinsicHeight 300
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight auto
*/
export default function TableTimes(props: TableTimesProps) {
const {
daysAhead = 7,
closedDays = "Mon",
lunchTimes = "12:30, 13:00, 13:30, 14:00",
dinnerTimes = "18:30, 19:00, 19:30, 20:00, 20:30, 21:00, 21:30",
timeZone = "Europe/Lisbon",
dayLabel = "Day",
lunchLabel = "Lunch",
dinnerLabel = "Dinner",
dateField = "Date",
timeField = "Time",
textColor = "#F2EDE3",
mutedColor = "#A8A193",
lineColor = "rgba(242,237,227,0.16)",
accentColor = "#E5482B",
accentTextColor = "#FFF6EE",
fontFamily = "Inter",
style,
} = props
const [days, setDays] = useState<Day[]>([])
const [day, setDay] = useState(0)
const [more, setMore] = useState(false)
const strip = useRef<HTMLDivElement>(null)
const lunch = useMemo(() => list(lunchTimes), [lunchTimes])
const dinner = useMemo(() => list(dinnerTimes), [dinnerTimes])
const [time, setTime] = useState(dinner[1] || dinner[0] || lunch[0] || "")
const shown = Math.max(1, Math.round(daysAhead))
// Dates depend on the clock, so they are filled in on the client only.
useEffect(() => {
const next = upcoming(shown, timeZone, list(closedDays))
startTransition(() => {
setDays(next)
setDay(Math.max(0, next.findIndex((d) => !d.closed)))
})
}, [shown, timeZone, closedDays])
// Fade the right edge of the day strip while there are more days to scroll to.
useEffect(() => {
const el = strip.current
if (!el) return
const check = () => {
const next = el.scrollWidth - el.clientWidth - el.scrollLeft > 4
startTransition(() => setMore(next))
}
check()
el.addEventListener("scroll", check, { passive: true })
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(check) : null
ro?.observe(el)
return () => {
el.removeEventListener("scroll", check)
ro?.disconnect()
}
}, [days.length])
const picked = days[day]
const months = days.length ? Array.from(new Set([days[0].month, days[days.length - 1].month])).join(" to ") : ""
const family = `"${fontFamily}", "Inter", system-ui, sans-serif`
const times = (label: string, values: string[]) =>
values.length ? (
<div>
<p className="lyr-tm-head">
<b>{label}</b>
</p>
<div className="lyr-tm-times" role="group" aria-label={label}>
{values.map((t) => (
<button key={t} type="button" className={`lyr-tm-time${t === time ? " lyr-tm-on" : ""}`} aria-pressed={t === time} onClick={() => setTime(t)}>
{t}
</button>
))}
</div>
</div>
) : null
return (
<div className="lyr-tm" style={{ ...style, position: "relative", width: "100%", fontFamily: family, color: textColor, display: "flex", flexDirection: "column", gap: 24, "--lyr-tm-line": lineColor, "--lyr-tm-accent": accentColor, "--lyr-tm-on-accent": accentTextColor, "--lyr-tm-muted": mutedColor, "--lyr-tm-text": textColor } as CSSProperties}>
<style dangerouslySetInnerHTML={{ __html: SHEET }} />
<div>
<p className="lyr-tm-head">
<b>{dayLabel}</b>
<span>{months}</span>
</p>
<div ref={strip} className={`lyr-tm-days${more ? " lyr-tm-more" : ""}`} role="group" aria-label={dayLabel}>
{days.length
? days.map((d, i) => (
<button key={d.iso} type="button" className={`lyr-tm-day${i === day ? " lyr-tm-on" : ""}`} disabled={d.closed} aria-pressed={i === day} aria-label={d.closed ? `${d.long}, closed` : d.long} onClick={() => setDay(i)}>
<small>{d.top}</small>
<span>{d.num}</span>
</button>
))
: Array.from({ length: Math.min(shown, 7) }, (_, i) => (
<button key={i} type="button" className="lyr-tm-day" disabled aria-hidden="true">
<small> </small>
<span style={{ textDecoration: "none" }}> </span>
</button>
))}
</div>
</div>
{times(lunchLabel, lunch)}
{times(dinnerLabel, dinner)}
<input type="hidden" name={dateField} value={picked ? picked.iso : ""} readOnly />
<input type="hidden" name={timeField} value={time} readOnly />
</div>
)
}
addPropertyControls(TableTimes, {
daysAhead: { type: ControlType.Number, title: "Days shown", defaultValue: 7, min: 1, max: 21, step: 1, displayStepper: true },
closedDays: { type: ControlType.String, title: "Closed days", defaultValue: "Mon", placeholder: "Mon, Tue", description: "Three-letter weekdays, comma separated." },
lunchTimes: { type: ControlType.String, title: "Lunch times", defaultValue: "12:30, 13:00, 13:30, 14:00" },
dinnerTimes: { type: ControlType.String, title: "Dinner times", defaultValue: "18:30, 19:00, 19:30, 20:00, 20:30, 21:00, 21:30" },
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." },
dayLabel: { type: ControlType.String, title: "Day label", defaultValue: "Day" },
lunchLabel: { type: ControlType.String, title: "Lunch label", defaultValue: "Lunch" },
dinnerLabel: { type: ControlType.String, title: "Dinner label", defaultValue: "Dinner" },
dateField: { type: ControlType.String, title: "Date field", defaultValue: "Date" },
timeField: { type: ControlType.String, title: "Time field", defaultValue: "Time" },
textColor: { type: ControlType.Color, title: "Text", defaultValue: "#F2EDE3" },
mutedColor: { type: ControlType.Color, title: "Muted text", defaultValue: "#A8A193" },
lineColor: { type: ControlType.Color, title: "Lines", defaultValue: "rgba(242,237,227,0.16)" },
accentColor: { type: ControlType.Color, title: "Accent", defaultValue: "#E5482B" },
accentTextColor: { type: ControlType.Color, title: "On accent", defaultValue: "#FFF6EE" },
fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter" },
})
In this preview
No pictures needed.