# Date range picker

> A button showing a date range, opening presets, typed dates and a calendar

Status: beta. Added in Components 2.16.0.

A button showing a date range, opening presets, typed dates and a calendar.

## Import

```tsx
import { DateRangePicker } from '@oztix/roadie-components/date-range-picker'
```

## Examples

### Default

Press the button and choose a preset, type a start and an end, or press two days in the calendar. The Start and End fields suggest single dates as you type, as [DateField](https://ticketsolutionsptyltd.github.io/roadie/components/date-field.md) does. They never suggest a range, since the presets hold those, and End suggests nothing before the start. A preset keeps its words, so "Last 30 days" saved today still means the last 30 days next week, and arrives in its one form: `{ period: 'week', offset: -1 }` as `'last-week'`. Typed or pressed dates become an absolute range, `{ start, end }`, with both ends included. The button shows the range in words, with the dates a relative range stands for beside it.

```tsx
function Default() {
  const [range, setRange] = useState({ direction: 'past', amount: 30, unit: 'day' })
  return (
    <div className='grid max-w-96 gap-4'>
      <DateRangePicker aria-label='Sales period' value={range} onValueChange={setRange} />
      <p className='text-sm text-subtle'>Value: {JSON.stringify(range)}</p>
    </div>
  )
}

render(<Default />)
```

### Variants

`commit='apply'` holds every change until Apply is pressed, and Cancel or `Escape` drops them. Use it when a change refetches a whole page, such as a dashboard's period. The default, `immediate`, changes the value with each choice, as a filter does.

```tsx
function Apply() {
  const [range, setRange] = useState('last-month')
  return (
    <div className='grid max-w-96 gap-4'>
      <DateRangePicker aria-label='Dashboard period' commit='apply' value={range} onValueChange={setRange} />
      <p className='text-sm text-subtle'>Value: {JSON.stringify(range)}</p>
    </div>
  )
}

render(<Apply />)
```

`granularity='minute'` adds an optional time to each end, read on the clock of `timeZone`. An end with a time becomes an instant with its offset, such as `'2026-11-02T09:00:00+11:00'`; an end with no time covers its whole day.

```tsx
function Presale() {
  const [range, setRange] = useState({ start: '2026-11-02T09:00:00+11:00', end: '2026-11-04' })
  return (
    <div className='grid max-w-96 gap-4'>
      <Field>
        <Field.Label>Presale, Sydney time</Field.Label>
        <DateRangePicker
          granularity='minute'
          timeZone='Australia/Sydney'
          presets={[]}
          value={range}
          onValueChange={setRange}
        />
      </Field>
      <p className='text-sm text-subtle'>Value: {JSON.stringify(range)}</p>
    </div>
  )
}

render(<Presale />)
```

### Emphasis

```tsx
<DateRangePicker aria-label='Normal' defaultValue='last-week' />
<DateRangePicker aria-label='Subtle' emphasis='subtle' defaultValue='last-week' />
<DateRangePicker aria-label='Subtler' emphasis='subtler' defaultValue='last-week' />
```

### Sizes

```tsx
<DateRangePicker aria-label='Small' size='sm' defaultValue='last-week' />
<DateRangePicker aria-label='Medium' size='md' defaultValue='last-week' />
<DateRangePicker aria-label='Large' size='lg' defaultValue='last-week' />
```

### States

`readOnly` shows the range with a lock and doesn't open, for a period the page has set. `disabled` turns the button off.

```tsx
{/* Default */}
<DateRangePicker aria-label='Default' defaultValue='last-week' />
{/* Invalid */}
<DateRangePicker aria-label='Invalid' defaultValue='last-week' invalid />
{/* Read only */}
<DateRangePicker aria-label='Read only' defaultValue='last-week' readOnly />
{/* Disabled */}
<DateRangePicker aria-label='Disabled' defaultValue='last-week' disabled />
```

### Composition

#### With Field

Wrap it in [Field](https://ticketsolutionsptyltd.github.io/roadie/components/field.md). The label names the button, the helper or error text describes it, and `required` adds “Required” to its description. Typed start and end dates explain their own errors inside the popup. The value is an object, so the picker holds it as state and submits nothing with a form; send it from your own handler.

```tsx
<Field className='max-w-80'>
  <Field.Label>Sales period</Field.Label>
  <DateRangePicker />
  <Field.HelperText>Orders placed in this period</Field.HelperText>
</Field>
```

#### With a comparison

For a dashboard, use [Dashboard period](https://ticketsolutionsptyltd.github.io/roadie/charts/dashboard-period.md): this picker with a Compare switch under the range, applied with it. Elsewhere, put a separate control beside the picker, such as a [Select](https://ticketsolutionsptyltd.github.io/roadie/components/select.md). Resolve both with `resolveDateRange` and `resolveComparison` from `@oztix/roadie-core/datetime`, passing the picker's `timeZone`, `weekStart` and `fiscalYearStart`, so they agree on what the words mean. A benchmark that isn't a date, such as similar venues, belongs in its own control.

```tsx
function Toolbar() {
  const [range, setRange] = useState({ direction: 'past', amount: 30, unit: 'day' })
  const [compare, setCompare] = useState('previous-period')
  return (
    <div className='flex flex-wrap items-center gap-2'>
      <DateRangePicker aria-label='Period' size='sm' commit='apply' className='w-fit' value={range} onValueChange={setRange} />
      <Select value={compare} onValueChange={setCompare}>
        <Select.Trigger size='sm' className='w-auto gap-2' aria-label='Compare to'>
          <Select.Value />
          <Select.Icon />
        </Select.Trigger>
        <Select.Content>
          <Select.Item value='previous-period'>Previous period</Select.Item>
          <Select.Item value='previous-year'>Same period last year</Select.Item>
        </Select.Content>
      </Select>
    </div>
  )
}

render(<Toolbar />)
```

### With presets

`presets` replaces the list, and `group` sets presets under a heading. Presets with no group come first, then each group in the order it first appears. Spread `dateRangePresets` to add to the defaults instead. A preset's `label` defaults to its value in words. When the value is a preset, fixed dates or relative, the button shows the preset's label with its dates, and so does the list on a phone. Here Xylophone Tide Festival adds its on-sale week, and a dashboard keeps the periods it reports on.

```tsx
<DateRangePicker
  aria-label='Festival period'
  defaultValue={{ start: '2026-11-02', end: '2026-11-08' }}
  presets={[
    ...dateRangePresets,
    { label: 'On-sale week', value: { start: '2026-11-02', end: '2026-11-08' }, group: 'Xylophone Tide Festival' }
  ]}
/>
<DateRangePicker
  aria-label='Dashboard period'
  commit='apply'
  presets={[
    { value: { direction: 'next', amount: 30, unit: 'day' } },
    { value: { direction: 'next', amount: 90, unit: 'day' } },
    { value: { direction: 'past', amount: 30, unit: 'day' } },
    { value: { direction: 'past', amount: 12, unit: 'month' } },
    { value: { period: 'year', offset: 0, fiscal: true } }
  ]}
/>
```

### With disabled days

`disabled` takes the same matchers as [Calendar](https://ticketsolutionsptyltd.github.io/roadie/components/calendar.md). Matched days are disabled in the calendar and refused when typed. `min` and `max` limit the length in days, both ends counted, and typed dates that break them say so. Presets aren't checked against either, so leave out presets that reach matched days or break the length. Here sales data starts on 1 July 2026.

```tsx
<DateRangePicker
  aria-label='Sales period'
  className='max-w-80'
  disabled={{ before: '2026-07-01' }}
  startMonth='2026-07-01'
/>
```

## Guidelines

### Keep relative ranges relative

**Do**

```tsx
saveView({ period: range })
```

Save the value as it comes. "Last 30 days" stays the last 30 days, and the button shows the dates it stands for today.

**Don’t**

```tsx
saveView({ period: resolveDateRange(range, options) })
```

Don't save resolved dates for a relative choice. The view stops moving with time.

### Let a phone show one thing at a time

**Do**

Keep presets as you'd show them on a wide screen. On a phone the picker puts them on their own Periods tab, each with its dates, and the calendar on another.

**Don’t**

Don't build a separate phone picker from chips or a second calendar. The drawer already gives each its own room.

### Apply when a change is expensive

**Do**

Use `commit='apply'` for a dashboard period, where every card refetches.

**Don’t**

Don't make a filter wait for Apply. Filters change the list as each choice is made.

## Accessibility

- The button is named after the picker and its range, such as "Choose dates, Last 30 days 8 Sept to 7 Oct 2026, Sales period", so pickers side by side stay apart. The range reads as the button shows it, so voice control users can say what they see. The popup is named "Choose dates, Sales period".
- Opening moves focus to the chosen preset, or to the calendar's first chosen day. In the drawer, the keyboard goes from Close to the tabs, then on the Calendar tab to Start and End, then into the scrolling body and its periods or months, then Clear and Apply. `Escape` closes and puts focus back on the button. With `commit='apply'` it drops the changes; in immediate mode, dates already chosen or committed stay.
- Presets are [Toggle](https://ticketsolutionsptyltd.github.io/roadie/components/toggle.md) buttons with `aria-pressed`, in groups named by their headings. Custom range is pressed when the value is no preset, and moves focus to the start date.
- Start and End are comboboxes, text inputs with suggestions, each with its own label and errors. Focused empty, they show hints with nothing highlighted. Once you type, the first suggestion is highlighted and `Enter` takes it, and `Escape` closes the list and keeps the text. In the drawer the list opens above it, so nothing clips it.
- With no suggestion highlighted, `Enter` commits typed text, and `Escape` puts back the last date. An end before the start says "Ends before it starts".
- The calendar works as in [Calendar](https://ticketsolutionsptyltd.github.io/roadie/components/calendar.md): arrow keys, `Page Up`, `Page Down`, `Home` and `End` move, and `Enter` or `Space` chooses. `Escape` after the first day of a range drops it and keeps the dates you had.
- On a screen narrower than 48rem, the popup is a full-height bottom [Drawer](https://ticketsolutionsptyltd.github.io/roadie/components/drawer.md) with the same name, titled with the picker's label. Under the title, its description sums up the dates chosen, such as "8 Sept to 7 Oct 2026 · 30 days", so it is read as the drawer opens.
- Two [Tabs](https://ticketsolutionsptyltd.github.io/roadie/components/tabs.md), Periods and Calendar, switch what the drawer shows. It opens on Periods when the value is a preset, and on Calendar otherwise. Periods is a [List](https://ticketsolutionsptyltd.github.io/roadie/components/list.md) with a row for each preset, its dates beside it, under its group's heading. The chosen row has `aria-current` and a check.
- Calendar has Start and End at the top, then months that scroll under a pinned row of weekdays, filling the width. The field marked in accent is the end the next tapped day sets: End while only a start is chosen, otherwise Start, which begins a new range. Tap End to move only the end, or Start to move only the start. An aimed end or start moves alone, even where it puts the end before the start or breaks `min` or `max`, and the End field says why.
- With `commit='apply'`, Clear and Apply stay in view at the drawer's foot. Close, `Escape`, a tap outside or a swipe down closes it, and with `commit='apply'` drops the changes, as Cancel does in the popover.

## API reference

### DateRangePicker

A button showing a date range, opening presets, typed dates and a calendar.

```tsx
import { DateRangePicker } from '@oztix/roadie-components/date-range-picker'
```

- `value`: `DateRangeValue | null`. The range: absolute `{ start, end }` with inclusive ISO dates (or date-times at `minute` granularity), or a relative range such as `'last-week'` or `{ direction: 'past', amount: 30, unit: 'day' }`. Pair with `onValueChange`.
- `defaultValue`: `DateRangeValue | null`. The range to start from when uncontrolled.
- `onValueChange`: `((value: DateRangeValue | null) => void)`. Called with a preset's value, relative and in its one form (`canonicalDateRange`), or with absolute dates when they are chosen or typed. Null when both dates are cleared.
- `presets`: `readonly DateRangePreset[]`. Defaults to `dateRangePresets`. Choices beside the calendar, grouped by their `group`. Pass your own, or add to `dateRangePresets`. An empty list hides it.
- `commit`: `"immediate" | "apply"`. Defaults to `'immediate'`. `immediate` changes the value with each choice. `apply` holds changes until Apply is pressed, for a period that refetches a whole page.
- `granularity`: `"day" | "minute"`. Defaults to `'day'`. `minute` adds an optional time to each end. An end with no time covers its whole day.
- `timeZone`: `string`. IANA zone whose calendar decides today and whose wall clock times are read on. Defaults to the viewer's.
- `today`: `string`. Today as an ISO date. Defaults to today in `timeZone`.
- `weekStart`: `number`. Defaults to `1`. First day of the week, for the calendar and "last week": 1 is Monday and 7 is Sunday.
- `fiscalYearStart`: `number`. Defaults to `7`. The month the financial year opens, for financial presets.
- `locale`: `string`. Defaults to `'en-AU'`.
- `disabled`: `boolean | CalendarMatchers`. `true` turns the picker off. Matchers, as on `Calendar`, disable those days in the calendar and refuse them when typed. Inherits `true` from `Field`.
- `invalid`: `boolean`. Marks the picker as invalid. Inherits from `Field` when omitted.
- `readOnly`: `boolean`. Shows the range with a lock, without letting it change.
- `required`: `boolean`. With `commit='apply'`, keeps Apply off while both dates are empty. Inherits from `Field` when omitted.
- `size`: `"sm" | "md" | "lg"`. Defaults to `'md'`.
- `emphasis`: `"normal" | "subtle" | "subtler"`. Defaults to `'normal'`.
- `placeholder`: `string`. Defaults to `'Choose dates'`. Shown while no range is chosen.
- `numberOfMonths`: `number`. Months side by side. Defaults to two on wide screens and one on narrow.
- `min`: `number`. The fewest days the range can span, both ends counted.
- `max`: `number`. The most days the range can span, both ends counted.
- `captionLayout`: `"label" | "dropdown"`. Defaults to `'label'`. `dropdown` swaps the calendar's month name for month and year selects.
- `startMonth`: `string`. The earliest month the calendar reaches, as any ISO date in it.
- `endMonth`: `string`. The latest month the calendar reaches, as any ISO date in it.
- `hourCycle`: `12 | 24`. Defaults to `12`. At `minute` granularity, `12` reads "7:30pm" and `24` reads "19:30".
- `minuteStep`: `number`. Defaults to `1`. At `minute` granularity, minutes per step of the time fields' arrow keys.
- `open`: `boolean`. Whether the popup is open. Pair with `onOpenChange`.
- `defaultOpen`: `boolean`. Whether the popup starts open when uncontrolled.
- `onOpenChange`: `((open: boolean) => void)`. Called when the popup opens or closes.

Docs updated 19 Oct 2026.
