Build a date-range picker that leads with presets
By the end of this recipe you'll have a date-range field that opens as a popover on desktop and a bottom sheet on mobile, with five presets up top, a two-month calendar below, validation that pins start ≤ end, and a clean {from, to} ISO output.
Overview
A trigger button shows the current range. Tap → presets (Today, Yesterday, This week, This month, Custom). Pick a preset and you're done; pick Custom and a two-month calendar slides into view.
Date ranges are how operators ask their data questions. "Show me sales this month." "Refunds last week." "All shifts between 1 May and 14 May." Most of those questions hit one of five answers, and the rest hit a 14-day span ending today. A picker that buries those behind two clicks on a calendar is hostile design.
Presets first, calendar second. Most operators pick "This month", not 1 May → 14 May. Show the answer they want before the tool that builds it. The calendar is a fallback, not the entry point.
Output is always {from, to} as ISO date strings, never Date objects — they serialise cleanly into URL params and persist across reloads. The calendar is opinionated: Monday-first weeks, the second month is always from's month + 1, validation pins from ≤ to by swapping if the user clicks them in the wrong order.
Skip this recipe if you only need a single date (use <Calendar mode="single"/> from the same primitive) or if your data is always "last 30 days" and the operator has no business changing it — then a label, not a picker, is the right control.
What you'll build
Three states: trigger, presets-with-Custom-open, calendar-with-range-being-selected.
Sales report
Filter by date
Presets
Custom
1 May — 15 May (14 days)
Required pieces
Everything this recipe pulls from @corelithzw/react.
@corelithzw/react exports used here: Button, Popover, BottomSheet, Calendar, Stack, Field, Input, useDateRange. The presets list is just data — no new export needed.
Step-by-step build
Model the range as {from, to} ISO strings, not Date objects
Date objects don't serialise into URL params and don't compare with ===. ISO date strings ('2026-05-01') sort lexicographically the same as chronologically, JSON-clone cleanly, and round-trip through the query string. The picker is the only place that ever sees a real Date — and only for rendering the calendar.
ISO strings sort the same as chronologically.
Swap on the way in — UI never has to handle "invalid".
Switch to the Code tab to see the snippet.
Build the preset list as plain data, not buttons
Presets are a static list of {label, range} pairs. Render them with a single map; the active state is whatever preset matches the current range, or 'custom' if none do. This means adding "Last 7 days" is a one-line change.
Pick the surface — Popover on desktop, BottomSheet on mobile
A two-month calendar floating off a 200px input on a phone is unusable. Switch the wrapper by viewport: Popover above ~720px, BottomSheet below. Both expose the same open / onOpenChange API so the trigger button doesn't care which one's mounted.
< 720px → BottomSheet (full-width, drag-to-dismiss)
Switch to the Code tab to see the snippet.
Wire the two-month calendar with hover preview
Calendar in mode="range" handles the first-click / second-click state internally; you give it numberOfMonths={2} and it lays out the second month to the right. The hover-preview range (between first click and second) is a visual concern only — don't write it back to useDateRange until the user commits.
Final composition
The whole DateRangePicker component, assembled. Presets, calendar, responsive surface, validation, ISO output — drop it in.
Variations
Three forks of this recipe. Each is a small diff from the final composition.
Single date
Drop the range entirely. The output becomes one ISO string, and presets shrink to "Today / Yesterday / Pick a date".
// Switch the calendar mode
<Calendar mode="single"
value={value} onChange={onChange} />
// Output type
export type SingleDate = string;
// Drop the from/to Field pair
With time-of-day
For shift exports and timesheets — pair each date with an HH:mm input. Output becomes ISO datetime strings.
// Extend the range
type DateRange = {
from: string; fromTime: string; // 09:00
to: string; toTime: string; // 17:00
};
// Add <Input type="time"/> next to each
// date Field. Default 00:00 / 23:59.
Fiscal calendar
Add "This quarter" / "Last quarter" / "Year-to-date" presets that respect a fiscal-year offset (e.g. April start).
// Add to PRESETS
{ id: 'qtd', label: 'Quarter to date',
range: (n) => ({
from: fiscalQtrStart(n, /*startMonth*/ 4),
to: n,
})
},
// Move fiscal config to a Provider.
Accessibility
What this recipe takes care of for you. Every item below is something a screen-reader or keyboard-only user will notice if you skip it.
- Trigger announces what it controls.
aria-haspopup="dialog"on the trigger button tells screen readers that activating it opens a dialog, not a menu. - Presets are a group. The preset row carries
role="group"+aria-label="Date range presets"so screen readers announce them as a related set, not five orphan buttons. - Custom is an expandable disclosure. The Custom button carries
aria-expanded={showCalendar}so users know whether the calendar is open without seeing it. - Calendar exposes the range.
Calendarsetsaria-labelon each day cell ("Friday 1 May 2026, start of selected range"), so range selection is audible. - Keyboard navigates the calendar. Arrow keys move day-by-day, Home/End jump to week start/end, PgUp/PgDn change months. Enter commits, Esc cancels.
- From ≤ To is enforced silently. If the user types
From: 31 May, To: 1 May, the picker swaps on submit — it doesn't show an error. - The bottom sheet on mobile is a real dialog. Focus traps inside it, Esc closes it, the trigger gets focus back on close.