Inputs
Date Range Picker
A two-month range calendar with preset shortcuts (Today / Last 7 days / Last 30 days / This month / Last month) — the standard filter for SaaS reports and dashboards.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/date-range-picker.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "DateRangePicker" component: a button
trigger that opens a non-portal popover holding a preset column and one or two
month grids. Icons from lucide-react, no date library — plain Date arithmetic
only.
Contract
- Export a forwardRef div (the positioning root) extending
React.HTMLAttributes<HTMLDivElement> (defaultValue/onChange omitted so they
can be re-typed).
- Range type: `{ from: Date | null; to: Date | null }`.
- Selection: controlled via value?: DateRange + onValueChange?(range), or
uncontrolled via defaultValue?: DateRange. `value !== undefined` decides the
mode, same convention as every other registry date control.
- presets?: { label: string; getRange: () => { from: Date; to: Date } }[] |
false. Default to five built-ins — Today, Last 7 days (today-6..today), Last
30 days (today-29..today), This month, Last month — each computing "today"
lazily inside its own getRange, never at module scope. `false` hides the
column entirely.
- numberOfMonths?: 1 | 2 (default 2), min?/max?: Date (inclusive, compared by
calendar day), disabledDate?: (d: Date) => boolean, weekStartsOn?: 0 | 1
(default 1), locale?: string (defaults to a FIXED "en-US" — never the
visitor's browser language, see Rendering below), align?: "start" | "end"
(which edge of the trigger the panel hangs from, default "start"),
placeholder?: string, disabled?: boolean.
Behavior
- Trigger is a real <button aria-haspopup="dialog" aria-expanded>; its label is
the formatted range ("Jul 1 – Jul 26, 2026", short month+day for `from` when
both ends share a year, full month+day+year for `to`) or the placeholder.
- The panel is position: absolute under the trigger's relative wrapper — no
portal, no Radix Popover. It opens on trigger click, closes on Escape
(focus returns to the trigger), and closes on any pointerdown outside both
the trigger and the panel (listener attached only while open).
- Selection is two-click: click 1 sets `{ from: day, to: null }` (always
clearing `to`, even if a full range already existed — clicking again starts
over); click 2 sets `to`, swapping the pair if the second click lands before
the first so `from` is always <= `to`. While `to` is null, hovering a day
previews the pending range live (from -> hovered day, reordered the same
way) with the exact same start/end/bar styling the committed range gets.
The panel does not auto-close after a two-click selection, so the user can
keep adjusting; it does close immediately after a preset click.
- Presets: clicking one commits `preset.getRange()` and closes the panel. A
preset is shown active (aria-current, accent background) when the current
value's from/to exactly match its computed range by calendar day — computed
only once "today" is resolved client-side (see Hydration below), never
during the render that must match server markup.
- Clear (bottom of the panel) resets the value to `{ from: null, to: null }`
without closing the panel.
- min/max/disabledDate all feed one `isDisabled(date)` predicate: disabled days
are unclickable, dimmed, and skipped by keyboard walking (bounded lookahead,
~2 months) rather than ever receiving focus.
- Two month grids (when numberOfMonths is 2) share one pair of
previous/next-month buttons that shift both grids together by one month;
each grid keeps its own weekday header and caption, captions live in one
aria-live="polite" region so paging announces the new month(s).
- Keyboard on the grids (identical semantics to mini-calendar, generalized
across however many grids are visible): Left/Right ±1 day, Up/Down ±1 week,
Home/End the first/last enabled day of that week, PageUp/PageDown the same
day one month away (clamped), Enter/Space commits the focused day through
the same two-click state machine as a mouse click. A move that lands outside
the currently visible month(s) pages the view to it; a shared "focus
pending" ref plus an effect restores real DOM focus after the grids
re-render, because a month swap re-creates every cell.
- Outside-month days are rendered as blank placeholder cells (not shown, not
clickable) rather than the neighbouring month's numbers — with two grids on
screen, showing them would put the same calendar day in view twice.
Rendering & styling
- Semantic tokens only: bg-background/bg-popover for surfaces, bg-primary
text-primary-foreground for the range's start/end day (filled circle),
bg-primary/10 for the connecting bar under the days in between (rounded-l-full
on the start cell's wrapper, rounded-r-full on the end cell's), ring-1
ring-primary for today when it isn't selected, bg-accent for the active
preset and for hover states, text-muted-foreground/50 + pointer-events-none
for disabled days. cn() merges consumer className throughout.
- Panel: rounded-xl border bg-popover p-4 shadow-md, max-w-[calc(100vw-2rem)]
with overflow-x-auto so it never pushes the page wider than the viewport;
align="start" | "end" picks left-0 vs right-0.
- Focus rings via focus-visible:ring-2 focus-visible:ring-ring on every
interactive element (trigger, nav buttons, day cells, preset buttons, Clear).
Hydration safety (read before changing anything date-related)
- "Today" — used for the today ring and for deciding which preset looks
active — comes from useSyncExternalStore whose server snapshot is null: SSR
and the first paint render with no today concept at all, and the real value
swaps in right after hydration. The literal `new Date()` call only ever
lives inside that store's snapshot function and inside each preset's
getRange — never inside the component's render body — so nothing impure
runs during the render that has to match server-rendered markup.
- locale always defaults to a fixed "en-US" string; every Intl.DateTimeFormat
is constructed with an explicit locale, never `undefined`, so the server and
the visitor's browser always format identical strings.
- The initial visible month is seeded once, lazily, from
value/defaultValue's `from` (falling back to the clock only when neither is
given) — pass value or defaultValue when you server-render and want
byte-identical first markup.
Customization levers
- Preset list: swap the five defaults for domain-specific ones (fiscal
quarter, "Year to date", a saved-segment list from the backend) — the
contract is just a label + a pure `() => {from, to}` factory, so anything
that returns a day pair works; pass `presets={false}` to drop the column and
widen the panel to just the grid(s).
- Density / month count: numberOfMonths 1 for a compact toolbar filter, 2 for
a full side-by-side comparison view — nothing else about the component
changes shape.
- Range bar intensity: bg-primary/10 is the only knob for the connecting bar;
raise it for more contrast on busy backgrounds, keep the start/end caps at
solid bg-primary regardless so the two endpoints stay the clearest thing on
the grid.
- Bounds: min/max are the cheap guardrails (reporting windows, subscription
lifetime); disabledDate is the expensive one (blackout dates, a Set of
closed days) and runs per rendered cell, so keep it O(1) — precompute a
Set<string> of keys rather than scanning an array inside it.
- Placement: align="end" flips the panel to hang from the trigger's right
edge for filters that sit at the right side of a toolbar.Concepts
- Two-click range, hover-previewed — the first click always clears
to(even restarting from a completed range), so the state machine has exactly one "pending" shape:fromset,tonull; hovering during that state previews the exact range a second click would commit. - Presets are a shortcut to the same commit, not a separate mode — clicking one calls the identical
onValueChangethe grid does, then closes the panel; "active" highlighting is just a by-day equality check against the current value. - No portal, on purpose — the panel is a plain
position: absolutesibling under aposition: relativeroot, so it inherits stacking/scroll context from wherever the picker is mounted instead of escaping to a portal root; click-outside and Escape close it manually. - One nav, many grids — numberOfMonths controls how many month grids render from the same
baseMonthstate; the previous/next buttons and the roving-tabindex focus model treat all visible grids as one continuous day timeline. - Local-date comparison — every date is built and compared from local year/month/day parts, never via UTC conversion, so "the same day" always means the day the visitor sees on their wall calendar.
- Server snapshot null for today — the server can't know the visitor's calendar day, so both the today ring and the "is a preset currently active" check stay off until hydration resolves the real date, trading one frame for zero mismatched markup.
Combobox
A searchable dropdown selector — single or multi-select, grouped, creatable, with keyboard-driven filtering.
Time Picker
A segmented hour:minute input — type digits or arrow-step, plus a clock-button dropdown of minuteStep candidates — always normalized to 24-hour "HH:mm" regardless of display format.