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