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…

"use client"

import * as React from "react"
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"

/** Always six rows so the panel keeps one height across months. */
const WEEK_ROWS = 6
const DAYS_PER_WEEK = 7
/** How far arrow keys keep looking for an enabled day before giving up. */
const MAX_SKIP = 62
/** Any Sunday — only ever used to render weekday names through Intl. */
const WEEKDAY_SEED = new Date(2024, 0, 7)

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/date-range-picker.json

Prompt

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: from set, to null; 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 onValueChange the 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: absolute sibling under a position: relative root, 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 baseMonth state; 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.

On This Page