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
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
| Prop | Type | Default | Description |
|---|---|---|---|
date | Date | — | The picked day (DatePicker): a calendar day at local midnight (see Dates, below). |
onDateChange | (date: Date | undefined) => void | — | Called with the new day; the calendar closes on the pick. |
from / to | Date | — | The picked stay (DateRangePicker): check-in and check-out. |
onRangeChange | ({ from, to }) => void | — | Called on each pick: the check-in alone first, then the whole stay; the calendar closes when the stay is whole. |
minNights | number | 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 | "field" | "split" | "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 | [string, string] | ["Check-in", "Check-out"] | The two halves’ labels (split). |
disabledDays | Matcher | Matcher[] | — | 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 | string | — | 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 | string | — | 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 | string | "Pick a date" · "Pick your dates" | What the field says before a pick. |
disabled | boolean | — | Greys the field out. |
className | string | — | More classes, merged with the component’s own (tailwind-merge). |
dateDateThe picked day (DatePicker): a calendar day at local midnight (see Dates, below).
onDateChange(date: Date | undefined) => voidCalled with the new day; the calendar closes on the pick.
from / toDateThe picked stay (DateRangePicker): check-in and check-out.
onRangeChange({ from, to }) => voidCalled on each pick: the check-in alone first, then the whole stay; the calendar closes when the stay is whole.
minNightsnumber1The 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"field" | "split""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[string, string]["Check-in", "Check-out"]The two halves’ labels (split).
disabledDaysMatcher | Matcher[]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-labelledbystringThe 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-labelstringA 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.
placeholderstring"Pick a date" · "Pick your dates"What the field says before a pick.
disabledbooleanGreys the field out.
classNamestringMore 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.
| Prop | Type | Default | Description |
|---|---|---|---|
layout | "field" | "split" | "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 | ReactNode · ReactNode · string | — | field: a decorative glyph (title=""), what’s picked as shown, and what it says before a pick (in the muted colour). |
parts | { label; value?; placeholder? }[] | — | split: the halves (Check-in → Check-out; Guests). |
aria-labelledby · aria-label | string | — | The name, built with the field’s own words after it (usePickerName does the same for a field of your own). |
description | string | — | Read after the name: what the field sums up (“2 adults, 1 child”). |
layout"field" | "split""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 · placeholderReactNode · ReactNode · stringfield: a decorative glyph (title=""), what’s picked as shown, and what it says before a pick (in the muted colour).
parts{ label; value?; placeholder? }[]split: the halves (Check-in → Check-out; Guests).
aria-labelledby · aria-labelstringThe name, built with the field’s own words after it (usePickerName does the same for a field of your own).
descriptionstringRead after the name: what the field sums up (“2 adults, 1 child”).