Date Range Picker
The time-period filter a dashboard is read through — a trigger showing the current range, relative presets, and two labelled calendars for a custom span.
No range selected
Usage#
import { DateRangePicker, type DateRange } from "neelam-ui";
const [range, setRange] = useState<DateRange | undefined>();
<DateRangePicker
value={range}
onValueChange={setRange}
disabled={(date) => date > new Date()}
/>DateRange is an inclusive span: from and to may be the same day.
Assembled from what already exists#
Almost none of this is new machinery:
Popoverfor the panel — and with itEscape, outside-click dismissal, and top-layer rendering, none of it reimplemented.- Two
Calendars for the custom range. RadioGroupfor the presets.
That last one matters most. The presets are mutually exclusive options, which is
exactly a radio group — so making them real <input type="radio">s means
arrow-key navigation, mutual exclusivity, and the "3 of 5" announcement all come
from the browser rather than from a hand-rolled listbox with its own roving
focus.
Two calendars, not one#
A single grid with range-painting has to express "click once for the start, again for the end" through hover preview alone — invisible to keyboard and touch users, and unannounced to assistive tech.
Two labelled start/end grids make the same selection unambiguous on every input
mode, and let Calendar stay the single-date component it already is. The end
grid disables everything before the start, and vice versa, so an inverted
range cannot be produced at all.
Presets#
defaultDateRangePresets covers what a dashboard filter usually wants. Replace
them with presets, or pass [] for a custom-range-only picker.
A preset stores a function, not a range, so "Last 7 days" is recomputed at selection time:
{ label: "Last 7 days", getValue: () => ({ from: addDays(new Date(), -6), to: new Date() }) }Why a function, not a value
A stored range would silently mean last week in a tab left open overnight. Recomputing on selection is what keeps a relative preset relative.
Sizing the trigger#
Use the size prop — "sm", "md" (default), or "lg" — rather than a
className height.
A className height will not replace the default
The trigger already carries buttonVariants({ size: "md" }). A plain
className="h-8" does not replace those classes, it sits alongside the
existing h-10 — two conflicting utilities on one element, with whichever
Tailwind emits later in its generated stylesheet silently winning.
Keyboard#
| Key | Behaviour |
|---|---|
| EnterSpace | Opens the panel from the trigger. |
| ↑↓ | Moves between presets — native radio-group behaviour. |
| Tab | Moves from the presets into the start grid, then the end grid. |
| ←→↑↓ | Moves within a calendar grid, once focus is inside one. |
| Escape | Closes the panel and returns focus to the trigger. |
Everything is reachable without a pointer — which is the point of choosing a radio group and two grids over a hover-driven single grid.
Accessibility#
- The trigger's accessible name comes from
label(default"Date range"), and the formatted range is its visible text. - Presets are real radio inputs in a labelled group, so their count and position are announced by the platform.
- Each calendar is labelled as the start or end grid, so it is never ambiguous which endpoint is being set.
- Invalid ranges are prevented structurally by disabling dates, rather than reported as an error after the fact — the better of the two, since there is no error state to announce or recover from.
disabledblocks dates in both grids, so a rule like "no future dates" is enforced on either endpoint.
API reference#
DateRangePicker#
| Prop | Type | Default |
|---|---|---|
className | string | — |
defaultValueInitial range when uncontrolled. | DateRange | — |
disabledMarks dates unselectable in the custom-range calendars, e.g. `(date) => date > new Date()` to block the future. | ((date: Date) => boolean) | — |
formatValueOverrides how the selected range is written on the trigger. | ((range: DateRange) => string) | — |
labelThe trigger's accessible name. Defaults to `"Date range"`. | string | Date range |
onValueChange | ((range: DateRange) => void) | — |
placeholderRendered on the trigger when nothing is selected yet. | ReactNode | Pick a date range |
presetsReplaces the preset rows. Pass `[]` for a custom-range-only picker. | DateRangePreset[] | [
{ label: "Last 7 days", getRange: () => ({ from: addDays(stripTime(new Date()), -6), to: stripTime(new Date()) }) },
{ label: "Last 30 days", getRange: () => ({ from: addDays(stripTime(new Date()), -29), to: stripTime(new Date()) }) },
{ label: "Last 90 days", getRange: () => ({ from: addDays(stripTime(new Date()), -89), to: stripTime(new Date()) }) },
{
label: "Month to date",
getRange: () => {
const today = stripTime(new Date());
return { from: new Date(today.getFullYear(), today.getMonth(), 1), to: today };
},
},
{
label: "Year to date",
getRange: () => {
const today = stripTime(new Date());
return { from: new Date(today.getFullYear(), 0, 1), to: today };
},
},
] |
sizeThe trigger button's size, same vocabulary as `Button`'s own (minus `"icon"`, which doesn't fit a trigger that always renders a label). Defaults to `"md"`, matching `Button`'s own default. Pick this instead of trying to shrink the trigger via `className`: the trigger already carries `buttonVariants({ size: "md" })`, so a plain `className="h-8 ..."` override doesn't replace those classes, it just adds `h-8` alongside the existing `h-10` — two conflicting utility classes on the same element, with whichever Tailwind happens to emit later in its generated stylesheet silently winning. | enum | md |
valueThe selected range. | DateRange | — |
DateRange#
| Prop | Type | Default |
|---|---|---|
fromrequired | Date | — |
torequired | Date | — |
DateRangePreset#
| Prop | Type | Default |
|---|---|---|
getRangerequiredComputed on selection rather than stored, so "Last 7 days" stays relative to today across a long-lived session. | () => DateRange | — |
labelrequiredShown in the preset list, and matched against to decide which row is selected. | string | — |