Skip to documentation content

Date Range Picker

Pick start and end dates for forms, filters, bookings, and reporting windows.

date-range-picker-demo

Overview

Use Date Range Picker when people need to set two dates together. Expense reports, booking searches, analytics windows, and invoice lists all reduce to the same job: pick a start, pick an end, move on. The trigger shows the current range as text, quick ranges can shorten the task to two clicks, and the whole flow works with a keyboard alone.

The component stays out of the way until a calendar is needed. Opened empty, it focuses today. Opened with a saved range, it lands on that range so both endpoints are visible before any change happens. Closing always returns focus to the trigger.

HonestUI does not ship a standalone Calendar yet. The month grids live inside this component and are built on react-day-picker, so grid behavior, keyboard movement, and gridcell semantics come from a maintained library rather than hand-rolled date math. If a public Calendar appears later, the shared parts will move there.

Use Field when the picker belongs to a labeled form. Use plain Button and text markup when you only need to show dates.

Anatomy

DateRangePicker
  • TriggerCalendar icon, current range or placeholder text, and clear action
  • PopoverOne panel holding every editing control
    • PresetsOptional quick ranges such as Last 7 days
    • CalendarsPrevious and next month buttons plus up to three month grids
    • FooterClear, Cancel, and Apply in confirm mode

DateRangePicker renders all regions. It also exports DateRangePresets and the internal DateRangeCalendar, so a team that needs a pinned filter bar can reuse them, though most applications install the single root component and stop there.

The trigger is a real button, not an input styled to look clickable. Screen readers get complete names: pressing Enter announces nothing new, activating a preset commits its dates, and the clear control is named "Clear date range."

Behavior

Selection takes at most three actions: open, choose the start, choose the end. Choosing a preset drops it to two. Three details keep those actions forgiving:

  1. A second click earlier than the first swaps the two days into the right order. Picking May 20 then May 12 stores May 12 through May 20, no error shown.
  2. Clicking the same day twice creates a one-day range, which counts as valid everywhere a longer range does.
  3. When a complete range exists, the next click starts a fresh one at that date. Nobody has to press Clear between reports.

Without confirmMode, a completed range or preset commits immediately and closes the popover, which keeps common work fast. With confirmMode, edits land in a temporary value. Apply commits it, Cancel restores the last committed range, and closing by Escape or an outside click cancels exactly like Cancel does. Expensive queries stay untouched by abandoned drafts.

Clear lives in two places on purpose. The trigger icon clears without opening anything, hidden when the value is empty, clearing is disabled, or the field is required. Inside confirm mode a footer Clear empties the draft while staying open, so building a replacement never loses your place.

Month navigation respects hard limits. Previous stops at minDate's month and Next stops at maxDate's month; unavailable years are unreachable instead of browsable. Dates outside limits, weekends blackouts, or anything isDateDisabled rejects render muted, cannot take clicks, and are skipped by keyboard selection attempts.

On screens under roughly 48rem wide the picker drops to a single month and moves presets above the calendars instead of squeezing two grids into a phone.

Accessibility

Keyboard work mirrors pointer work point for point:

KeyAction
Enter or SpaceOpen the picker, select the focused date
Arrow keysMove one day, or one week for Up and Down
Home / EndFirst or last day of the visible week
Page Up / Page DownPrevious or next month
TabMove between presets, buttons, and the grid
EscapeClose and return focus to the trigger

Grid cells use a roving tab stop, so Tab crosses to presets and footers without stepping through sixty-two buttons. Each cell reads as its full date plus role: "Tuesday, May 12, 2026, start of range," "today" gets spoken by label overrides, and unavailable cells announce nothing beyond their muted look because they refuse activation.

Give the trigger an accessible name through your Field label: put id="report-period" on the picker and reference it with <FieldLabel htmlFor="report-period">. Applications without a visible label can pass aria-label directly.

State changes announce through a polite live region: "Start date selected, May 12, 2026. Choose an end date." followed by "Date range selected, May 12 through May 25, 2026." Override every string through labels when the English defaults do not fit.

Color never carries status alone. Selection pairs solid accent fills with font-weight changes and spoke roles, disabled dates keep readable text rather than vanishing, and the popover honors reduced-motion settings by fading instead of scaling.

Installation

npx honestui@latest add date-range-picker

Usage

The value shape names its ends so call sites read cleanly:

import { DateRangePicker } from "@/components/ui/date-range-picker/date-range-picker"

<DateRangePicker />

Uncontrolled use starts here. Add a controlled value the moment an application needs to reset or inspect the range:

const [range, setRange] = useState<DateRange>()

<DateRangePicker value={range} onValueChange={setRange} />

onValueChange fires only with complete ranges, and passes undefined when cleared. Combine both guarantees when reading:

if (range?.from && range.to) {
  fetchReport(range.from, range.to)
}

In a form

Wrap the picker in Field for label, description, and error slots. Setting required hides the clear action; invalid draws the danger border while FieldError explains recovery.

<Field>
  <FieldLabel htmlFor="renewal-window">Renewal window</FieldLabel>
  <DateRangePicker id="renewal-window" required invalid />
  <FieldHelperSlot>
    <FieldError>Choose renewal dates on or after today.</FieldError>
  </FieldHelperSlot>
</Field>

Don't do this

Treating the first pick as committed

// Bad. An incomplete range reaches neither branch reliably.
onValueChange={(range) => {
  const from = range?.from ?? range?.to
  applyFilter(from)
}}
// Good. Only complete ranges mean anything downstream.
onValueChange={(range) => {
  if (range?.from && range.to) {
    applyFilter(range.from, range.to)
  }
}}

A person who opens the picker, taps one day, then presses Escape produces no callback at all. Code assuming immediate commits fires either too early or never.

Querying on every edit instead of using confirm mode

Live dashboards with slow aggregation endpoints should enable confirmMode. Outside clicks cancel there, so users who change their mind cost nothing; without it, every stray tap refetches the report.

Examples

Default

Two months, hover preview while the end date is pending, automatic close on completion, and a readout below showing the exact value committed.

date-range-picker-default

Quick ranges

Optional presets sit beside the calendars. Selecting one commits immediately and matches itself against the current value, highlighted whenever they agree.

date-range-picker-presets

Confirm before applying

Edits stay temporary across navigations and preset picks. Cancel, Escape, and outside clicks restore the committed window; Apply is disabled until both endpoints exist.

date-range-picker-confirm

Min, max, and disabled dates

Navigation clamps to reachable months, weekend blackout dates mute themselves, and crossing an unavailable date resets selection back to your last click.

date-range-picker-limits

Controlled state

The parent owns the value and sees every commit, including clears.

date-range-picker-controlled

Form fields and states

Required with a description, invalid with a recovery message, and a read-only period that displays without opening the calendar.

date-range-picker-form

Single month

One grid for toolbars and side panels, identical behavior otherwise. Narrow viewports fall back to this automatically.

date-range-picker-single-month

API reference

DateRangePicker

PropTypeDefaultDescription
valueDateRange | undefinedNoneControlled range.
defaultValueDateRangeNoneInitial uncontrolled range.
onValueChange(range: DateRange | undefined) => voidNoneCalled when the committed range changes, including clears.
openbooleanNoneControlled popover state.
defaultOpenbooleanfalseInitial popover state.
onOpenChange(open: boolean) => voidNonePopover open-state callback.
minDateDateNoneEarliest selectable date; bounds navigation.
maxDateDateNoneLatest selectable date; bounds navigation.
isDateDisabled(date: Date) => booleanNoneExtra unavailable-day logic.
numberOfMonthsnumber2Visible months. Narrows to 1 under 48rem viewports.
localedate-fns Localeapp localeFormats trigger text, captions, weekday heads, and day names.
weekStartsOn06locale valueOverrides the first day of the week.
showOutsideDaysbooleantrueShows adjacent-month dates.
presetsDateRangePreset[]NoneQuick ranges rendered beside the calendars.
confirmModebooleanfalseRequires Apply before committing.
clearablebooleantrueEnables the clear action.
disabledbooleanfalseBlocks opening and selecting.
readOnlybooleanfalseDisplays the value without editing.
requiredbooleanfalseHides the clear action.
invalidbooleanfalseApplies the danger border treatment.
placeholderstring"Select date range"Trigger text when empty.
formatRange(range: DateRange) => stringlocale formatterReplaces visible formatting for complete ranges.
labelsPartial labels objectEnglish defaultsOverrides strings listed below.

Any other button prop (id, aria-label, className) forwards to the trigger.

DateRange holds from?: Date and to?: Date; missing ends only ever appear in draft UI, never in committed callbacks.

DateRangePreset

MemberTypeDescription
labelstringText shown in the preset column.
getValue() => DateRangeRuns at selection so relative ranges stay correct overnight.

getDateRangePresets() returns Today, Yesterday, Last 7 days, Last 30 days, This month, and Last month as a starting list. Map over it to translate labels or replace entries; nothing preset-shaped is baked into the component.

Labels

KeyDefaultUsed for
clearActionClear date rangeAccessible name of the trigger clear button
selectEndDateSelect end datePlaceholder half of a partial trigger
previousMonth / nextMonthPrevious month / Next monthNavigation button names
presetsGroupQuick rangesGroup label around presets
startSelected / rangeSelected / clearedformatted functionsLive-region announcements

Formatting helpers exported alongside: formatDateLabel(date, locale?) matches the built-in trigger style when applications print the same range elsewhere.