EmpireUI
Get Pro
← Blog8 min read#date picker#react#calendar

Date Picker in React: react-day-picker v9 and Custom Approach

Build accessible date pickers in React with react-day-picker v9 or roll your own. Real code, real trade-offs, no hand-waving. Updated for 2026.

Developer working on a React date picker component on a laptop screen

Why Date Pickers Are Still Annoying in 2026

Date inputs are deceptively hard. The native <input type="date"> looks fine on desktop Chrome, renders differently in Safari, and falls apart on mobile browsers that decide to open a full-screen modal you can't style at all. So you end up reaching for a library — or, if you're brave, writing your own.

Honestly, the biggest mistake I see teams make is over-engineering this. A date range picker for a hotel-booking app has completely different requirements than a simple "pick your birthday" field in an onboarding form. Before you pull in a library that adds 40 kB to your bundle, ask yourself: what does the user actually need to do?

There are two realistic approaches in React right now: react-day-picker v9 (the most popular headless-ish option, ~12 kB gzipped) and rolling a custom solution on top of a date math library like date-fns or Temporal. We'll cover both, with actual code you can copy.

Worth noting: the Intl API has matured enough in 2026 that you don't need day.js or moment.js just to format a date string. Intl.DateTimeFormat handles locale, timezone, and display format with zero bundle cost.

react-day-picker v9: What Changed and How to Set It Up

react-day-picker v9 landed in late 2024 and it's a significant rewrite. The API is cleaner — no more fighting with ClassNames props, the selected / onSelect pair is consistent across modes, and TypeScript types are first-class instead of bolted on. If you're still on v8, migration is worth the hour it takes.

Install it with your package manager of choice and bring in the default stylesheet (you can override every token later): ``bash npm install react-day-picker ` Basic single-date picker: `tsx import { useState } from 'react'; import { DayPicker } from 'react-day-picker'; import 'react-day-picker/dist/style.css'; export function BirthdayPicker() { const [selected, setSelected] = useState<Date | undefined>(); return ( <div className="p-4 rounded-xl border border-gray-200 bg-white shadow-sm inline-block"> <DayPicker mode="single" selected={selected} onSelect={setSelected} captionLayout="dropdown" fromYear={1920} toYear={2010} /> {selected && ( <p className="mt-2 text-sm text-gray-600"> Selected: {selected.toLocaleDateString()} </p> )} </div> ); } ``

captionLayout="dropdown" is the one option I'd always enable for birthday fields — it shows year and month selects instead of arrow-through-each-month navigation. Users born in 1985 don't want to click a back arrow 480 times.

For a date range picker (check-in / check-out, report date range, that sort of thing), swap to mode="range" and give it a DateRange state: ``tsx import { DateRange } from 'react-day-picker'; const [range, setRange] = useState<DateRange | undefined>(); <DayPicker mode="range" selected={range} onSelect={setRange} numberOfMonths={2} pagedNavigation /> ` Two-month layout is the standard pattern here. pagedNavigation` jumps both months at once instead of sliding — feels much more natural.

Styling react-day-picker v9 to Match Your Design System

The default stylesheet is a starting point, not a final answer. In practice, you'll want to override the CSS custom properties that v9 exposes. The full token list is documented, but here are the ones you touch most often: ``css /* globals.css or your component styles */ .rdp { --rdp-accent-color: #7c3aed; /* selected day background */ --rdp-accent-color-dark: #6d28d9; /* hover on selected */ --rdp-background-color: #f5f3ff; /* hover background */ --rdp-outline: 2px solid #7c3aed; /* focus ring */ --rdp-outline-selected: 2px solid #7c3aed; --rdp-cell-size: 40px; /* day cell width and height */ font-family: inherit; } ``

If you're on Tailwind and want to avoid the CSS custom property approach entirely, v9 supports a classNames prop that accepts a map of BEM-style class keys to your own class strings. It's verbose but gives you full control: ``tsx <DayPicker classNames={{ day: 'rdp-day', day_selected: 'bg-violet-600 text-white rounded-full', day_today: 'font-bold text-violet-700', day_outside: 'text-gray-300', nav_button: 'hover:bg-gray-100 rounded-md p-1 transition-colors', }} /> ``

One more thing — if your project already uses a design system like Empire UI's, the glassmorphism or neobrutalism tokens can be wired directly into those --rdp-* variables. A frosted-glass calendar sitting on a gradient background looks genuinely good and takes about 15 minutes to put together using the glassmorphism generator to grab your backdrop-filter and background values.

Quick aside: don't forget to scope your CSS overrides. If you drop the .rdp {} block in globals.css, it affects every DayPicker instance in your app. For component-level isolation, use CSS Modules or a scoping class on the wrapper div.

Accessibility: What react-day-picker Gets Right (and What You Still Own)

react-day-picker v9 ships with solid ARIA semantics out of the box. The calendar grid uses role="grid", each day cell has aria-label="July 22, 2026" (full date, not just the number), selected dates get aria-selected="true", and the whole thing is keyboard navigable: arrow keys move focus, Enter/Space selects, PageUp/PageDown switch months. That's a lot of work you don't have to do yourself.

What the library can't own is the trigger — the input field or button that opens the calendar popover. That's your code. A few rules: the trigger should have a visible label (not just a calendar icon with no text), the popover should trap focus while open and return focus to the trigger on close, and the popover's container needs role="dialog" with an aria-label. If you're using a library like Radix UI's Popover or Floating UI, this is handled; if you're rolling a plain useState(isOpen) toggle, you'll need to do this yourself. ``tsx // Accessible trigger + popover pattern <div className="relative inline-block"> <button aria-haspopup="dialog" aria-expanded={isOpen} aria-label="Open date picker" onClick={() => setIsOpen(true)} className="flex items-center gap-2 px-3 py-2 border border-gray-300 rounded-lg" > <CalendarIcon className="w-4 h-4" /> <span>{selected ? selected.toLocaleDateString() : 'Pick a date'}</span> </button> {isOpen && ( <div role="dialog" aria-label="Date picker calendar" className="absolute top-full left-0 mt-2 z-50 shadow-xl" > <DayPicker mode="single" selected={selected} onSelect={(d) => { setSelected(d); setIsOpen(false); }} /> </div> )} </div> ``

Honestly, focus trapping is the part most teams skip. It's not hard — focus-trap-react is a 2 kB package that handles it — but it makes a real difference for keyboard-only users. Add it.

Rolling a Custom Date Picker Without a Calendar Library

Sometimes react-day-picker is overkill. A simple month/year/day select trio works fine for birthdays or expiry dates where no calendar grid is needed. You can build that in about 80 lines with zero dependencies beyond date-fns for day-in-month calculation. ``tsx import { getDaysInMonth } from 'date-fns'; import { useState } from 'react'; type Parts = { year: number; month: number; day: number }; export function SelectDatePicker({ onChange }: { onChange: (d: Date) => void }) { const [parts, setParts] = useState<Partial<Parts>>({}); function update(key: keyof Parts, value: number) { const next = { ...parts, [key]: value }; setParts(next); if (next.year && next.month && next.day) { onChange(new Date(next.year, next.month - 1, next.day)); } } const daysInMonth = parts.year && parts.month ? getDaysInMonth(new Date(parts.year, parts.month - 1)) : 31; return ( <div className="flex gap-2"> <select onChange={(e) => update('month', +e.target.value)} className="border rounded px-2 py-1"> <option value="">Month</option> {Array.from({ length: 12 }, (_, i) => ( <option key={i + 1} value={i + 1}> {new Date(2000, i).toLocaleString('en', { month: 'long' })} </option> ))} </select> <select onChange={(e) => update('day', +e.target.value)} className="border rounded px-2 py-1"> <option value="">Day</option> {Array.from({ length: daysInMonth }, (_, i) => ( <option key={i + 1} value={i + 1}>{i + 1}</option> ))} </select> <input type="number" placeholder="Year" min={1900} max={2026} className="border rounded px-2 py-1 w-20" onChange={(e) => update('year', +e.target.value)} /> </div> ); } ``

The key trick is recalculating daysInMonth dynamically — so February 2024 shows 29 days but February 2025 shows 28. getDaysInMonth from date-fns handles leap years correctly and it's tree-shakeable, so you're not pulling in the full library.

That said, the select approach breaks down the moment you need range selection, disabled dates, or a calendar grid. Don't force it where it doesn't fit. For anything more complex than three selects, reach for react-day-picker. The 12 kB cost is worth it.

Look, there's also the increasingly popular option of pairing a headless hook with your own rendering. Libraries like @rehookify/datepicker give you state management and keyboard logic as hooks and let you render exactly what you want. Worth knowing this option exists — though in most projects react-day-picker's classNames API gets you far enough without the extra abstraction layer.

Integration with React Hook Form and Zod

The most common real-world setup is react-day-picker inside a react-hook-form controlled field, with Zod validation ensuring the date is valid and within bounds. Here's the wiring: ``tsx import { Controller, useForm } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import { DayPicker } from 'react-day-picker'; import { useState } from 'react'; const schema = z.object({ checkIn: z.date({ required_error: 'Check-in date is required' }), checkOut: z.date({ required_error: 'Check-out date is required' }), }).refine((d) => d.checkOut > d.checkIn, { message: 'Check-out must be after check-in', path: ['checkOut'], }); type FormValues = z.infer<typeof schema>; export function BookingForm() { const { control, handleSubmit, formState: { errors } } = useForm<FormValues>({ resolver: zodResolver(schema), }); return ( <form onSubmit={handleSubmit(console.log)} className="space-y-4"> <Controller name="checkIn" control={control} render={({ field }) => ( <div> <label className="block text-sm font-medium mb-1">Check-in</label> <DayPicker mode="single" selected={field.value} onSelect={field.onChange} disabled={{ before: new Date() }} /> {errors.checkIn && ( <p className="text-red-500 text-xs mt-1">{errors.checkIn.message}</p> )} </div> )} /> <button type="submit" className="px-4 py-2 bg-violet-600 text-white rounded-lg"> Book Now </button> </form> ); } ``

The disabled={{ before: new Date() }} prop is react-day-picker's built-in way to grey out past dates. You can combine matchers — disabled={[{ before: new Date() }, { dayOfWeek: [0, 6] }]} disables both past dates and weekends with no extra logic on your end.

One thing to watch: Zod's z.date() validates that it's a JS Date object, not a string. If you're submitting to an API that expects an ISO string, add a .transform((d) => d.toISOString()) at the end of your schema field. Obvious in retrospect, but it catches people the first time.

Performance, Bundle Size, and Choosing the Right Tool

react-day-picker v9 is ~12 kB gzipped when you import just DayPicker. That's acceptable. If you're worried about it, lazy-load the calendar behind a dynamic import — there's no reason to parse that JS on initial page load when the calendar is hidden until the user clicks an input field. ``tsx import dynamic from 'next/dynamic'; const DayPicker = dynamic( () => import('react-day-picker').then((m) => m.DayPicker), { ssr: false } ); ` The ssr: false` is important — DayPicker uses browser APIs internally and will throw during Next.js server rendering without it.

For date math you genuinely don't need moment.js in 2026. date-fns at ~13 kB tree-shaken (only the functions you import) or the native Temporal API (no bundle cost, but still behind a polyfill in some environments) are the right calls. Pick one and stick with it across your project — mixing date-fns and raw Date manipulation leads to timezone bugs that are genuinely painful to debug.

In practice, the decision tree looks like this: simple selects for date-of-birth or expiry → custom selects (no library). Single date or range with a calendar grid → react-day-picker v9. Date + time in one input → reach for something like react-aria's DatePicker which handles the combined input interaction correctly. Full scheduling calendar with events → go all-in on FullCalendar, this is a different product category entirely.

If you're building a UI-heavy React app and want a consistent visual language across your date pickers and everything else — cards, modals, navigation — it's worth browsing Empire UI's component library. The box shadow generator is also handy for getting those calendar popover shadows just right without eyeballing CSS values by hand.

FAQ

What's the difference between react-day-picker v8 and v9?

v9 rewrites the API around a consistent mode prop (single, range, multiple) with selected / onSelect pairs. The ClassNames API is cleaner, TypeScript types are built-in, and the CSS custom property system replaces the old modifier class approach. Migration usually takes an hour.

Can I use react-day-picker with React Hook Form?

Yes — wrap it in a Controller component from react-hook-form. The onSelect prop maps directly to field.onChange, and selected maps to field.value. Add Zod's z.date() validator for schema-level date validation.

Is react-day-picker accessible?

The calendar grid itself is — it uses proper ARIA grid roles, aria-label on each day, and full keyboard navigation. The part you own is the trigger and popover: add aria-haspopup, aria-expanded, role="dialog", and focus trapping when the calendar opens.

Should I use the native date input instead of a library?

For simple use cases where appearance doesn't matter, yes — <input type="date"> is free and accessible. The problems are inconsistent styling across browsers and no support for disabling specific dates, range selection, or locale control. Once you need any of those, a library wins.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Calendar Grid in React: Month View, Event Dots, Date SelectionMulti-Select in React: Tags Input, Checkboxes and Combobox PatternsWCAG 2025 Accessibility Guide for React DevelopersReact Accessibility (a11y): The 8 Patterns You Keep Getting Wrong