BookingPanel
A contained booking card with date and guest selection, pricing display, and a reserve CTA.
pnpm add @wandercom/design-system-web
The BookingPanel block provides a self-contained booking interface with check-in/check-out date inputs, a guest selector, dynamic pricing header, and a submit button. It adapts its header and CTA label based on whether dates are selected.
When dateRange and onDateChange are provided, the PropertyCalendar is composed directly into the date inputs as a popover on desktop or a full-screen modal from the mobile bar. Clicking the date inputs opens the calendar automatically.
import {
BookingPanel,
BookingPanelMobileBar,
BookingPanelRoot,
formatGuestLabel,
} from '@wandercom/design-system-web/blocks/booking-panel';
import type { AvailabilityDay, DateRange } from '@wandercom/design-system-web';
import { format } from 'date-fns';
import { useState } from 'react';
export function Example() {
const [dateRange, setDateRange] = useState<DateRange>({ start: null, end: null });
const [guests, setGuests] = useState<number | null>(null);
const [pets, setPets] = useState<number | null>(null);
return (
<BookingPanelRoot
desktop={
<BookingPanel
dateRange={dateRange}
onDateChange={setDateRange}
availability={availabilityData}
stayRequirements={{ minNights: 3 }}
checkinDate={dateRange.start ? format(dateRange.start, "MM/dd/yyyy") : null}
checkoutDate={dateRange.end ? format(dateRange.end, "MM/dd/yyyy") : null}
guests={guests}
onGuestsChange={setGuests}
pets={pets}
onPetsChange={setPets}
petsAllowed
guestLabel={formatGuestLabel(guests, pets) || "1 guest"}
pricing={dateRange.start && dateRange.end ? { totalPrice: "$12,500", nightCount: 5 } : null}
showTotalBeforeTaxesCopy
onSubmit={() => handleReserve()}
/>
}
mobile={
<BookingPanelMobileBar
dateRange={dateRange}
onDateChange={setDateRange}
availability={availabilityData}
stayRequirements={{ minNights: 3 }}
checkinDate={dateRange.start ? format(dateRange.start, "MM/dd/yyyy") : null}
checkoutDate={dateRange.end ? format(dateRange.end, "MM/dd/yyyy") : null}
nightlyRate="$2,500"
showTotalBeforeTaxesCopy
onPress={handlePress}
/>
}
/>
);
}The primary preview places the composed block in a minimal property detail layout: property content on the left, a sticky desktop booking sidebar on the right, and the fixed mobile booking bar on narrow viewports.
The panel adapts based on the props provided:
No dates selected -- The header displays "Select dates for pricing" and the CTA reads "Add dates". When calendar props are provided, clicking the CTA opens the calendar popover.
Dates selected with pricing -- The header shows the total price and night count, and the CTA reads "Reserve". By default the summary stays as for N nights.
Dates selected with pricing + total-before-taxes copy -- Pass showTotalBeforeTaxesCopy to append "(before taxes)" to the stay summary when your pricing excludes taxes.
Pricing loading -- When pricingLoading is true, the header shows skeleton shimmer placeholders.
Request to book -- When bookingMode="request", the panel heading changes to "Request to book" and the button defaults to "Request to book". The submitLabel prop still overrides the button label.
Error -- When error is set, the date input borders turn destructive red and the message is displayed between the inputs and the CTA.
Dates this property cannot take -- When datesInvalid is true, the date input borders turn destructive red, and the panel stops holding the CTA back until a price arrives. Dates the property cannot take will never be priced, so there is nothing to wait for. Whether the CTA is usable stays with submitDisabled: leave it alone to repurpose the CTA (for example "Change dates", sending the guest back to the calendar), or set it to keep a dead "Unavailable" button.
Use error for the message, or put your own content in children -- it renders between the inputs and the CTA.
Submit button variant -- The CTA picks its own button variant from whether a stay is selected. Pass submitVariant to choose a different one, for example when the CTA has been repurposed and should not read as the primary booking action.
Submitting -- When submitting is true, the CTA button shows a loading spinner.
BookingPanelRoot switches between desktop and mobile content at the lg viewport breakpoint (65rem). Pass separate desktop and mobile render props to show the appropriate layout for the available viewport width. If either prop is omitted, children is used as the fallback.
import {
BookingPanel,
BookingPanelMobileBar,
BookingPanelRoot,
} from '@wandercom/design-system-web/blocks/booking-panel';
<BookingPanelRoot
desktop={<BookingPanel {...desktopProps} />}
mobile={<BookingPanelMobileBar {...mobileProps} />}
/>This is the recommended pattern for property detail pages where the desktop sidebar panel and the mobile fixed bottom bar share the same booking state.
The BookingPanel supports two patterns for guest management.
Integrated popover -- When guests and onGuestsChange are provided, the guests input becomes a Popover with NumberInput rows for guests and pets. Use formatGuestLabel to derive the display string from the current values.
import { BookingPanel, formatGuestLabel } from '@wandercom/design-system-web/blocks/booking-panel';
const [guests, setGuests] = useState<number | null>(null);
const [pets, setPets] = useState<number | null>(null);
<BookingPanel
guests={guests}
onGuestsChange={setGuests}
pets={pets}
onPetsChange={setPets}
petsAllowed
maxOccupancy={12}
maxPets={3}
guestLabel={formatGuestLabel(guests, pets) || "1 guest"}
// ...other props
/>When petsAllowed is false, the pets row is rendered but disabled with a value of 0.
External callback -- When guests/onGuestsChange are not provided, the panel falls back to the onGuestsClick callback so the host application can manage the guest picker externally.
<BookingPanel
guestLabel="3 guests, 1 pet"
onGuestsClick={() => openGuestPicker()}
// ...other props
/>Pass dateRange, onDateChange, and optionally availability, stayRequirements, rules, calendarAlert, pricePerNight, and onDatesSelected to compose the PropertyCalendar directly into the panel. The calendar opens as a Popover on desktop when date inputs or the "Add dates" CTA are clicked.
Use onDatesSelected to trigger a pricing API request once the user commits both dates — it fires after both check-in and check-out are set.
<BookingPanel
dateRange={dateRange}
onDateChange={setDateRange}
availability={availability}
stayRequirements={{ minNights: 3, checkInDays: ['Saturday'] }}
checkinDate={dateRange.start ? format(dateRange.start, "MM/dd/yyyy") : null}
checkoutDate={dateRange.end ? format(dateRange.end, "MM/dd/yyyy") : null}
pricePerNight="$787.86/night pre-tax"
showTotalBeforeTaxesCopy
onDatesSelected={(range) => fetchPricing(range)}
onSubmit={handleReserve}
/>Pass calendarOpen + onCalendarOpenChange to control the calendar popover (desktop) or full-screen modal (mobile) from outside the panel — useful when a sibling component needs to open the calendar (e.g. an "Add dates" CTA elsewhere on the page). Leave both undefined for the default uncontrolled behavior.
When the viewport crosses the lg breakpoint, an open composed calendar closes before the desktop popover or mobile full-screen modal becomes active.
const [calendarOpen, setCalendarOpen] = useState(false);
<BookingPanelComposed
calendarOpen={calendarOpen}
onCalendarOpenChange={setCalendarOpen}
dateRange={dateRange}
onDateChange={setDateRange}
/* ...rest */
/>
// Open from anywhere else on the page:
<button onClick={() => setCalendarOpen(true)}>Add dates</button>BookingPanelMobileBar is the fixed bottom bar used in the narrow viewport layout. It supports the same calendar integration props and opens a vertically scrolling, full-screen PropertyCalendarMobile when tapped. Its price-first summary reserves the same layout while nightly rates and selected-stay pricing load.
import { BookingPanelMobileBar } from '@wandercom/design-system-web/blocks/booking-panel';
<BookingPanelMobileBar
dateRange={dateRange}
onDateChange={setDateRange}
availability={availability}
stayRequirements={{ minNights: 3 }}
checkinDate={dateRange.start ? format(dateRange.start, "MM/dd/yyyy") : null}
checkoutDate={dateRange.end ? format(dateRange.end, "MM/dd/yyyy") : null}
nightlyRate="$2,500"
showTotalBeforeTaxesCopy
onPress={handlePress}
/>Pass mobileCalendarAction to BookingPanelMobileBar or BookingPanelComposed to customize the full-screen calendar footer button. It accepts saveLabel, saveVariant, saveDisabled, saving, and onConfirm, forwarded to PropertyCalendarMobile.
Without onConfirm, the button keeps its default Save behavior and closes the calendar. Supplying onConfirm replaces that action: the calendar stays open until the application closes it via calendarOpen and onCalendarOpenChange, or navigates away. This supports reserving directly from the calendar while keeping loading or error feedback visible. Closing the calendar through its close button or Escape does not invoke onConfirm.
saveLabel changes the button text, saveVariant selects its Button variant (default primary), and saving shows progress and disables the button. saveDisabled adds an application-level disabled condition; the booking panel always disables confirmation while either date is missing.
Pass summaryContent to BookingPanelMobileBar, or mobileSummaryContent to BookingPanelComposed, to render a badge or other non-interactive content beneath the stay details. This content sits inside the button that opens the calendar, so it must not contain links, buttons, or form controls. Omitting the slot preserves the existing layout.
The application supplies the content and decides when it should appear. For example, it can calculate cancellation eligibility and pass a success Badge with the refund deadline. Use children for the corresponding desktop placement above Reserve.
Pass callout to show a short note at the top of the panel, such as why the selected dates are a good choice. BookingPanel shows it above the price, and BookingPanelMobileBar shows it across the top of the bar, above the stay summary and CTA. BookingPanelComposed forwards it to both.
The application supplies the content and decides when it should appear. Omitting it preserves the existing layout.
<BookingPanelComposed
callout={<DateCallout>Cheapest dates in the next 60 days</DateCallout>}
dateRange={dateRange}
onDateChange={setDateRange}
pricing={{ totalPrice: "$12,500", nightCount: 4 }}
/>Use the children prop to inject additional content between the guest selector and the CTA, such as a coupon input or price breakdown.
<BookingPanel
dateRange={dateRange}
onDateChange={setDateRange}
checkinDate={dateRange.start ? format(dateRange.start, "MM/dd/yyyy") : null}
checkoutDate={dateRange.end ? format(dateRange.end, "MM/dd/yyyy") : null}
pricing={{ totalPrice: "$12,500", nightCount: 5 }}
onSubmit={() => handleReserve()}
>
<PriceBreakdown items={lineItems} />
</BookingPanel>desktop?:
mobile?:
children?:
className?:
dateRange?:
onDateChange?:
availability?:
availabilityLoading?:
rules?:
stayRequirements?:
calendarAlert?:
pricePerNight?:
onDatesSelected?:
checkinDate?:
checkoutDate?:
guests?:
onGuestsChange?:
pets?:
onPetsChange?:
petsAllowed?:
maxOccupancy?:
maxPets?:
guestLabel?:
pricing?:
pricingLoading?:
showTotalBeforeTaxesCopy?:
error?:
datesInvalid?:
submitLabel?:
submitVariant?:
submitDisabled?:
submitting?:
disclaimer?:
callout?:
bookingMode?:
onCheckinClick?:
onCheckoutClick?:
onGuestsClick?:
onSubmit?:
children?:
className?:
dateRange?:
onDateChange?:
availability?:
availabilityLoading?:
rules?:
stayRequirements?:
calendarAlert?:
checkinDate?:
checkoutDate?:
pricing?:
showTotalBeforeTaxesCopy?:
nightlyRate?:
nightlyRateLoading?:
summaryContent?:
callout?:
mobileCalendarAction?:
labels?:
calendarLabels?:
onPress?:
submitLabel?:
pricingLoading?:
submitDisabled?:
submitting?:
bookingMode?:
className?:
dateRange?:
onDateChange?:
availability?:
availabilityLoading?:
rules?:
stayRequirements?:
calendarAlert?:
onDatesSelected?:
checkinDate?:
checkoutDate?:
guests?:
onGuestsChange?:
pets?:
onPetsChange?:
petsAllowed?:
maxOccupancy?:
maxPets?:
pricing?:
pricingLoading?:
showTotalBeforeTaxesCopy?:
guestLabel?:
pricePerNight?:
submitLabel?:
submitDisabled?:
submitting?:
disclaimer?:
error?:
bookingMode?:
onSubmit?:
onPress?:
nightlyRate?:
nightlyRateLoading?:
mobileSummaryContent?:
callout?:
mobileCalendarAction?:
children?:
className?:
formatGuestLabel
function formatGuestLabel(
guests: number | null | undefined,
pets: number | null | undefined
): stringReturns a formatted string like "3 guests, 1 pet" or "2 guests". Returns an empty string when both values are null/undefined. Handles singular/plural forms automatically.
The shared calendar integration interface extended by both BookingPanel and BookingPanelMobileBar.
interface BookingPanelCalendarProps {
dateRange?: DateRange;
onDateChange?: (range: DateRange) => void;
availability?: AvailabilityDay[];
availabilityLoading?: boolean;
rules?: BookingRule[];
stayRequirements?: StayRequirements;
calendarAlert?: ReactNode;
onDatesSelected?: (range: DateRange) => void;
onError?: () => void;
calendarOpen?: boolean;
onCalendarOpenChange?: (open: boolean) => void;
}The guest management interface extended by BookingPanel.
interface BookingPanelGuestsProps {
guests?: number | null;
onGuestsChange?: (value: number | null) => void;
pets?: number | null;
onPetsChange?: (value: number | null) => void;
petsAllowed?: boolean;
maxOccupancy?: number; // default 16
maxPets?: number; // default 5
}type BookingPanelPricing = {
totalPrice: string;
nightCount: number;
};type BookingPanelRating = {
stars: number;
reviewCount: number;
};