Layr Components

Components / Booking and forms

Stay Bar

One ruled row with arrival and departure dates, a guest stepper and a button that hands the stay to your booking link.

Props
14
3
2
4
#0E0E0D
#6E6E6B
#E6E5E1
#FF4A1C
#FFFFFF

Set in code: Booking link. The preview fills these in with demo content.

Pick dates, or change the number of guests.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Stay Bar shows up in the Insert menu, and its controls appear in the right panel.
// Stay Bar · Layr Components
//
// One ruled row with arrival and departure dates, a guest stepper and a button
// that hands the stay to a booking link. An outside booking engine receives the
// dates and guests in its address. The stay is also kept in the browser's
// session (key "lyr-stay-bar"), so two bars on one site keep the same answers.
// Paste into a new code file in Framer. Place it at full width in a stack: it sets
// its own height, and folds into two rows on narrow screens.

import { addPropertyControls, ControlType, Link, useIsStaticRenderer } from "framer"
import { useEffect, useRef, useState, type CSSProperties } from "react"

interface StayBarProps {
    bookingLink: string
    newTab: boolean
    buttonLabel: string
    arriveLabel: string
    leaveLabel: string
    guestsLabel: string
    guestWord: string
    guestsWord: string
    nightWord: string
    nightsWord: string
    daysAhead: number
    nights: number
    guests: number
    maxGuests: number
    monthFirst: boolean
    language: string
    arriveParam: string
    leaveParam: string
    guestsParam: string
    fontFamily: string
    ink: string
    muted: string
    hairline: string
    buttonFill: string
    buttonText: string
    style?: CSSProperties
}

// The session key, and the window event that tells other bars it changed.
const KEY = "lyr-stay-bar"
const pad = (n: number) => String(n).padStart(2, "0")
const iso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const parse = (s: string) => {
    const [y, m, d] = s.split("-").map(Number)
    return new Date(y, (m || 1) - 1, d || 1)
}
const addDays = (s: string, n: number) => {
    const d = parse(s)
    d.setDate(d.getDate() + n)
    return iso(d)
}
const between = (a: string, b: string) => Math.round((parse(b).getTime() - parse(a).getTime()) / 86400000)
const valid = (s: unknown): s is string => typeof s === "string" && /^\d{4}-\d{2}-\d{2}$/.test(s)

function readStay(): { arriving: string; leaving: string; guests: number } | null {
    try {
        const raw = window.sessionStorage.getItem(KEY)
        const v = raw ? JSON.parse(raw) : null
        if (v && valid(v.arriving) && valid(v.leaving)) return { arriving: v.arriving, leaving: v.leaving, guests: Number(v.guests) || 2 }
    } catch {}
    return null
}

/**
 * @framerIntrinsicWidth 1120
 * @framerIntrinsicHeight 100
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight auto
 */
export default function StayBar(props: StayBarProps) {
    const {
        bookingLink,
        newTab = true,
        buttonLabel = "Check availability",
        arriveLabel = "Arrive",
        leaveLabel = "Leave",
        guestsLabel = "Guests",
        guestWord = "guest",
        guestsWord = "guests",
        nightWord = "night",
        nightsWord = "nights",
        daysAhead = 14,
        nights = 3,
        guests = 2,
        maxGuests = 4,
        monthFirst = false,
        language = "en-GB",
        arriveParam = "arriving",
        leaveParam = "leaving",
        guestsParam = "guests",
        fontFamily = "Inter",
        ink = "#1C1B1A",
        muted = "#767169",
        hairline = "rgba(28,27,26,0.14)",
        buttonFill = "#1C1B1A",
        buttonText = "#F4F3EF",
    } = props
    const isStatic = useIsStaticRenderer()
    const cap = Math.max(1, Math.round(maxGuests))
    const firstGuests = Math.max(1, Math.min(cap, Math.round(guests || 2)))
    const fresh = () => {
        const a = addDays(iso(new Date()), Math.max(0, Math.round(daysAhead)))
        return { arriving: a, leaving: addDays(a, Math.max(1, Math.round(nights))), guests: firstGuests }
    }
    // Dates depend on today, so the published page fills them in after it loads.
    const [stay, setStay] = useState(() => (isStatic ? fresh() : { arriving: "", leaving: "", guests: firstGuests }))
    const [today, setToday] = useState("")
    const arriveRef = useRef<HTMLInputElement>(null)
    const leaveRef = useRef<HTMLInputElement>(null)

    useEffect(() => {
        if (isStatic || typeof window === "undefined") return
        const now = iso(new Date())
        setToday(now)
        const pick = () => {
            const kept = readStay()
            setStay(kept && kept.arriving >= now && kept.leaving > kept.arriving ? { ...kept, guests: Math.max(1, Math.min(cap, kept.guests)) } : fresh())
        }
        pick()
        // Two bars on one site keep the same answers.
        window.addEventListener(KEY, pick)
        return () => window.removeEventListener(KEY, pick)
    }, [isStatic, daysAhead, nights, firstGuests, cap])

    // The newest answers, so two changes in a row never start from an old copy.
    const latest = useRef(stay)
    latest.current = stay
    const keep = (change: (s: typeof stay) => typeof stay) => {
        const next = change(latest.current)
        latest.current = next
        setStay(next)
        try {
            window.sessionStorage.setItem(KEY, JSON.stringify(next))
            window.dispatchEvent(new Event(KEY))
        } catch {}
    }
    const setArrive = (v: string) => {
        if (!valid(v)) return
        keep((s) => {
            const span = s.leaving ? Math.max(1, between(s.arriving, s.leaving)) : Math.max(1, nights)
            return { ...s, arriving: v, leaving: v >= s.leaving ? addDays(v, span) : s.leaving }
        })
    }
    const setLeave = (v: string) => {
        if (!valid(v)) return
        keep((s) => ({ ...s, leaving: v <= s.arriving ? addDays(s.arriving, 1) : v }))
    }
    const open = (ref: { current: HTMLInputElement | null }) => {
        try {
            ref.current?.showPicker?.()
        } catch {}
    }

    const show = (s: string) => {
        if (!s) return " "
        try {
            const parts = new Intl.DateTimeFormat(language || undefined, { weekday: "short", day: "numeric", month: "short" }).formatToParts(parse(s))
            const get = (t: string) => parts.find((p) => p.type === t)?.value || ""
            return monthFirst ? `${get("weekday")} ${get("month")} ${get("day")}` : `${get("weekday")} ${get("day")} ${get("month")}`
        } catch {
            return s
        }
    }
    const count = stay.arriving && stay.leaving ? between(stay.arriving, stay.leaving) : 0
    const outside = /^(https?:)?\/\//i.test(String(bookingLink || ""))
    let href = String(bookingLink || "")
    if (outside && stay.arriving) {
        const q = [arriveParam && `${encodeURIComponent(arriveParam)}=${stay.arriving}`, leaveParam && `${encodeURIComponent(leaveParam)}=${stay.leaving}`, guestsParam && `${encodeURIComponent(guestsParam)}=${stay.guests}`].filter(Boolean).join("&")
        if (q) href += (href.includes("?") ? "&" : "?") + q
    }
    const send = () => {
        if (!latest.current.arriving) return
        try {
            window.sessionStorage.setItem(KEY, JSON.stringify(latest.current))
        } catch {}
    }

    const vars = {
        "--lyr-sb-ink": ink,
        "--lyr-sb-muted": muted,
        "--lyr-sb-line": hairline,
        "--lyr-sb-fill": buttonFill,
        "--lyr-sb-on": buttonText,
        fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif`,
    } as CSSProperties
    const arrow = (
        <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
            <path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
    )
    const button = (
        <a className="lyr-sb-btn" href={outside ? href : undefined} target={outside && newTab ? "_blank" : undefined} rel={outside && newTab ? "noopener" : undefined} onClick={send} onPointerDown={send} onKeyDown={(e) => e.key === "Enter" && send()}>
            <span>{buttonLabel}</span>
            {arrow}
        </a>
    )

    return (
        <div className="lyr-sb" style={{ ...props.style, ...vars }} role="group" aria-label={buttonLabel}>
            <style>{CSS}</style>
            <div className={"lyr-sb-row" + (stay.arriving ? " lyr-sb-on" : "")}>
                <label className="lyr-sb-f lyr-sb-date" onClick={(e) => e.target !== arriveRef.current && open(arriveRef)}>
                    <span className="lyr-sb-l">{arriveLabel}</span>
                    <span className="lyr-sb-v">{show(stay.arriving)}</span>
                    <input ref={arriveRef} className="lyr-sb-in" type="date" aria-label={arriveLabel} value={stay.arriving} min={today || undefined} onChange={(e) => setArrive(e.target.value)} />
                </label>
                <label className="lyr-sb-f lyr-sb-date" onClick={(e) => e.target !== leaveRef.current && open(leaveRef)}>
                    <span className="lyr-sb-l">{leaveLabel}</span>
                    <span className="lyr-sb-v">
                        {show(stay.leaving)}
                        {count > 0 && (
                            <span className="lyr-sb-n">
                                {count} {count === 1 ? nightWord : nightsWord}
                            </span>
                        )}
                    </span>
                    <input ref={leaveRef} className="lyr-sb-in" type="date" aria-label={leaveLabel} value={stay.leaving} min={stay.arriving ? addDays(stay.arriving, 1) : undefined} onChange={(e) => setLeave(e.target.value)} />
                </label>
                <div className="lyr-sb-f lyr-sb-guests">
                    <span className="lyr-sb-l">{guestsLabel}</span>
                    <span className="lyr-sb-g">
                        <span className="lyr-sb-v" aria-live="polite">
                            {stay.guests} {stay.guests === 1 ? guestWord : guestsWord}
                        </span>
                        <span className="lyr-sb-steps">
                            <button type="button" aria-label={`One ${guestWord} fewer`} disabled={stay.guests <= 1} onClick={() => keep((s) => ({ ...s, guests: Math.max(1, s.guests - 1) }))}>
                                <svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
                                    <path d="M2 6h8" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
                                </svg>
                            </button>
                            <button type="button" aria-label={`One ${guestWord} more`} disabled={stay.guests >= cap} onClick={() => keep((s) => ({ ...s, guests: Math.min(cap, s.guests + 1) }))}>
                                <svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
                                    <path d="M2 6h8M6 2v8" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
                                </svg>
                            </button>
                        </span>
                    </span>
                </div>
                <div className="lyr-sb-go">{outside || !bookingLink ? button : <Link href={bookingLink}>{button}</Link>}</div>
            </div>
        </div>
    )
}

const CSS = `
.lyr-sb{position:relative;width:100%;container-type:inline-size;color:var(--lyr-sb-ink);-webkit-font-smoothing:antialiased}
.lyr-sb *{box-sizing:border-box}
.lyr-sb-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.25fr) minmax(0,1fr) auto;align-items:stretch;border-top:1px solid var(--lyr-sb-line);border-bottom:1px solid var(--lyr-sb-line)}
.lyr-sb-f{position:relative;display:flex;flex-direction:column;justify-content:center;gap:8px;min-width:0;padding:22px 24px 24px 24px;border-left:1px solid var(--lyr-sb-line)}
.lyr-sb-f:first-child{padding-left:0;border-left:0}
.lyr-sb-date{cursor:pointer}
.lyr-sb-l{font-size:14px;line-height:1.3;letter-spacing:-0.005em;color:var(--lyr-sb-muted)}
.lyr-sb-v{display:flex;align-items:baseline;gap:12px;font-size:22px;line-height:1.2;letter-spacing:-0.03em;font-weight:400;white-space:nowrap;opacity:0;transition:opacity .3s ease}
.lyr-sb-on .lyr-sb-v{opacity:1}
@media (hover: hover){.lyr-sb-date:hover .lyr-sb-v{opacity:.68}.lyr-sb-steps button:hover{border-color:var(--lyr-sb-ink)}.lyr-sb-btn:hover{opacity:.82}}
.lyr-sb-n{font-size:14px;letter-spacing:-0.005em;color:var(--lyr-sb-muted)}
.lyr-sb-in{position:absolute;inset:0;width:100%;height:100%;margin:0;padding:0;border:0;opacity:0;cursor:pointer;font-size:16px;-webkit-appearance:none;appearance:none;background:transparent}
.lyr-sb-in::-webkit-calendar-picker-indicator{position:absolute;inset:0;width:100%;height:100%;margin:0;padding:0;opacity:0;cursor:pointer}
.lyr-sb-date:focus-within .lyr-sb-v{text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:6px}
.lyr-sb-g{display:flex;align-items:center;justify-content:space-between;gap:16px}
.lyr-sb-steps{display:flex;gap:8px;flex:none;margin:-3px 0}
.lyr-sb-steps button{display:grid;place-items:center;width:32px;height:32px;padding:0;border:1px solid var(--lyr-sb-line);border-radius:50%;background:transparent;color:inherit;cursor:pointer;transition:border-color .25s ease,opacity .25s ease}
.lyr-sb-steps button:disabled{opacity:.32;cursor:default;border-color:var(--lyr-sb-line)}
.lyr-sb-steps button:focus-visible,.lyr-sb-btn:focus-visible{outline:1px solid var(--lyr-sb-ink);outline-offset:3px}
.lyr-sb-go{display:flex;align-items:center;padding:0 0 0 24px;border-left:1px solid var(--lyr-sb-line)}
.lyr-sb-btn{display:inline-flex;align-items:center;justify-content:center;gap:10px;height:48px;padding:0 22px;border-radius:100px;background:var(--lyr-sb-fill);color:var(--lyr-sb-on);font-size:15px;line-height:1.2;font-weight:500;letter-spacing:-0.01em;text-decoration:none;white-space:nowrap;cursor:pointer;transition:opacity .25s ease}
@container (max-width: 859px){
.lyr-sb-row{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}
.lyr-sb-f{justify-content:flex-start;padding:18px 0 20px 20px}
.lyr-sb-f:nth-child(odd){padding-left:0;border-left:0}
.lyr-sb-guests{grid-column:1 / -1;border-top:1px solid var(--lyr-sb-line)}
.lyr-sb-go{grid-column:1 / -1;padding:0 0 20px 0;border-left:0}
.lyr-sb-btn{width:100%}
.lyr-sb-v{font-size:20px;gap:10px}
}
@container (max-width: 419px){
.lyr-sb-v{font-size:18px;flex-direction:column;align-items:flex-start;gap:4px}
.lyr-sb-guests .lyr-sb-v{flex-direction:row}
}
@media (prefers-reduced-motion: reduce){.lyr-sb-v,.lyr-sb-btn,.lyr-sb-steps button{transition:none}}
`

addPropertyControls(StayBar, {
    bookingLink: { type: ControlType.Link, title: "Booking link", description: "Your booking engine, or a page of this site. An outside link receives the dates and guests in its address." },
    newTab: { type: ControlType.Boolean, title: "New tab", defaultValue: true, description: "Open an outside booking link in a new tab." },
    buttonLabel: { type: ControlType.String, title: "Button", defaultValue: "Check availability" },
    arriveLabel: { type: ControlType.String, title: "Arrive label", defaultValue: "Arrive" },
    leaveLabel: { type: ControlType.String, title: "Leave label", defaultValue: "Leave" },
    guestsLabel: { type: ControlType.String, title: "Guests label", defaultValue: "Guests" },
    guestWord: { type: ControlType.String, title: "One guest", defaultValue: "guest" },
    guestsWord: { type: ControlType.String, title: "Several guests", defaultValue: "guests" },
    nightWord: { type: ControlType.String, title: "One night", defaultValue: "night" },
    nightsWord: { type: ControlType.String, title: "Several nights", defaultValue: "nights" },
    daysAhead: { type: ControlType.Number, title: "Days ahead", defaultValue: 14, min: 0, max: 180, step: 1, displayStepper: true, description: "How far from today the first arrival date sits." },
    nights: { type: ControlType.Number, title: "Nights", defaultValue: 3, min: 1, max: 30, step: 1, displayStepper: true },
    guests: { type: ControlType.Number, title: "Guests", defaultValue: 2, min: 1, max: 12, step: 1, displayStepper: true },
    maxGuests: { type: ControlType.Number, title: "Most guests", defaultValue: 4, min: 1, max: 12, step: 1, displayStepper: true },
    monthFirst: { type: ControlType.Boolean, title: "Month first", defaultValue: false, description: "Off reads Fri 16 Oct. On reads Fri Oct 16." },
    language: { type: ControlType.String, title: "Date language", defaultValue: "en-GB", description: "A language code such as en-GB, fr or de." },
    arriveParam: { type: ControlType.String, title: "Arrive name", defaultValue: "arriving", description: "The name your booking engine uses for the arrival date." },
    leaveParam: { type: ControlType.String, title: "Leave name", defaultValue: "leaving" },
    guestsParam: { type: ControlType.String, title: "Guests name", defaultValue: "guests" },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "Use the family name of your text styles." },
    ink: { type: ControlType.Color, title: "Text", defaultValue: "#1C1B1A" },
    muted: { type: ControlType.Color, title: "Labels", defaultValue: "#767169" },
    hairline: { type: ControlType.Color, title: "Lines", defaultValue: "rgba(28,27,26,0.14)" },
    buttonFill: { type: ControlType.Color, title: "Button colour", defaultValue: "#1C1B1A" },
    buttonText: { type: ControlType.Color, title: "Button text", defaultValue: "#F4F3EF" },
})

In this preview

No pictures needed.