Skip to content

Booking Nav

Hotel booking navbar over a full-bleed photo: dismissible announcement, hide on scroll down and a Dates, Guests, Reserve pill.

  • Navbars
  • Travel & hospitality
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. The pill uses backdrop-filter where supported and a solid tint elsewhere.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/booking-nav.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/navbars/booking-nav.tsx
1/**
2 * BookingNav, a boutique hotel header. A dismissible announcement bar sits
3 * above a transparent nav laid over a full-bleed photograph. Once the page
4 * scrolls the nav turns solid, hides while you scroll down and comes back
5 * the moment you scroll up. On the right a compact "Dates · Guests ·
6 * Reserve" pill opens a range calendar (with nightly rates and sold-out
7 * nights) and a guest stepper.
8 *
9 * Props: hotel, place, announcement, links, month (year, monthIndex),
10 * rates (day → price), soldOut (days), defaultRange, currency, onReserve.
11 * CSS vars: --fd-bkn-cream, --fd-bkn-sea, --fd-bkn-terra.
12 *
13 * Behaviour: the header is `position: fixed`; put it before a full-bleed
14 * hero. It reads window scroll.
15 * Browser support: all evergreen browsers (backdrop-filter on the pill
16 * degrades to a solid tint).
17 * Accessibility: popover triggers have aria-expanded/aria-controls; the
18 * calendar is a grid of buttons with arrow-key movement, aria-pressed on
19 * chosen nights and labels such as "Saturday 12 December, €340"; Escape
20 * closes and returns focus. The mobile menu is a modal dialog with a focus
21 * trap. The announcement close button moves focus to the hotel name.
22 * Reduced motion: no slide on hide/show, popovers appear instantly.
23 */
24import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
25import type { KeyboardEvent as ReactKeyboardEvent } from "react";
26
27export interface BookingNavProps {
28 hotel?: string;
29 place?: string;
30 announcement?: string;
31 links?: { label: string; href: string }[];
32 month?: { year: number; index: number };
33 rates?: (day: number) => number;
34 soldOut?: number[];
35 defaultRange?: [number, number];
36 currency?: string;
37 onReserve?: (r: { from: number; to: number; adults: number; children: number }) => void;
38 className?: string;
39}
40
41const LINKS = [
42 { label: "Rooms", href: "#rooms" },
43 { label: "Terrace", href: "#terrace" },
44 { label: "Kitchen", href: "#kitchen" },
45 { label: "Journal", href: "#journal" },
46];
47const SOLD = [24, 25, 31];
48const MONTH = { year: 2026, index: 11 };
49const RANGE: [number, number] = [12, 15];
50const rate = (d: number) => (d >= 18 ? 420 : [5, 6, 12, 13].includes(d) ? 380 : 340);
51const WD = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
52
53export function BookingNav({
54 hotel = "Sirena",
55 place = "Positano · since 1952",
56 announcement = "Open all winter: from €340 a night until 15 March, breakfast on the terrace included.",
57 links = LINKS,
58 month = MONTH,
59 rates = rate,
60 soldOut = SOLD,
61 defaultRange = RANGE,
62 currency = "€",
63 onReserve,
64 className,
65}: BookingNavProps) {
66 const uid = useId();
67 const root = useRef<HTMLElement>(null);
68 const ann = useRef<HTMLDivElement>(null);
69 const brand = useRef<HTMLAnchorElement>(null);
70 const datesBtn = useRef<HTMLButtonElement>(null);
71 const guestsBtn = useRef<HTMLButtonElement>(null);
72 const burger = useRef<HTMLButtonElement>(null);
73 const sheet = useRef<HTMLDivElement>(null);
74 const pop = useRef<HTMLDivElement>(null);
75 const [annOpen, setAnnOpen] = useState(true);
76 const [annH, setAnnH] = useState(0);
77 const [solid, setSolid] = useState(false);
78 const [hidden, setHidden] = useState(false);
79 const [panel, setPanel] = useState<null | "dates" | "guests">(null);
80 const [menu, setMenu] = useState(false);
81 const [range, setRange] = useState<[number, number | null]>(defaultRange);
82 const [adults, setAdults] = useState(2);
83 const [kids, setKids] = useState(0);
84 const [focusDay, setFocusDay] = useState(defaultRange[0]);
85 const [sent, setSent] = useState(false);
86
87 useLayoutEffect(() => {
88 const el = ann.current;
89 if (!el) return;
90 const set = () => setAnnH(annOpen ? el.offsetHeight : 0);
91 set();
92 const ro = new ResizeObserver(set);
93 ro.observe(el);
94 return () => ro.disconnect();
95 }, [annOpen]);
96
97 // Solid after the hero top, hide on scroll down, show on scroll up.
98 useEffect(() => {
99 let last = window.scrollY, raf = 0;
100 const on = () => {
101 if (raf) return;
102 raf = requestAnimationFrame(() => {
103 raf = 0;
104 const y = window.scrollY;
105 setSolid(y > 24);
106 if (y < 120) setHidden(false);
107 else if (y > last + 6) setHidden(true);
108 else if (y < last - 6) setHidden(false);
109 last = y;
110 });
111 };
112 on();
113 window.addEventListener("scroll", on, { passive: true });
114 return () => {
115 window.removeEventListener("scroll", on);
116 cancelAnimationFrame(raf);
117 };
118 }, []);
119
120 // Popover: Escape, outside click, focus.
121 useEffect(() => {
122 if (!panel) return;
123 const opener = panel === "dates" ? datesBtn.current : guestsBtn.current;
124 window.setTimeout(() => {
125 const p = pop.current;
126 (p?.querySelector<HTMLElement>(`[data-day="${focusDay}"]`) ?? p?.querySelector<HTMLElement>("button"))?.focus();
127 }, 30);
128 const onKey = (e: KeyboardEvent) => {
129 if (e.key === "Escape") {
130 setPanel(null);
131 opener?.focus();
132 }
133 };
134 const onDown = (e: PointerEvent) => {
135 const t = e.target as Node;
136 if (!pop.current?.contains(t) && !datesBtn.current?.contains(t) && !guestsBtn.current?.contains(t)) setPanel(null);
137 };
138 document.addEventListener("keydown", onKey);
139 document.addEventListener("pointerdown", onDown);
140 return () => {
141 document.removeEventListener("keydown", onKey);
142 document.removeEventListener("pointerdown", onDown);
143 };
144 }, [panel]); // eslint-disable-line react-hooks/exhaustive-deps
145
146 // Mobile menu.
147 useEffect(() => {
148 if (!menu) return;
149 const s = sheet.current;
150 window.setTimeout(() => s?.querySelector<HTMLElement>("a,button")?.focus(), 40);
151 const onKey = (e: KeyboardEvent) => {
152 if (e.key === "Escape") {
153 setMenu(false);
154 burger.current?.focus();
155 }
156 if (e.key === "Tab" && s) {
157 const f = Array.from(s.querySelectorAll<HTMLElement>("a,button")).filter((x) => x.offsetParent !== null);
158 const i = f.indexOf(document.activeElement as HTMLElement);
159 if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus(); }
160 else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus(); }
161 }
162 };
163 const mq = matchMedia("(min-width: 901px)");
164 const onMq = () => mq.matches && setMenu(false);
165 document.addEventListener("keydown", onKey);
166 mq.addEventListener("change", onMq);
167 return () => {
168 document.removeEventListener("keydown", onKey);
169 mq.removeEventListener("change", onMq);
170 };
171 }, [menu]);
172
173 // Calendar maths (UTC, no "now", so server and client agree).
174 const first = new Date(Date.UTC(month.year, month.index, 1));
175 const days = new Date(Date.UTC(month.year, month.index + 1, 0)).getUTCDate();
176 const lead = (first.getUTCDay() + 6) % 7;
177 const monthName = first.toLocaleString("en-GB", { month: "long", timeZone: "UTC" });
178 const monShort = first.toLocaleString("en-GB", { month: "short", timeZone: "UTC" });
179 const dayName = (d: number) => new Date(Date.UTC(month.year, month.index, d)).toLocaleString("en-GB", { weekday: "long", timeZone: "UTC" });
180 const [from, to] = range;
181 const nights = to ? to - from : 0;
182 const blocked = (a: number, b: number) => soldOut.some((s) => s >= a && s < b);
183 const total = to ? Array.from({ length: nights }, (_, i) => rates(from + i)).reduce((x, y) => x + y, 0) : 0;
184
185 const pickDay = (d: number) => {
186 if (soldOut.includes(d) && (to !== null || d <= from)) return;
187 if (to === null && d > from && !blocked(from, d)) setRange([from, d]);
188 else setRange([d, null]);
189 setFocusDay(d);
190 };
191 const gridKey = (e: ReactKeyboardEvent) => {
192 const m: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
193 if (!(e.key in m)) return;
194 e.preventDefault();
195 const n = Math.min(days, Math.max(1, focusDay + m[e.key]));
196 setFocusDay(n);
197 (e.currentTarget as HTMLElement).querySelector<HTMLElement>(`[data-day="${n}"]`)?.focus();
198 };
199
200 const dateLabel = to ? `${from}–${to} ${monShort}` : `${from} ${monShort} →`;
201 const guestLabel = `${adults + kids} ${adults + kids === 1 ? "guest" : "guests"}`;
202 const reserve = () => {
203 if (!to) {
204 setPanel("dates");
205 return;
206 }
207 onReserve?.({ from, to, adults, children: kids });
208 setSent(true);
209 setPanel(null);
210 window.setTimeout(() => setSent(false), 2600);
211 };
212
213 const calendar = (
214 <div className="fd-bkn-cal">
215 <div className="fd-bkn-calhead">
216 <b>{monthName} {month.year}</b>
217 <span>{to ? `${nights} ${nights === 1 ? "night" : "nights"} · ${currency}${total.toLocaleString("en-GB")}` : "Choose your last night"}</span>
218 </div>
219 <div className="fd-bkn-grid" role="grid" aria-label={`${monthName} ${month.year}`} onKeyDown={gridKey}>
220 <div role="row" className="fd-bkn-wk">{WD.map((w) => <span key={w} role="columnheader">{w}</span>)}</div>
221 {Array.from({ length: Math.ceil((lead + days) / 7) }, (_, r) => (
222 <div role="row" key={r} className="fd-bkn-wk">
223 {Array.from({ length: 7 }, (_, c) => {
224 const d = r * 7 + c - lead + 1;
225 if (d < 1 || d > days) return <span key={c} role="gridcell" />;
226 const sold = soldOut.includes(d);
227 const isEnd = d === from || d === to;
228 const inRange = to !== null && d > from && d < to;
229 return (
230 <span key={c} role="gridcell">
231 <button
232 type="button"
233 data-day={d}
234 tabIndex={d === focusDay ? 0 : -1}
235 className="fd-bkn-day"
236 data-end={isEnd ? "" : undefined}
237 data-in={inRange ? "" : undefined}
238 data-start={d === from ? "" : undefined}
239 data-stop={d === to ? "" : undefined}
240 aria-pressed={isEnd || inRange}
241 aria-disabled={sold || undefined}
242 aria-label={`${dayName(d)} ${d} ${monthName}, ${sold ? "sold out" : `${currency}${rates(d)}`}`}
243 onClick={() => pickDay(d)}
244 >
245 <b>{d}</b>
246 <small>{sold ? "full" : rates(d)}</small>
247 </button>
248 </span>
249 );
250 })}
251 </div>
252 ))}
253 </div>
254 <p className="fd-bkn-calnote">Prices per night for two, breakfast included. Check-in from 15:00.</p>
255 </div>
256 );
257 const stepper = (label: string, sub: string, v: number, set: (n: number) => void, min: number, max: number) => (
258 <div className="fd-bkn-step">
259 <span><b>{label}</b><small>{sub}</small></span>
260 <button type="button" aria-label={`Fewer ${label.toLowerCase()}`} disabled={v <= min} onClick={() => set(v - 1)}>−</button>
261 <output aria-live="polite" aria-label={label}>{v}</output>
262 <button type="button" aria-label={`More ${label.toLowerCase()}`} disabled={v >= max} onClick={() => set(v + 1)}>+</button>
263 </div>
264 );
265 const guests = (
266 <div className="fd-bkn-guests">
267 {stepper("Adults", "13 and over", adults, setAdults, 1, 4)}
268 {stepper("Children", "Cots free, under 3", kids, setKids, 0, 3)}
269 <p className="fd-bkn-calnote">Our largest room, the Torre suite, sleeps four.</p>
270 </div>
271 );
272
273 // No transform while the menu is open: a transformed ancestor would trap the fixed sheet.
274 const shift = menu ? null : hidden && !panel ? "-100%" : solid ? `-${annH}px` : null;
275
276 return (
277 <header
278 ref={root}
279 className={`fd-bkn${className ? " " + className : ""}`}
280 data-solid={solid || menu ? "" : undefined}
281 style={shift ? { transform: `translateY(${shift})` } : undefined}
282 >
283 <style>{css}</style>
284 <div className="fd-bkn-annwrap" data-open={annOpen ? "" : undefined}>
285 <div ref={ann} className="fd-bkn-ann" inert={!annOpen ? true : undefined}>
286 <p>{announcement} <a href="#winter">See winter dates</a></p>
287 <button type="button" className="fd-bkn-annx" aria-label="Dismiss announcement" onClick={() => { setAnnOpen(false); brand.current?.focus(); }}>
288 <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 3l8 8M11 3l-8 8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /></svg>
289 </button>
290 </div>
291 </div>
292 <div className="fd-bkn-bar">
293 <nav className="fd-bkn-links" aria-label="Hotel">
294 {links.map((l) => <a key={l.href} href={l.href}>{l.label}</a>)}
295 </nav>
296 <a ref={brand} href="#top" className="fd-bkn-brand" aria-label={`${hotel}, ${place}`}>
297 <span className="fd-bkn-word">{hotel}</span>
298 <span className="fd-bkn-place">{place}</span>
299 </a>
300 <div className="fd-bkn-right">
301 <div className="fd-bkn-pill">
302 <button ref={datesBtn} type="button" className="fd-bkn-seg" aria-expanded={panel === "dates"} aria-controls={`${uid}-pop`} onClick={() => setPanel((p) => (p === "dates" ? null : "dates"))}>
303 <small>Dates</small><b>{dateLabel}</b>
304 </button>
305 <span className="fd-bkn-dot" aria-hidden="true" />
306 <button ref={guestsBtn} type="button" className="fd-bkn-seg" aria-expanded={panel === "guests"} aria-controls={`${uid}-pop`} onClick={() => setPanel((p) => (p === "guests" ? null : "guests"))}>
307 <small>Guests</small><b>{guestLabel}</b>
308 </button>
309 <button type="button" className="fd-bkn-reserve" onClick={reserve}>
310 <span>{sent ? "Request sent" : "Reserve"}</span>
311 </button>
312 </div>
313 <button type="button" className="fd-bkn-mreserve" onClick={() => { setMenu(true); }}>Reserve</button>
314 <button ref={burger} type="button" className="fd-bkn-burger" aria-label="Menu" aria-expanded={menu} aria-controls={`${uid}-sheet`} onClick={() => setMenu((m) => !m)}>
315 <span /><span />
316 </button>
317 {panel && (
318 <div ref={pop} id={`${uid}-pop`} className="fd-bkn-pop" role="dialog" aria-label={panel === "dates" ? "Choose dates" : "Guests"} data-kind={panel}>
319 {panel === "dates" ? calendar : guests}
320 <div className="fd-bkn-popfoot">
321 <button type="button" className="fd-bkn-text" onClick={() => (panel === "dates" ? setRange([from, null]) : (setAdults(2), setKids(0)))}>Clear</button>
322 <button type="button" className="fd-bkn-reserve fd-bkn-reserve-lg" onClick={() => (panel === "dates" && to ? setPanel("guests") : reserve())}>
323 {panel === "dates" ? (to ? "Next: guests" : "Pick a last night") : `Reserve · ${currency}${total.toLocaleString("en-GB")}`}
324 </button>
325 </div>
326 </div>
327 )}
328 </div>
329 </div>
330 {sent && <p className="fd-bkn-toast" role="status">Request sent for {dateLabel}, {guestLabel}. Chiara will reply within the hour.</p>}
331
332 <div ref={sheet} id={`${uid}-sheet`} className="fd-bkn-sheet" role="dialog" aria-modal="true" aria-label="Menu" hidden={!menu} style={{ top: annH + 64 }}>
333 <nav aria-label="Hotel">
334 {links.map((l, i) => <a key={l.href} href={l.href} style={{ animationDelay: `${i * 50}ms` }} onClick={() => setMenu(false)}>{l.label}</a>)}
335 </nav>
336 <div className="fd-bkn-mbook">
337 <p className="fd-bkn-mh">Stay with us</p>
338 {calendar}
339 {guests}
340 <button type="button" className="fd-bkn-reserve fd-bkn-reserve-lg fd-bkn-mfull" disabled={!to} onClick={() => { if (to) { reserve(); setMenu(false); } }}>
341 {to ? `Reserve ${dateLabel} · ${currency}${total.toLocaleString("en-GB")}` : "Pick a last night"}
342 </button>
343 </div>
344 </div>
345 </header>
346 );
347}
348
349const serif = `"Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif`;
350const sans = `ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif`;
351
352const css = `
353.fd-bkn {
354 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
355 --fd-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
356 --fd-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
357 --fd-dur-1: 120ms; --fd-dur-2: 240ms; --fd-dur-3: 420ms; --fd-dur-4: 700ms;
358 --fd-bkn-cream: #f6efe3;
359 --fd-bkn-sea: #0f2a3a;
360 --fd-bkn-terra: #b4532f;
361 --fd-bkn-line: #e2d6c3;
362 --fd-bkn-fg: #fff;
363 position: fixed; left: 0; right: 0; top: 0; z-index: 60; display: block; margin: 0; padding: 0;
364 font: 400 14px/1.4 ${sans};
365 color: var(--fd-bkn-fg); background: transparent;
366 transition: transform var(--fd-dur-3) var(--fd-ease-out);
367}
368.fd-bkn[data-solid] { --fd-bkn-fg: var(--fd-bkn-sea); }
369.fd-bkn, .fd-bkn *, .fd-bkn *::before, .fd-bkn *::after { box-sizing: border-box; }
370.fd-bkn a:focus-visible, .fd-bkn button:focus-visible { outline: 2px solid color-mix(in srgb, var(--fd-bkn-terra) 75%, transparent); outline-offset: 2px; }
371
372.fd-bkn-annwrap { display: grid; grid-template-rows: 0fr; margin: 0; padding: 0; transition: grid-template-rows var(--fd-dur-3) var(--fd-ease-in-out); }
373.fd-bkn-annwrap[data-open] { grid-template-rows: 1fr; }
374.fd-bkn-ann { min-height: 0; overflow: hidden; display: flex; align-items: center; justify-content: center; gap: 12px; margin: 0; padding: 0 48px; background: var(--fd-bkn-sea); color: #f6efe3; position: relative; }
375.fd-bkn-ann p { margin: 0; padding: 10px 0; font: 400 0.8125rem/1.35 ${sans}; color: #f6efe3; text-align: center; }
376.fd-bkn-ann a { margin: 0 0 0 6px; font: italic 400 0.875rem/1 ${serif}; color: #f2c9a8; text-decoration: underline; text-underline-offset: 3px; }
377.fd-bkn-annx { position: absolute; right: 10px; top: 50%; display: grid; place-items: center; width: 30px; height: 30px; margin: -15px 0 0; padding: 0; border: 0; border-radius: 9999px; background: none; color: #f6efe3; cursor: pointer; appearance: none; -webkit-appearance: none; }
378.fd-bkn-annx:hover { background: rgba(246,239,227,0.12); }
379.fd-bkn-annx svg { display: block; }
380
381.fd-bkn-bar { position: relative; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; height: 84px; margin: 0; padding: 0 28px; color: var(--fd-bkn-fg); transition: background var(--fd-dur-3) linear, color var(--fd-dur-3) linear, box-shadow var(--fd-dur-3) linear; }
382.fd-bkn:not([data-solid]) .fd-bkn-bar { background: linear-gradient(rgba(10,20,28,0.42), rgba(10,20,28,0)); text-shadow: 0 1px 12px rgba(0,0,0,0.25); }
383.fd-bkn[data-solid] .fd-bkn-bar { background: var(--fd-bkn-cream); box-shadow: 0 1px 0 var(--fd-bkn-line); height: 72px; }
384.fd-bkn-links { display: flex; gap: 26px; margin: 0; padding: 0; }
385.fd-bkn-links a { position: relative; font: 500 0.8125rem/1 ${sans}; letter-spacing: 0.06em; text-transform: uppercase; color: inherit; text-decoration: none; padding: 6px 0; }
386.fd-bkn-links a::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: currentColor; transform: scaleX(0); transform-origin: right; transition: transform var(--fd-dur-2) var(--fd-ease-out); }
387.fd-bkn-links a:hover::after { transform: scaleX(1); transform-origin: left; }
388.fd-bkn-brand { display: flex; flex-direction: column; align-items: center; gap: 2px; margin: 0; padding: 2px 8px; color: inherit; text-decoration: none; border-radius: 4px; }
389.fd-bkn-word { font: italic 400 2.25rem/0.95 ${serif}; letter-spacing: -0.02em; color: inherit; transition: font-size var(--fd-dur-3) var(--fd-ease-out); }
390.fd-bkn[data-solid] .fd-bkn-word { font-size: 1.875rem; }
391.fd-bkn-place { font: 500 0.625rem/1 ${sans}; letter-spacing: 0.22em; text-transform: uppercase; opacity: 0.85; }
392.fd-bkn-right { position: relative; display: flex; justify-content: flex-end; align-items: center; gap: 10px; }
393
394.fd-bkn-pill { display: flex; align-items: center; margin: 0; padding: 4px 4px 4px 6px; border: 1px solid rgba(255,255,255,0.35); border-radius: 9999px; background: rgba(15,42,58,0.22); -webkit-backdrop-filter: blur(14px) saturate(1.2); backdrop-filter: blur(14px) saturate(1.2); text-shadow: none; transition: background var(--fd-dur-3) linear, border-color var(--fd-dur-3) linear; }
395.fd-bkn[data-solid] .fd-bkn-pill { background: #fff; border-color: var(--fd-bkn-line); -webkit-backdrop-filter: none; backdrop-filter: none; }
396.fd-bkn-seg { display: flex; flex-direction: column; align-items: flex-start; gap: 3px; margin: 0; padding: 6px 12px; border: 0; border-radius: 9999px; background: none; color: inherit; cursor: pointer; appearance: none; -webkit-appearance: none; text-align: left; transition: background var(--fd-dur-1) linear; }
397.fd-bkn-seg:hover, .fd-bkn-seg[aria-expanded="true"] { background: rgba(255,255,255,0.16); }
398.fd-bkn[data-solid] .fd-bkn-seg:hover, .fd-bkn[data-solid] .fd-bkn-seg[aria-expanded="true"] { background: #f3ebde; }
399.fd-bkn-seg small { font: 500 0.625rem/1 ${sans}; letter-spacing: 0.14em; text-transform: uppercase; color: inherit; opacity: 0.75; }
400.fd-bkn-seg b { font: 400 0.9375rem/1 ${serif}; color: inherit; white-space: nowrap; font-variant-numeric: tabular-nums; }
401.fd-bkn-dot { display: block; width: 3px; height: 3px; border-radius: 9999px; background: currentColor; opacity: 0.5; }
402.fd-bkn-reserve { display: inline-flex; align-items: center; justify-content: center; height: 40px; margin: 0 0 0 6px; padding: 0 18px; border: 0; border-radius: 9999px; background: var(--fd-bkn-terra); color: #fff; font: 600 0.8125rem/1 ${sans}; letter-spacing: 0.04em; cursor: pointer; appearance: none; -webkit-appearance: none; white-space: nowrap; transition: transform var(--fd-dur-1) var(--fd-ease-out), background var(--fd-dur-1) linear; }
403.fd-bkn-reserve:hover { background: #9e4626; }
404.fd-bkn-reserve:active { transform: scale(0.96); }
405.fd-bkn-reserve-lg { height: 44px; margin: 0; padding: 0 20px; }
406
407.fd-bkn-pop { position: absolute; right: 0; top: calc(100% + 10px); z-index: 3; width: 360px; margin: 0; padding: 18px; border: 1px solid var(--fd-bkn-line); border-radius: 22px; background: var(--fd-bkn-cream); color: var(--fd-bkn-sea); text-shadow: none; box-shadow: 0 30px 60px -12px rgba(15,42,58,0.35); animation: fd-bkn-pop var(--fd-dur-3) var(--fd-ease-out); transform-origin: top right; }
408.fd-bkn-pop[data-kind="guests"] { width: 320px; }
409@keyframes fd-bkn-pop { from { opacity: 0; transform: translateY(-6px) scale(0.98); } }
410.fd-bkn-cal, .fd-bkn-guests { margin: 0; padding: 0; color: var(--fd-bkn-sea); }
411.fd-bkn-calhead { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin: 0 0 12px; }
412.fd-bkn-calhead b { font: italic 400 1.25rem/1 ${serif}; color: var(--fd-bkn-sea); }
413.fd-bkn-calhead span { font: 500 0.75rem/1 ${sans}; color: var(--fd-bkn-terra); }
414.fd-bkn-grid { display: block; margin: 0; padding: 0; }
415.fd-bkn-wk { display: grid; grid-template-columns: repeat(7, 1fr); }
416.fd-bkn-wk > span { display: block; margin: 0; padding: 0; }
417.fd-bkn-wk:first-child span { padding: 0 0 6px; font: 500 0.6875rem/1 ${sans}; letter-spacing: 0.06em; color: #7b8790; text-align: center; }
418.fd-bkn-day { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; width: 100%; height: 44px; margin: 0; padding: 0; border: 0; border-radius: 0; background: none; color: var(--fd-bkn-sea); cursor: pointer; appearance: none; -webkit-appearance: none; }
419.fd-bkn-day b { font: 400 0.9375rem/1 ${serif}; font-variant-numeric: tabular-nums; }
420.fd-bkn-day small { font: 400 0.5625rem/1 ${sans}; color: #7b8790; }
421.fd-bkn-day:hover b { text-decoration: underline; text-underline-offset: 3px; }
422.fd-bkn-day[data-in] { background: #ecdcc6; }
423.fd-bkn-day[data-start] { background: linear-gradient(90deg, transparent 50%, #ecdcc6 50%); }
424.fd-bkn-day[data-stop] { background: linear-gradient(90deg, #ecdcc6 50%, transparent 50%); }
425.fd-bkn-day[data-start]:not([data-stop]):last-child { background: none; }
426.fd-bkn-day[data-end]::before { content: ""; position: absolute; left: 50%; top: 50%; width: 42px; height: 42px; margin: -21px 0 0 -21px; border-radius: 9999px; background: var(--fd-bkn-sea); z-index: 0; animation: fd-bkn-dot var(--fd-dur-2) var(--fd-spring); }
427@keyframes fd-bkn-dot { from { transform: scale(0.6); } }
428.fd-bkn-day[data-end] b, .fd-bkn-day[data-end] small { position: relative; z-index: 1; color: #f6efe3; }
429.fd-bkn-day[aria-disabled="true"] { cursor: not-allowed; }
430.fd-bkn-day[aria-disabled="true"] b { color: #a9b2b8; text-decoration: line-through; }
431.fd-bkn-calnote { margin: 12px 0 0; font: italic 400 0.8125rem/1.35 ${serif}; color: #5b6a74; }
432.fd-bkn-step { display: grid; grid-template-columns: 1fr 36px 32px 36px; align-items: center; gap: 4px; padding: 12px 0; border-bottom: 1px solid var(--fd-bkn-line); }
433.fd-bkn-step > span { display: flex; flex-direction: column; gap: 3px; }
434.fd-bkn-step b { font: 400 1.0625rem/1 ${serif}; color: var(--fd-bkn-sea); }
435.fd-bkn-step small { font: 400 0.75rem/1 ${sans}; color: #7b8790; }
436.fd-bkn-step button { display: grid; place-items: center; width: 36px; height: 36px; margin: 0; padding: 0; border: 1px solid #cdbfa9; border-radius: 9999px; background: none; color: var(--fd-bkn-sea); font: 400 1.125rem/1 ${sans}; cursor: pointer; appearance: none; -webkit-appearance: none; }
437.fd-bkn-step button:disabled { opacity: 0.35; cursor: default; }
438.fd-bkn-step output { font: 400 1.125rem/1 ${serif}; text-align: center; color: var(--fd-bkn-sea); }
439.fd-bkn-popfoot { display: flex; justify-content: space-between; align-items: center; margin: 14px 0 0; padding: 14px 0 0; border-top: 1px solid var(--fd-bkn-line); }
440.fd-bkn-text { margin: 0; padding: 8px 4px; border: 0; background: none; font: 500 0.8125rem/1 ${sans}; color: var(--fd-bkn-sea); text-decoration: underline; text-underline-offset: 3px; cursor: pointer; appearance: none; -webkit-appearance: none; }
441.fd-bkn-toast { position: fixed; left: 50%; bottom: 24px; z-index: 70; max-width: calc(100vw - 32px); margin: 0; padding: 12px 18px; border-radius: 14px; background: var(--fd-bkn-sea); color: #f6efe3; font: 400 0.9375rem/1.35 ${serif}; transform: translateX(-50%); box-shadow: 0 16px 40px rgba(15,42,58,0.35); animation: fd-bkn-toast var(--fd-dur-3) var(--fd-spring); }
442@keyframes fd-bkn-toast { from { opacity: 0; transform: translate(-50%, 12px); } }
443
444.fd-bkn-burger, .fd-bkn-mreserve { display: none; }
445.fd-bkn-burger { position: relative; width: 42px; height: 42px; margin: 0 -8px 0 0; padding: 0; border: 0; background: none; color: inherit; cursor: pointer; appearance: none; -webkit-appearance: none; }
446.fd-bkn-burger span { position: absolute; left: 11px; right: 11px; height: 1.5px; background: currentColor; transition: transform var(--fd-dur-3) var(--fd-spring); }
447.fd-bkn-burger span:first-child { top: 17px; }
448.fd-bkn-burger span:last-child { top: 24px; }
449.fd-bkn-burger[aria-expanded="true"] span:first-child { transform: translateY(3.5px) rotate(45deg); }
450.fd-bkn-burger[aria-expanded="true"] span:last-child { transform: translateY(-3.5px) rotate(-45deg); }
451.fd-bkn-mreserve { height: 36px; margin: 0; padding: 0 14px; border: 0; border-radius: 9999px; background: var(--fd-bkn-terra); color: #fff; font: 600 0.75rem/1 ${sans}; letter-spacing: 0.04em; cursor: pointer; appearance: none; -webkit-appearance: none; }
452.fd-bkn-sheet { position: fixed; left: 0; right: 0; top: 64px; bottom: 0; z-index: 2; overflow-y: auto; margin: 0; padding: 12px 20px 32px; background: var(--fd-bkn-cream); color: var(--fd-bkn-sea); text-shadow: none; animation: fd-bkn-sheet var(--fd-dur-3) var(--fd-ease-out); }
453.fd-bkn-sheet[hidden] { display: none; }
454@keyframes fd-bkn-sheet { from { clip-path: inset(0 0 100% 0); } to { clip-path: inset(0 0 0 0); } }
455.fd-bkn-sheet nav { display: block; margin: 0 0 20px; padding: 0; }
456.fd-bkn-sheet nav a { display: block; margin: 0; padding: 12px 0; border-bottom: 1px solid var(--fd-bkn-line); font: italic 400 2.25rem/1 ${serif}; letter-spacing: -0.02em; color: var(--fd-bkn-sea); text-decoration: none; animation: fd-bkn-rise var(--fd-dur-3) var(--fd-ease-out) both; }
457@keyframes fd-bkn-rise { from { opacity: 0; transform: translateY(10px); } }
458.fd-bkn-mbook { margin: 0; padding: 18px; border: 1px solid var(--fd-bkn-line); border-radius: 22px; background: #fbf7f0; }
459.fd-bkn-mh { margin: 0 0 14px; font: 500 0.6875rem/1 ${sans}; letter-spacing: 0.16em; text-transform: uppercase; color: var(--fd-bkn-terra); }
460.fd-bkn-mbook .fd-bkn-guests { margin: 8px 0 16px; }
461.fd-bkn-mfull { width: 100%; }
462
463@media (max-width: 1100px) { .fd-bkn-links { gap: 18px; } .fd-bkn-links a:nth-child(n+4) { display: none; } }
464@media (max-width: 900px) {
465 .fd-bkn-bar, .fd-bkn[data-solid] .fd-bkn-bar { grid-template-columns: 1fr auto; height: 64px; padding: 0 18px; }
466 .fd-bkn-links, .fd-bkn-pill { display: none; }
467 .fd-bkn-brand { align-items: flex-start; padding-left: 0; }
468 .fd-bkn-word, .fd-bkn[data-solid] .fd-bkn-word { font-size: 1.75rem; }
469 .fd-bkn-burger, .fd-bkn-mreserve { display: block; }
470 .fd-bkn-ann { padding: 0 44px 0 16px; justify-content: flex-start; }
471 .fd-bkn-ann p { text-align: left; font-size: 0.75rem; }
472}
473@media (prefers-reduced-motion: reduce) {
474 .fd-bkn, .fd-bkn-annwrap, .fd-bkn-bar, .fd-bkn-word, .fd-bkn-burger span { transition: none; }
475 .fd-bkn-pop, .fd-bkn-sheet, .fd-bkn-sheet nav a, .fd-bkn-toast, .fd-bkn-day[data-end]::before { animation: none; }
476}
477@media (prefers-reduced-transparency: reduce) { .fd-bkn-pill { -webkit-backdrop-filter: none; backdrop-filter: none; background: rgba(15,42,58,0.75); } }
478`;
479
480/* ── Demo ─────────────────────────────────────────────────────────────── */
481
482export function Demo({ src = "/img/halcyon/positano-sunset.webp", roomSrc = "/img/halcyon/suite-900.webp" }: { src?: string; roomSrc?: string }) {
483 return (
484 <div className="fd-bkn-demo">
485 <style>{demoCss}</style>
486 <BookingNav />
487 <section className="fd-bkn-demo-hero">
488 <img src={src} alt="Positano at sunset, seen from the hotel terrace" width={1024} height={1024} />
489 <div className="fd-bkn-demo-copy">
490 <p>Hotel Sirena · Via Pasitea 118 · 23 rooms</p>
491 <h1>A small hotel above the beach, <i>open all winter.</i></h1>
492 </div>
493 </section>
494 <section className="fd-bkn-demo-rooms" id="rooms">
495 <div>
496 <p className="fd-bkn-demo-k">Rooms</p>
497 <h2>Twenty-three rooms, nineteen with the sea in the window.</h2>
498 <p>Terracotta floors from Vietri, linen from a mill in Biella, and shutters that still close with a hook. The Torre suite sits in the 16th century watchtower and sleeps four.</p>
499 <ul>
500 <li><b>Camera Mare</b><span>24 m² · from €340</span></li>
501 <li><b>Terrazza</b><span>38 m² · from €480</span></li>
502 <li><b>Suite Torre</b><span>62 m² · from €890</span></li>
503 </ul>
504 </div>
505 <img src={roomSrc} alt="Suite with linen bed and a sea view" width={900} height={900} loading="lazy" />
506 </section>
507 </div>
508 );
509}
510
511const demoCss = `
512.fd-bkn-demo { margin: 0; padding: 0; background: #f6efe3; color: #0f2a3a; font: 400 15px/1.55 ${sans}; }
513.fd-bkn-demo *, .fd-bkn-demo *::before, .fd-bkn-demo *::after { box-sizing: border-box; }
514.fd-bkn-demo-hero { position: relative; height: 100vh; min-height: 620px; margin: 0; overflow: hidden; background: #2a2622; }
515.fd-bkn-demo-hero img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: 50% 60%; }
516.fd-bkn-demo-hero::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 45%, rgba(10,20,28,0.55)); }
517.fd-bkn-demo-copy { position: absolute; left: 28px; right: 28px; bottom: 44px; z-index: 1; color: #fff; }
518.fd-bkn-demo-copy p { margin: 0 0 14px; font: 500 0.75rem/1 ${sans}; letter-spacing: 0.16em; text-transform: uppercase; color: rgba(255,255,255,0.86); }
519.fd-bkn-demo-copy h1 { max-width: 15ch; margin: 0; font: 400 clamp(2.5rem, 6.4vw, 5.75rem)/0.98 ${serif}; letter-spacing: -0.03em; color: #fff; }
520.fd-bkn-demo-rooms { display: grid; grid-template-columns: 1fr 1fr; gap: 56px; align-items: center; max-width: 1240px; margin: 0 auto; padding: 96px 28px 140px; }
521.fd-bkn-demo-k { margin: 0 0 14px; font: 500 0.6875rem/1 ${sans}; letter-spacing: 0.16em; text-transform: uppercase; color: #b4532f; }
522.fd-bkn-demo-rooms h2 { margin: 0 0 18px; font: 400 2.5rem/1.05 ${serif}; letter-spacing: -0.02em; }
523.fd-bkn-demo-rooms p { margin: 0 0 24px; max-width: 46ch; font: 400 1.0625rem/1.6 ${serif}; color: #3b4f5c; }
524.fd-bkn-demo-rooms ul { margin: 0; padding: 0; list-style: none; border-top: 1px solid #e2d6c3; }
525.fd-bkn-demo-rooms li { display: flex; justify-content: space-between; padding: 14px 0; border-bottom: 1px solid #e2d6c3; }
526.fd-bkn-demo-rooms li b { font: italic 400 1.25rem/1 ${serif}; }
527.fd-bkn-demo-rooms li span { font: 400 0.875rem/1 ${sans}; color: #5b6a74; }
528.fd-bkn-demo-rooms img { display: block; width: 100%; aspect-ratio: 4 / 5; height: auto; object-fit: cover; border-radius: 200px 200px 0 0; }
529@media (max-width: 900px) {
530 .fd-bkn-demo-copy { left: 18px; right: 18px; bottom: 32px; }
531 .fd-bkn-demo-rooms { grid-template-columns: 1fr; gap: 32px; padding: 56px 18px 80px; }
532 .fd-bkn-demo-rooms h2 { font-size: 1.875rem; }
533}
534`;

More Navbars. Same file contract.

Floating Capsule Nav, navbars preview: Sticky navbar that springs from a full-width bar into a floating capsule on scroll, with a live status light…

Floating Capsule Nav

Sticky navbar that springs from a full-width bar into a floating capsule on scroll, with a live status light and version chip.

Command Nav, navbars preview: Navbar with command palette: search opens a real ⌘K palette with recents, grouped fuzzy results and a live…

Command Nav

Navbar with command palette: search opens a real ⌘K palette with recents, grouped fuzzy results and a live preview.

App Sidebar Nav, navbars preview: App sidebar navigation for React: workspace switcher, nested groups, badges, a sliding active marker and a…

App Sidebar Nav

App sidebar navigation for React: workspace switcher, nested groups, badges, a sliding active marker and a collapsible icon rail.

Questions about Booking Nav

Run npx shadcn@latest add https://freedesignmd.com/r/booking-nav.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.

Hotel booking navbar over a full-bleed photo: dismissible announcement, hide on scroll down and a Dates, Guests, Reserve pill. It is a free, MIT-licensed React navbars component from the freedesignmd library.

Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.

All evergreen browsers. The pill uses backdrop-filter where supported and a solid tint elsewhere.

Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.