Home/ Cookbook/ Forms/ Date-range picker

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.

Forms 4 primitives · 0 blocks · 1 pattern ~25 min React · @corelithzw/react

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.

Huchu · Reports

Sales report

Filter by date

1 May — 31 May
01 Trigger — current range as label
Pick range

Presets

TodayYesterdayWeekMonthCustom

Custom

From: 1 May 2026
To: 31 May 2026
Apply
02 Presets — Custom expands the calendar
May 2026
MTWTFSS 123 45678910 1112131415

1 May — 15 May (14 days)

Apply
03 Calendar — range highlight, hover-to-preview

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

01

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.

Code-only step

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.

Step 1 · Range model@corelithzw/react
02

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.

Today Yesterday This week This month Custom
Step 2 · Presets
03

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.

Code-only step

< 720px → BottomSheet (full-width, drag-to-dismiss)

Switch to the Code tab to see the snippet.

Step 3 · Responsive surface
04

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.

M T W T F S S 1 2 3 4 5 6 7
Step 4 · Calendar

Final composition

The whole DateRangePicker component, assembled. Presets, calendar, responsive surface, validation, ISO output — drop it in.

Today Yesterday This week This month Custom
From
2026-05-01
To
2026-05-31
Cancel
Apply
DateRangePicker.tsx@corelithzw/react

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. Calendar sets aria-label on 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.