Range picker
StableA start and end date for report filters, with analytics presets, typed ends and two linked months.
The panel has presets on the left (a scrolling row on phones), Start and End as typed date fields, and two months side by side (one below 640 pixels). The first click sets the start and the second the end, in either order, with a preview that follows the pointer or the keyboard focus. Nothing changes until Apply: onChange then gets { start, end } as ISO dates, both inclusive.
Report filter
import { useState } from 'react'import { RangePicker, type DateRange } from '@rchursin/product/calendar'
/** A report filter. The range changes only on Apply; Cancel or Esc keeps the one before. */export default function RangePickerBasic() { const [range, setRange] = useState<DateRange | null>({ start: '2026-10-01', end: '2026-10-21' })
return ( <div className="flex w-full max-w-80 flex-col gap-3"> <RangePicker label="Date range" hint="Runs that started in this range." name="range" value={range} onChange={setRange} /> <p className="font-mono text-[11px] text-fg-3">{range ? `start: "${range.start}" · end: "${range.end}"` : 'value: null'}</p> </div> )}Usage
import { RangePicker, RangePanel, rangeLabel, type DateRange } from '@rchursin/product/calendar'const [range, setRange] = useState<DateRange | null>({ start: '2026-10-01', end: '2026-10-21' })
<RangePicker label="Date range" value={range} onChange={setRange} />Examples
Panel on its own
RangePanel inline with the presets turned off. Cancel throws the draft away by remounting the panel.
import { useState } from 'react'import { RangePanel, rangeLabel, type DateRange } from '@rchursin/product/calendar'
/** * The panel on its own. Two months from 640px up, one below. Click a start and an end (in * either order), or type them, or pick a preset; nothing is used until Apply. */export default function RangePickerPanel() { const [applied, setApplied] = useState<DateRange>({ start: '2026-10-05', end: '2026-10-16' }) // A new key throws away the draft on Cancel const [round, setRound] = useState(0)
return ( <div className="flex w-full flex-col items-center gap-3"> <div className="rc-pop-panel max-w-full"> <RangePanel key={round} value={applied} onApply={setApplied} onCancel={() => setRound((r) => r + 1)} presets={false} /> </div> <p className="text-xs text-fg-3 tabular-nums">Applied: {rangeLabel(applied)}</p> </div> )}Custom presets
Quarters instead of the analytics set, inside a min and max for the year. Choosing one fills the draft and shows it pressed; after Apply the field names it too.
import { useState } from 'react'import { RangePicker, type DateRange, type RangePreset } from '@rchursin/product/calendar'
/** Your own presets: each one returns the range it means (it is given today, which fixed ranges can ignore). */const quarters: RangePreset[] = [ { id: 'q3', label: 'Q3 2026', range: () => ({ start: '2026-07-01', end: '2026-09-30' }) }, { id: 'q4', label: 'Q4 2026', range: () => ({ start: '2026-10-01', end: '2026-12-31' }) }, { id: 'h2', label: 'Second half', range: () => ({ start: '2026-07-01', end: '2026-12-31' }) },]
export default function RangePickerPresets() { const [range, setRange] = useState<DateRange | null>({ start: '2026-10-01', end: '2026-12-31' })
return ( <div className="w-full max-w-80"> <RangePicker label="Reporting period" value={range} onChange={setRange} presets={quarters} min="2026-01-01" max="2026-12-31" /> </div> )}API
RangePicker props
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | – | Visible label, and the start of the field’s accessible name. |
valuerequired | DateRange | null | – | The applied range, { start, end } in ISO dates, both inclusive. |
onChangerequired | (value: DateRange | null) => void | – | Called on Apply with the new range (the panel has no Clear, so it never sends null itself). |
min | ISODate | – | Earliest date that can be chosen. |
max | ISODate | – | Latest date that can be chosen. For analytics, todayISO(): there is no data from the future. |
isDateUnavailable | (date: ISODate) => boolean | – | Extra dates that can’t be chosen. |
presets | RangePreset[] | false | rangePresets | Today, Yesterday, Last 7 / 14 / 30 days, This month, Last month and This year (all but Yesterday and Last month end today). Pass your own, or false to hide them. The preset a range matches is shown pressed in the panel and named after the range in the field (“· Q4 2026”), from your list or, with false, from the built-in set. Matching needs today’s date, so it starts once the page runs in the browser. |
placeholder | string | 'Pick a range' | Shown when there is no value. |
hint | string | – | Help text under the label, linked with aria-describedby. |
hideLabel | boolean | – | Keep the label for screen readers only. |
name | string | – | Submit the range as an ISO 8601 interval ("2026-10-01/2026-10-21") under this name. |
className | string | – | Classes on the wrapper. |
RangePanel props
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | DateRange | null | – | The range the draft starts from. The months open on its end date. |
onApplyrequired | (value: DateRange) => void | – | Apply was pressed with a complete range. |
onCancelrequired | () => void | – | Cancel was pressed. Close the popover, or remount the panel to drop the draft. |
min | ISODate | – | Earliest date that can be chosen. |
max | ISODate | – | Latest date that can be chosen. |
isDateUnavailable | (date: ISODate) => boolean | – | Extra dates that can’t be chosen. |
presets | RangePreset[] | false | rangePresets | The preset list, or false for none. The one the draft matches is pressed. |
focusedRef | RefObject<HTMLButtonElement | null> | – | Filled with the focused date cell, for a popover’s first focus. |
className | string | – | Classes on the panel. |
rangeLabel(range, style)
| Prop | Type | Default | Description |
|---|---|---|---|
rangerequired | DateRange | – | The range to write out. |
style | 'short' | 'spoken' | 'short' | Short: “Oct 1 – 21, 2026” (shared parts said once). Spoken: “Thursday, October 1 to Wednesday, October 21, 2026”, with “to” for the dash. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| EnterSpace | On the field: opens the panel with focus on the range’s end date (or today). |
| Left arrowRight arrow | Previous or next day (swapped in right-to-left layouts). |
| Up arrowDown arrow | The same weekday a week earlier or later. |
| HomeEnd | First or last day of the week, in the dates locale (Sunday or Monday first; see setDateLocale). |
| Page UpPage Down | The same day in the previous or next month, clamped (Mar 31 goes to Feb 28). |
| ShiftPage UpShiftPage Down | The same day a year earlier or later. |
| EnterSpace | On a date: the first press sets the start, the second the end. The arrows walk across both months. |
| TabShiftTab | Moves between the presets, the Start and End segments, the month buttons, the grid (one stop), Cancel and Apply, and wraps around at either end: the panel is a modal dialog, so focus stays in it until Apply, Cancel, Esc or a click outside. |
| Esc | Closes without a change. |
- Each step is announced: “Start date Thursday, October 1, 2026. Now choose the end date.”, then “Selected … Press Apply to use it.”, and after Apply “Date range: …”.
- Spoken ranges use “to” instead of the dash, which some screen readers read as “en dash”.
- Presets are toggle buttons (aria-pressed) in a group named “Presets”.
- In range mode the grid is aria-multiselectable and each date says “start of selected range”, “in selected range” or “end of selected range”.
- The months on show are announced politely when they change (“October 2026 and November 2026”), and the footer names the time zone the days are in (“your time zone” until the page runs in the browser, which is the only place it is known).
Guidelines
Do
- Set max to today for analytics, where there is no data from the future.
- Offer the presets people use most; they cover most filters in one press.
- Show the applied range in words next to charts that use it.
Don’t
- Don’t apply a range while it is being picked; wait for Apply.
- Don’t use two separate date pickers for a range; the band and preview are the point.
In use
Related
- Date pickerA field that shows the chosen date and opens a month grid in a popover. Values are ISO date strings.
- Calendar gridOne month as an ARIA grid with the APG date-picker keys. Every picker in the kit is built on it.
- Date fieldA date typed in segments in the locale’s order, with a calendar button beside it. Paste "2026-10-21" to fill it.
- Screen-reader announcementsuseAnnounce makes screen readers say a result like “Moved to Done”, politely or, for urgent things, at once. Nothing shows on screen.
Source
The component’s own files, exactly as they run on this site.
/** * RangePicker: a start and end date, for report filters ("Last 30 days", "Oct 1 – 28, 2026"). * * const [range, setRange] = useState<DateRange | null>({ start: '2026-10-01', end: '2026-10-28' }) * <RangePicker label="Date range" value={range} onChange={setRange} max={todayISO()} /> * * API: `value` is { start, end } in ISO dates (both inclusive) or null; `onChange` fires only * on Apply. `presets` defaults to the analytics set (Today … This year); pass false to hide them. * Pass `name` to submit the range as an ISO 8601 interval ("2026-10-01/2026-10-28"). * * In the panel: presets on the left (a scrolling row on phones), the start and end as typed * fields above two months (one on phones), Cancel and Apply below. The first click sets the * start, the second the end (in either order); until then the range previews under the pointer * or the keyboard focus. Arrow keys cross from one month to the next. Each step is announced, * and so is the applied range ("Date range: Thursday, October 1 to Wednesday, October 28, 2026"). * * RangePanel is the panel on its own, for inline use. */import { useId, useRef, useState, useSyncExternalStore, type RefObject } from "react"import { cx } from "../../components/cx"