Date range picker in React with presets, two months and keyboard
Build a React date range picker with Tailwind: native date inputs first, then a two month calendar in a popover with presets, arrow keys and ISO string values.
By Aman Kaushik, MiniDev24 min read
The simplest date range picker in React is two native <input type="date"> fields that hold ISO strings, and that is exactly what the free MiniDev UI DateRangePicker is. When the product needs presets like "Last 30 days", two months side by side and arrow key navigation, you keep the same { from, to } value and put a calendar grid inside a Popover, which is what this guide builds.
Everything below uses React 19, Tailwind v4 and the kit's tokens. There is no date library: the grid needs about thirty lines of date math, and all of it is in this page. The result is one value model that works with the native inputs on phones and the calendar on desktop, so a form can switch between them without changing its state.
Start with the native inputs
npx shadcn@latest add @minidev/date-range-picker
npx shadcn@latest add @minidev/popover
npx shadcn@latest add @minidev/button
DateRangePicker renders two DatePicker inputs with a "to" between them. It is controlled through value and onChange, and it keeps its own state when value is omitted:
const [range, setRange] = React.useState<{ from?: string; to?: string }>({})
<DateRangePicker value={range} onChange={setRange} fromId="report-from" toId="report-to" />
- Values are plain
YYYY-MM-DDstrings, which is what the native input reads and writes. Keep that format all the way to the server and you never serialize aDate. fromIdandtoIdlet a visible label or aFormFieldpoint at each input. The inputs also carry fixedaria-labels, "Start date" and "End date".disableddisables both inputs. There is nomin,maxor ordering check: the user can type an end before the start, so validate the pair yourself (see validation).- On phones the browser opens its own picker, which is the best experience you can get there. Keep this component as the mobile path even after you build the calendar.
The kit also ships CalendarMonth, but read it before you reach for it: it is a static preview that draws thirty numbered buttons under a hard coded "September 2026" heading and reports the clicked number. It is useful as a visual reference for the grid styling, not as a date aware calendar, which is why the sections below write a real one.
Date math without a library
Four helpers are enough. The important one is parseIso, which builds a local date from the string. new Date("2026-10-14") parses as UTC midnight and shows as October 13 for everyone west of Greenwich, which is the most common date picker bug there is.
export type Range = { from?: string; to?: string }
export function parseIso(s: string): Date {
const [y, m, d] = s.split("-").map(Number)
return new Date(y, m - 1, d)
}
export function toIso(d: Date): string {
const p = (n: number) => String(n).padStart(2, "0")
return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate())
}
export const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
export const addMonths = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth() + n, 1)
export const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1)
export const sameDay = (a: Date, b: Date) => toIso(a) === toIso(b)
/** Weeks of a month, Monday first. Cells outside the month are null. */
export function monthWeeks(month: Date): (Date | null)[][] {
const first = startOfMonth(month)
const lead = (first.getDay() + 6) % 7
const count = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate()
const cells: (Date | null)[] = Array.from({ length: lead }, () => null)
for (let i = 1; i <= count; i++) cells.push(new Date(month.getFullYear(), month.getMonth(), i))
while (cells.length % 7) cells.push(null)
const weeks: (Date | null)[][] = []
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7))
return weeks
}
Day arithmetic goes through the Date constructor on purpose. Passing day 32 of a month rolls into the next month and day 0 rolls back, and the constructor handles leap years and daylight saving changes, where adding 86,400,000 milliseconds does not.
A month grid that is a real grid
Each month is a role="grid" with one row per week and a button per day. Only one button in the whole picker has tabIndex={0} (the focused day); the rest are -1, so Tab leaves the calendar in one press and the arrow keys move inside it. That is the roving tabindex pattern from the ARIA grid spec, and it is what native date pickers on macOS and Windows do too.
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { monthWeeks, sameDay, toIso } from "@/lib/dates"
type MonthGridProps = {
month: Date
from?: Date
to?: Date
/** Day under the pointer while only `from` is chosen, drawn as a provisional end. */
preview?: Date
focused: Date
min?: Date
max?: Date
onFocus: (d: Date) => void
onHover: (d: Date | undefined) => void
onPick: (d: Date) => void
}
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
const monthName = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric" })
const dayName = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })
export function MonthGrid({ month, from, to, preview, focused, min, max, onFocus, onHover, onPick }: MonthGridProps) {
const end = to ?? (from && preview && preview > from ? preview : undefined)
const inRange = (d: Date) => Boolean(from && end && d >= from && d <= end)
const isDisabled = (d: Date) => Boolean((min && d < min) || (max && d > max))
return (
<div role="grid" aria-label={monthName.format(month)} className="w-64">
<p aria-hidden className="mb-2 text-center text-sm font-medium text-fg">{monthName.format(month)}</p>
<div role="row" className="grid grid-cols-7 text-center text-[11px] text-fg-subtle">
{WEEKDAYS.map((w) => <span key={w} role="columnheader" className="py-1">{w}</span>)}
</div>
{monthWeeks(month).map((week, wi) => (
<div key={wi} role="row" className="grid grid-cols-7 gap-y-0.5">
{week.map((d, i) =>
d ? (
<button
key={i}
type="button"
role="gridcell"
aria-label={dayName.format(d)}
aria-selected={Boolean(from && sameDay(d, from)) || Boolean(to && sameDay(d, to)) || undefined}
aria-disabled={isDisabled(d) || undefined}
tabIndex={sameDay(d, focused) ? 0 : -1}
data-day={toIso(d)}
onFocus={() => onFocus(d)}
onMouseEnter={() => onHover(d)}
onMouseLeave={() => onHover(undefined)}
onClick={() => { if (!isDisabled(d)) onPick(d) }}
className={cn(
"h-8 w-full text-sm text-fg outline-none transition-colors duration-[70ms] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent",
inRange(d) ? "bg-accent-soft" : "rounded-lg hover:bg-sunken",
from && sameDay(d, from) && "rounded-l-lg bg-accent text-on-accent hover:bg-accent",
end && sameDay(d, end) && "rounded-r-lg bg-accent text-on-accent hover:bg-accent",
isDisabled(d) && "text-fg-subtle line-through hover:bg-transparent"
)}
>
{d.getDate()}
</button>
) : (
<span key={i} role="gridcell" aria-hidden />
)
)}
</div>
))}
</div>
)
}
Each day button has a full aria-label ("Wednesday, October 14, 2026"), so a screen reader never has to infer the month from a bare "14". Disabled days use aria-disabled rather than disabled so they stay in the arrow key path and can be announced as unavailable instead of silently skipped.
Two months in a Popover with presets
The picker owns the visible month, the focused day, the hover preview and a draft start date for the click in progress. The committed value stays in the parent as { from, to } strings, the same shape DateRangePicker uses. PopoverContent from the kit positions and animates the panel; w-auto overrides its default w-72 so two grids fit.
"use client"
import * as React from "react"
import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { MonthGrid } from "@/components/month-grid"
import { addDays, addMonths, parseIso, startOfMonth, toIso, type Range } from "@/lib/dates"
const today = () => new Date(new Date().setHours(0, 0, 0, 0))
const PRESETS: { label: string; range: () => Range }[] = [
{ label: "Last 7 days", range: () => ({ from: toIso(addDays(today(), -6)), to: toIso(today()) }) },
{ label: "Last 30 days", range: () => ({ from: toIso(addDays(today(), -29)), to: toIso(today()) }) },
{ label: "This month", range: () => ({ from: toIso(startOfMonth(today())), to: toIso(today()) }) },
{ label: "Last month", range: () => ({ from: toIso(addMonths(today(), -1)), to: toIso(addDays(startOfMonth(today()), -1)) }) },
]
const short = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" })
export function RangePicker({ value, onChange, max }: { value: Range; onChange: (r: Range) => void; max?: Date }) {
const [open, setOpen] = React.useState(false)
const from = value.from ? parseIso(value.from) : undefined
const to = value.to ? parseIso(value.to) : undefined
const [month, setMonth] = React.useState(() => startOfMonth(from ?? today()))
const [focused, setFocused] = React.useState<Date>(from ?? today())
const [hover, setHover] = React.useState<Date>()
const [draft, setDraft] = React.useState<Date>()
const grids = React.useRef<HTMLDivElement>(null)
const pick = (d: Date) => {
if (!draft) {
setDraft(d)
onChange({ from: toIso(d), to: undefined })
return
}
const [a, b] = d < draft ? [d, draft] : [draft, d]
onChange({ from: toIso(a), to: toIso(b) })
setDraft(undefined)
setOpen(false)
}
/** Move the keyboard cursor, scroll the months if needed, then focus the new day button. */
const moveFocus = (next: Date) => {
setFocused(next)
const first = startOfMonth(month)
const last = addMonths(first, 2)
if (next < first) setMonth(startOfMonth(next))
else if (next >= last) setMonth(addMonths(startOfMonth(next), -1))
requestAnimationFrame(() => grids.current?.querySelector<HTMLElement>(`[data-day="${toIso(next)}"]`)?.focus())
}
const onKeyDown = (e: React.KeyboardEvent) => {
const map: Record<string, () => Date> = {
ArrowLeft: () => addDays(focused, -1),
ArrowRight: () => addDays(focused, 1),
ArrowUp: () => addDays(focused, -7),
ArrowDown: () => addDays(focused, 7),
Home: () => addDays(focused, -((focused.getDay() + 6) % 7)),
End: () => addDays(focused, 6 - ((focused.getDay() + 6) % 7)),
PageUp: () => new Date(focused.getFullYear(), focused.getMonth() - 1, focused.getDate()),
PageDown: () => new Date(focused.getFullYear(), focused.getMonth() + 1, focused.getDate()),
}
const go = map[e.key]
if (go) { e.preventDefault(); moveFocus(go()); return }
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pick(focused) }
}
const label = from && to ? short.format(from) + " to " + short.format(to) : from ? short.format(from) + " to …" : "Pick a date range"
const shared = { from, to, preview: draft ? hover ?? focused : undefined, focused, max, onFocus: setFocused, onHover: setHover, onPick: pick }
return (
<Popover open={open} onOpenChange={(o) => { setOpen(o); if (!o) setDraft(undefined) }}>
<PopoverTrigger render={<Button variant="outline" />}>
<CalendarIcon />
{label}
</PopoverTrigger>
<PopoverContent align="start" className="w-auto flex-row gap-4 p-4">
<ul className="flex w-32 flex-col gap-0.5 border-r border-border pr-3">
{PRESETS.map((p) => (
<li key={p.label}>
<Button variant="ghost" size="sm" className="w-full justify-start" onClick={() => { onChange(p.range()); setOpen(false) }}>
{p.label}
</Button>
</li>
))}
</ul>
<div>
<div className="mb-1 flex items-center justify-between">
<Button variant="ghost" size="icon-sm" aria-label="Previous month" onClick={() => setMonth((m) => addMonths(m, -1))}><ChevronLeftIcon /></Button>
<Button variant="ghost" size="icon-sm" aria-label="Next month" onClick={() => setMonth((m) => addMonths(m, 1))}><ChevronRightIcon /></Button>
</div>
<div ref={grids} onKeyDown={onKeyDown} className="flex gap-6">
<MonthGrid month={month} {...shared} />
<MonthGrid month={addMonths(month, 1)} {...shared} />
</div>
<p role="status" className="mt-3 text-xs text-fg-muted">
{draft ? "Start " + short.format(draft) + ". Choose an end date." : "Choose a start date."}
</p>
</div>
</PopoverContent>
</Popover>
)
}
- The first click sets
draftand reports{ from, to: undefined }immediately, so a parent that renders the native inputs alongside stays in sync. The second click orders the pair, so clicking the end first is fine. previewis the hovered day, or the keyboard cursor while a draft exists, which is how keyboard users see the provisional range as they arrow around.- Closing the popover clears
draftbut keepsfrom. If you would rather discard a half picked range, callonChange({})in the same branch. - Presets commit and close in one step. "Last 30 days" means 30 calendar days including today, which is worth stating in your docs because analytics tools disagree about it.
Keyboard support
| Key | Action |
|---|---|
| Tab | Moves from the trigger into the presets, then to the month buttons, then to the single focusable day |
| Arrow keys | Move the cursor by one day or one week; the grid scrolls when the cursor leaves the two visible months |
| Home and End | Jump to Monday and Sunday of the current week |
| PageUp and PageDown | Same day in the previous or next month |
| Enter or Space | Pick the focused day as start, then as end |
| Escape | Closes the popover and returns focus to the trigger (handled by Base UI) |
Focus is moved in a requestAnimationFrame because the target button may not exist until React has rendered the new month. Reading the day back from data-day keeps the grid free of refs per cell.
Validation and the value model
- Treat
fromandtoas calendar dates, not instants. Compare them as strings:YYYY-MM-DDsorts correctly, sofrom <= toworks without parsing. - When the native inputs produce
from > to, swap them inonChangerather than showing an error; nobody intends an inverted range. - Pass
max={today()}for reports andminfor bookings. The grid renders those days witharia-disabledand ignores clicks on them. - On the server, parse the strings with the same local date rule or treat them as dates in the user's stored time zone. Converting through
Date.toISOString()on either side reintroduces the UTC shift. - Limit the span if the backend cannot handle a year of rows: check
dayDiffwhen the second date is picked and show arole="alert"message instead of committing.
Phones: fall back to the native inputs
A 640 pixel wide popover does not fit a phone, and the platform picker is better there anyway. Switch on pointer type rather than viewport width, so a tablet with a keyboard still gets the calendar:
function useCoarsePointer() {
return React.useSyncExternalStore(
(cb) => { const q = matchMedia("(pointer: coarse)"); q.addEventListener("change", cb); return () => q.removeEventListener("change", cb) },
() => matchMedia("(pointer: coarse)").matches,
() => false
)
}
const coarse = useCoarsePointer()
return coarse ? <DateRangePicker value={range} onChange={setRange} /> : <RangePicker value={range} onChange={setRange} />
useSyncExternalStore with a false server snapshot renders the calendar during SSR and swaps after hydration without a mismatch warning. See hydration for why reading matchMedia during render would break.
Accessibility notes
- The trigger shows the current range in text, so the value is readable without opening anything. Add
aria-describedbyto the trigger pointing at the field's label if it sits in a form row. - The
role="status"line under the grids announces "Start October 1. Choose an end date." when the first day is picked. Without it a screen reader user has no confirmation that the click registered. - Selected days carry
aria-selected; the range in between is only colour. If the range matters to assistive technology, add the dates to the status line. - The accent on selected days uses
text-on-accent, which the kit tunes for WCAG contrast in both themes. The lighterbg-accent-softrange fill is decorative and does not need to pass. Popoverfrom Base UI handles Escape, outside clicks and returning focus to the trigger, so the picker does not reimplement any overlay behaviour. See ARIA for what the grid roles promise.
Pitfalls
- Parsing ISO strings with `new Date(string)`. Off by one day for half the planet. Use
parseIso. - Storing `Date` objects in state. They are mutable and compare by reference, so
useEffectdependencies and memoisation misbehave. Store strings, derive dates. - Month navigation with `setMonth(m => new Date(m.setMonth(...)))`.
setMonthmutates in place and skips to the following month when the current day does not exist (January 31 plus one month).addMonthsabove always starts at day 1. - Week start. The grid is Monday first. Locales that start on Sunday need
lead = first.getDay()and the weekday labels rotated. Read it fromIntl.Locale.prototype.getWeekInfowhere available. - Forgetting the second month on narrow desktop windows. Below about 700 pixels, render one
MonthGridand keep the next and previous buttons; the keyboard code already copes with a single month because it scrolls on cursor exit.
Components used in this guide
DateRangePicker, DatePicker, Popover and Button are free and MIT licensed, and the rest of the form controls are in the forms category. The range value drops straight into the filters of the TanStack data table guide and the date filters in the SaaS dashboard guide. If you want the whole reporting screen built rather than assembled, the MiniDev studio builds products on this kit.
npx shadcn@latest add https://ui.minidev.pro/r/<name>.json
# or the package
npm i minidev-ui-kit
Components used in this guide
Frequently asked questions
Do I need date-fns or Day.js for a date range picker?
Not for the picker itself. Parsing and formatting ISO day strings, adding days and building a month grid take about thirty lines with the Date constructor and Intl.DateTimeFormat. Reach for a library when you need time zones, recurrence rules or localized parsing of typed input.
Why does my picked date show one day earlier?
Because new Date("2026-10-14") is parsed as UTC midnight, which is the previous evening in the Americas. Build local dates from the parts (new Date(y, m - 1, d)) and format back to strings yourself instead of calling toISOString().
Should the picker use the native date inputs on mobile?
Yes. The platform picker is faster to use with a thumb, supports the user's locale and needs no extra code. Switch on (pointer: coarse) so devices with a mouse or trackpad get the calendar popover and touch devices get the native inputs, with the same { from, to } value in both.