Layr Components

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
7
#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>&nbsp;</small>
                                  <span style={{ textDecoration: "none" }}>&nbsp;</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.