Zostel
Forms Date Picker

Date Picker

A day to arrive, or the nights you stay, from one button: the brand calendar in a popover, two months side by side, one on a phone.

Preview

Arrive

Your stay

A room's dates: the split look

The sold-out night is a sample.

Usage

tsx
import { DatePicker, DateRangePicker, dayFromISO, dayToISO } from "zostel-ui";

const [arrive, setArrive] = useState<Date | undefined>();
const [stay, setStay] = useState({ from: undefined, to: undefined });

<h3 id="arrive">Arrive</h3>
<DatePicker aria-labelledby="arrive" date={arrive} onDateChange={setArrive} disabledDays={{ before: today }} />

<h3 id="stay">Your stay</h3>
<DateRangePicker
  aria-labelledby="stay"
  from={stay.from}
  to={stay.to}
  onRangeChange={setStay}
  disabledDays={{ before: today }}
/>

{/* a room's calendar: its sold-out nights come from the API as "YYYY-MM-DD" */}
<DateRangePicker
  variant="split"
  aria-label="Your dates"
  from={stay.from}
  to={stay.to}
  onRangeChange={setStay}
  disabledDays={[{ before: today }, ...room.soldOut.map(dayFromISO)]}
  minNights={2}
/>

{/* back to the API: the day, not a timestamp */}
book({ checkIn: dayToISO(stay.from), checkOut: dayToISO(stay.to) });

Props

date
Type: Date
Default: —

The picked day (DatePicker): a calendar day at local midnight (see Dates, below).

onDateChange
Type: (date: Date | undefined) => void
Default: —

Called with the new day; the calendar closes on the pick.

from / to
Type: Date
Default: —

The picked stay (DateRangePicker): check-in and check-out.

onRangeChange
Type: ({ from, to }) => void
Default: —

Called on each pick: the check-in alone first, then the whole stay; the calendar closes when the stay is whole.

minNights
Type: number
Default: 1

The fewest nights a stay can be (DateRangePicker). The line under the calendar says it; a check-out picked too soon keeps the check-in and says “Minimum 2 nights: check out on Wed 15 Oct or later.”

variant
Type: "field" | "split"
Default: "field"

DateRangePicker: one line (Sun 12 Oct – Wed 15 Oct), or two labelled halves with an arrow between (Check-in → Check-out), for a search bar or a room list’s header. Both take the card’s colours with the field edge, so they sit in a bar at night too.

labels
Type: [string, string]
Default: ["Check-in", "Check-out"]

The two halves’ labels (split).

disabledDays
Type: Matcher | Matcher[]
Default: —

Days that can’t be picked, e.g. { before: today } (react-day-picker v9 matchers). For a stay these are nights that can’t be booked: no check-in on one and no stay through one, but a check-out on one is fine (you leave that morning).

aria-labelledby
Type: string
Default: —

The id of the visible label. The name is the label followed by what the field shows: “Arrive, Sun 12 Oct”, the words on the screen (WCAG 2.5.3). On a phone the sheet’s title is the label’s words.

aria-label
Type: string
Default: —

A name when there is no visible label, read before what the field shows. One of the two is required; in development, a picker with neither warns in the console.

placeholder
Type: string
Default: "Pick a date" · "Pick your dates"

What the field says before a pick.

disabled
Type: boolean
Default: —

Greys the field out.

className
Type: string
Default: —

More classes, merged with the component’s own (tailwind-merge).

How it works

The button is a field (PickerField: the Button’s field look, the card’s colours with an edge that clears 3:1 by day and at night, the same as the Combobox and the guest picker). It shows the day short (Sun 12 Oct), and its name is its label followed by those same words.

The calendar is the kit’s Calendar. On a desk it opens in a popover under the field; on a phone (below 640 px, the kit’s one breakpoint) it rises from the bottom in the kit’s sheet, titled with the field’s label, one month at a time, with Done (as zostel.com does). It opens on the picked day, the arrow keys move by day and week, and Escape closes it and gives the focus back to the field.

A stay counts nights. The first pick is check-in and the second check-out, and a line under the calendar says which comes next, out loud too: “Check-in Sun 12 Oct. Now pick your check-out day.” A day that can’t be booked is a night: you can’t check in on it or stay through it, but you can check out on it, so the first sold-out night after your check-in is still a morning you can leave on. A check-out too soon for minNights keeps the check-in and says the minimum; a day past a night that can’t be booked starts a new stay, and the line says why. A pick on a finished stay starts a new one.

Dates

Every Date the kit takes or gives is a calendar day: local midnight in the browser’s zone. The pickers print them in that zone, so make days with dayFromISO("2026-10-12"), never new Date("2026-10-12") (that is midnight UTC: the evening before, west of Greenwich), and send them back with dayToISO(day), not toISOString().

Print them in the browser. A server prints in its own zone (UTC on Vercel), so a Date made on the server and printed again in the browser can fall on another day, and the page won’t hydrate. Pass “YYYY-MM-DD” strings across the server line and make the days on each side, or fill the dates in after the page loads, as these demos do with today.

A picker of your own: PickerField and PickerShell

The pickers' field and shell are exported: PickerShell opens a popover on a desk and the sheet on a phone (it passes its phone flag to a function child), PickerField is the trigger.

layout
Type: "field" | "split"
Default: "field"

One line (the icon, then the value or the placeholder), or a small label over each value, two halves with an arrow between.

icon · value · placeholder
Type: ReactNode · ReactNode · string
Default: —

field: a decorative glyph (title=""), what’s picked as shown, and what it says before a pick (in the muted colour).

parts
Type: { label; value?; placeholder? }[]
Default: —

split: the halves (Check-in → Check-out; Guests).

aria-labelledby · aria-label
Type: string
Default: —

The name, built with the field’s own words after it (usePickerName does the same for a field of your own).

description
Type: string
Default: —

Read after the name: what the field sums up (“2 adults, 1 child”).