VibeWeek
Home/Grow/Date Pickers & Date Range Selection

Date Pickers & Date Range Selection

⬅️ Day 6: Grow Overview

If you're building B2B SaaS in 2026, date pickers are everywhere — analytics filters ("last 30 days"), schedulers, due dates, report ranges, billing cycles, hire dates, expiration. The naive approach: drop in a <input type="date"> and call it done. The structured approach: use a date library (date-fns / Luxon / Day.js — not Moment), pick a UI library (react-aria / react-day-picker / shadcn DatePicker), handle timezones explicitly (see timezone-handling-chat.md), provide preset ranges, accessibility, and locale formatting. This guide covers the implementation craft of date pickers themselves; for time-zone math, see the companion guide.

1. Pick a date library — Moment is dead

Choose a date manipulation library for 2026.

Don't use Moment.js:
- 76KB minified (massive)
- Mutable API (footguns)
- Officially in maintenance mode since 2020
- Tree-shaking impossible

Modern options:

date-fns (recommended default):
- Functional API (immutable)
- Tree-shakeable (only import what you use)
- ~1-15KB depending on functions used
- Locale-aware
- TypeScript native

Day.js:
- Moment-compatible API (easy migration)
- ~7KB total
- Plugin system for advanced features
- Good for projects migrating from Moment

Luxon:
- TypeScript-first
- Strong timezone support (Intl-based)
- ~75KB but features-rich
- Best for heavy timezone needs (see timezone-handling-chat)

Temporal (TC39 proposal):
- Built-in browser API (no library needed)
- Available natively in modern browsers (2025+)
- Use polyfill for older support
- The future; consider for new projects

For 2026 default: date-fns + Temporal-aware code.
For timezone-heavy: Luxon or Temporal directly.
For Moment migrations: Day.js.

Output:
1. Recommendation
2. Bundle-size budget
3. Sample import (tree-shaken)
4. Locale support strategy
5. Migration path if currently using Moment

The 2026 trend: Temporal is landing in browsers. New projects should plan for Temporal as the long-term default; date-fns/Luxon as bridge.

2. Pick a date-picker UI library

Choose a date-picker UI library for React in 2026.

Native HTML5 (<input type="date">):
- Pros: zero deps; works
- Cons: ugly cross-browser; broken on iOS Safari edge cases; minimal range/preset support
- Verdict: only for ultra-simple form fields

react-day-picker:
- Lightweight (~10KB); accessible by default; range + multi support
- Used by shadcn/ui's Calendar component
- Recommended default in 2026

shadcn/ui DatePicker:
- Built on react-day-picker + Radix Popover
- Tailwind-styled
- Copy-paste into project; full control
- Recommended for shadcn-based projects

react-aria DatePicker (Adobe):
- Accessibility-first
- Composable
- Slightly higher learning curve

Mantine DatePicker / Chakra DatePicker / Material UI DatePicker:
- Bundled with parent UI libraries
- Use whichever matches your design system

react-datepicker (legacy popular):
- 700K weekly downloads
- Mature but less modern API
- Locked styling (CSS)
- OK alternative; not first choice

For 2026:
- shadcn-based stack → shadcn Calendar (uses react-day-picker)
- Custom design system → react-day-picker directly
- Adobe / accessibility-first → react-aria
- Material/Mantine → use library's DatePicker

Output:
1. Recommendation
2. Install + setup
3. Styling integration
4. Single date + range example
5. Bundle-size addition

The 2026 default: shadcn/ui Calendar component. Built on react-day-picker, Tailwind-styled, accessible. Most teams should start there.

3. Single date vs date range — different UX

The most-shipped pattern: single date is a popover with calendar; range is two-month side-by-side calendar.

Build single-date and range pickers.

Single date picker:
- Trigger: input or button showing formatted date
- Click: opens popover with month calendar
- Click date: closes popover; sets value
- Today shortcut + clear button
- Keyboard: arrows to navigate, enter to select

Range picker:
- Trigger: input showing "Start - End"
- Click: opens popover with TWO months side-by-side
- Click first date: sets start; live-preview range as user moves
- Click second date: sets end; closes popover
- Smart logic: if user clicks earlier date second, swap start/end
- Reset on opening if both already set

Preset ranges (highly recommended for analytics):
- Today, Yesterday, Last 7 days, Last 30 days, This month, Last month, This quarter, This year, Custom
- Click preset → set range + close (or stay open with range visible)
- Custom = user picks dates

Comparison ranges (advanced):
- Compare to "previous period" / "previous year"
- Two ranges visible
- Often paired with line charts

For [USE CASE], output:
1. Picker type (single / range / range with preset / range with comparison)
2. Component implementation
3. Default value strategy
4. Placeholder text
5. Validation (required, min, max)

The preset rule: if your picker is for analytics filtering (last 30 days etc.), 80% of users click a preset, not custom. Make presets prominent — vertical menu on left of calendar.

4. Timezone awareness in date pickers

A common bug: user picks "Mar 15" in their timezone, server stores midnight UTC, user sees "Mar 14" tomorrow.

Handle timezones in date pickers.

Three patterns based on data semantics:

Pattern 1: "Local date" (no time component)
- Birthday, hire date, due date — date with no time
- Store as YYYY-MM-DD string (no timezone)
- Display same string everywhere
- Don't convert to UTC midnight; that introduces the bug

Pattern 2: "Date + time at user's location"
- Meeting at 3pm Tuesday — user's local time
- Store as ISO 8601 with timezone (2026-03-15T15:00:00-07:00)
- Display in user's local time
- Convert when other timezone users view

Pattern 3: "Universal moment in time"
- Created_at, login_at — server timestamps
- Store as UTC (epoch ms or ISO Z)
- Display in user's local time (using their browser locale)

Date picker implementation per pattern:
- Pattern 1: pure date picker (no time); send YYYY-MM-DD
- Pattern 2: date + time picker with explicit timezone selector
- Pattern 3: typically server-set; user only filters by date range

Common bug: using new Date(string) in JavaScript
- "2026-03-15" parsed as UTC midnight
- new Date(...).toLocaleDateString() in PST shows "Mar 14"
- Fix: parse "YYYY-MM-DD" without timezone offset (use date-fns parseISO with care)

For [USE CASE], output:
1. Which pattern fits
2. Storage format (string / ISO / epoch)
3. Picker config (with/without time)
4. Timezone display strategy
5. Test cases for DST + timezone edge cases

The "birthday bug": classic. User in PST enters Mar 15 birthday. Server stores 2026-03-15T00:00:00Z. User in JST sees Mar 15 9am (correct date). User in HST sees Mar 14 2pm (wrong date). Pattern 1 (date string) avoids this.

5. Locale-aware formatting

Date formats vary by country. Plan for it.

Implement locale-aware date formatting.

Common formats by locale:
- en-US: 03/15/2026 (MM/DD/YYYY) — confusing internationally
- en-GB: 15/03/2026 (DD/MM/YYYY)
- de-DE: 15.03.2026 (DD.MM.YYYY)
- ja-JP: 2026/03/15 (YYYY/MM/DD)
- zh-CN: 2026年3月15日

Browser API:
- Intl.DateTimeFormat (built-in; no library)
- new Intl.DateTimeFormat('en-US').format(date) → "3/15/2026"
- Use 'short', 'medium', 'long', 'full' presets

Best practice for B2B SaaS:
- Default to user locale (browser)
- Allow override in user preferences
- Display ISO-ish format (YYYY-MM-DD) for unambiguous UI
- Use long format for user-facing ("March 15, 2026") to avoid ambiguity

Picker considerations:
- Calendar week-start: Sunday (US) vs Monday (most of world)
- Month names localized
- Date format displayed in input matches locale

Output:
1. Locale-detection strategy
2. User-preference override
3. Intl.DateTimeFormat usage
4. Picker config: weekStart, locale prop
5. Test in locales: en-US, en-GB, de-DE, ja-JP

The MM/DD/YYYY trap: Americans write 3/15/2026; Europeans write 15/3/2026. "5/3/2026" is ambiguous. Use ISO (2026-03-15) or long form (March 15, 2026) for user-facing dates that cross locales.

6. Validation — min, max, disabled days, custom rules

Validate date input.

Common validations:
- Required (date must be set)
- Min date (no past dates / no dates before another field)
- Max date (no future dates / no dates after another field)
- Disabled specific dates (holidays, weekends, fully-booked days)
- Date range validation (start < end; minimum range; maximum range)

Custom rules:
- Business days only (Mon-Fri)
- Working calendar (factor in office holidays)
- Available slots from API (backend determines)

UI patterns:
- Disabled dates greyed out, can't click
- Tooltip on disabled: "Office closed" / "Slot full"
- Inline error: "End date must be after start date"
- Async availability check (debounced)

Implementation:
- React Hook Form + Zod for schema validation
- date-fns helpers: isWeekend, isWithinInterval, eachDayOfInterval
- Async validation for slot availability (with loading state)

Output:
1. Validation schema (Zod or react-hook-form rules)
2. Disabled-dates function for picker
3. Server-side mirror validation
4. Error message UX (inline + sticky)
5. Async-availability pattern with debounce

The min/max trap: setting min={today} on a date picker doesn't prevent server submission of past dates. Always validate server-side as well.

7. Accessibility — keyboard, screen reader, focus

Date pickers are accessibility hard mode. Don't skip.

Make date pickers accessible.

Keyboard requirements:
- Tab: focus the input/trigger
- Enter / Space / Down arrow: open popover
- Escape: close popover
- Arrow keys: navigate days
- Page Up / Page Down: previous / next month
- Shift + Page Up / Down: previous / next year
- Home / End: first / last day of week
- Enter: select focused date

Screen reader requirements:
- Trigger announces "Date picker, Mar 15 2026 selected"
- Calendar announces month/year on open
- Each day announces "Tuesday, March 15, 2026" with availability status
- Selected date announces "selected"
- Disabled dates announce "unavailable"
- Range mode announces start vs end clearly

Focus management:
- On open: focus moves to selected date or today
- On close: focus returns to trigger
- Within calendar: arrow keys move focus visually + programmatically

Libraries that handle this well:
- react-aria DatePicker (Adobe) — best-in-class
- react-day-picker — good with proper config
- shadcn Calendar — inherits react-day-picker

Test:
- VoiceOver (Mac) + NVDA (Windows)
- Keyboard-only navigation
- axe-core / Lighthouse audit

Output:
1. Library-specific accessibility config
2. ARIA attributes (aria-haspopup, aria-expanded, aria-label)
3. Keyboard handler matrix
4. Screen-reader announcements
5. Test plan

The skipped-accessibility cost: form date pickers that fail keyboard nav block screen-reader users from completing forms. WCAG / Section 508 violations. react-aria solves this; rolling your own is risky.

8. Time picker (when date alone isn't enough)

Date + time pickers are harder. Plan separately.

Implement date + time picker.

Two patterns:

Pattern A: Combined picker
- One popover: calendar above, time selector below
- Used in scheduling tools (Calendly-like)
- More complex but feels integrated

Pattern B: Separate inputs
- Date input + time input, side by side
- Simpler implementation
- More flexibility (user can change one without other)

Time selector options:
- Native <input type="time"> (works; ugly cross-browser)
- Hour + minute dropdowns (granular)
- Slider (rare; for ranges)
- 24-hour vs 12-hour AM/PM (locale-dependent)
- Step intervals (every 15 min, every 30 min, every hour)

Specific to scheduling:
- Show only available slots (from server)
- Slot duration (30 min, 60 min)
- Buffer between slots
- Working hours (8am-6pm)
- Time zone disclosure

For [USE CASE], output:
1. Combined or separate
2. Time-input strategy (native / dropdowns / slider)
3. 12 vs 24 hour
4. Step interval
5. Slot-availability integration
6. Timezone handling

The combined-picker complexity: most teams underestimate. Date alone is hard; date + time is much harder. If you can decompose (date input, time input separately), do it.

9. Mobile — native pickers vs custom

Mobile date pickers: use native or custom?

Decide mobile date-picker approach.

Native (<input type="date">):
- iOS: scrolling wheel, OS-controlled UX
- Android: Material picker, OS-controlled UX
- Pros: familiar; no custom code; respects user settings
- Cons: can't customize (presets, ranges); inconsistent with desktop UX

Custom on mobile (same as desktop):
- Pros: brand-consistent; presets work; design control
- Cons: more code; need to nail mobile UX (touch targets, sheet from bottom)

Hybrid (recommended):
- Desktop: custom popover with calendar
- Mobile: bottom sheet with native-feeling calendar (large touch targets)
- Or: use native input on mobile, custom on desktop

Touch considerations:
- Day cells minimum 44x44px (iOS HIG)
- Bottom sheet pattern (slide up from bottom)
- Confirm/cancel buttons (don't auto-close on date click on mobile)
- Year picker (tap year, see scrollable list)

Library support:
- react-day-picker: scales OK to mobile
- react-aria: handles mobile gracefully
- shadcn Calendar: works on mobile but tight

Output:
1. Mobile strategy (native / custom / hybrid)
2. Bottom-sheet pattern (if custom)
3. Touch target sizing
4. Confirm/cancel pattern
5. Test on iOS Safari + Android Chrome

The hybrid pattern works well: same date library, different presentation. Desktop popover; mobile bottom-sheet.

10. Common patterns — analytics, scheduling, due-date, expiration

Date picker patterns by use case.

Pattern 1: Analytics filter
- Range picker with presets prominent (Last 7 / 30 / 90 days, MTD, QTD, YTD, custom)
- "Compare to" toggle (previous period / previous year)
- Default: last 30 days
- URL state: serialize as ?from=2026-03-01&to=2026-03-31

Pattern 2: Scheduling / appointment
- Single date + time picker
- Available slots from server
- Working hours filtering
- Timezone disclosure
- Buffer between slots

Pattern 3: Due date
- Single date picker
- Today / Tomorrow shortcuts
- "+1 week" / "+1 month" relative options
- Optional: time component for deadlines
- Default: empty or +1 week

Pattern 4: Expiration / valid-until
- Single date picker
- Future-only (min = today)
- Common shortcuts: "1 month", "3 months", "1 year"
- Display countdown: "Expires in 14 days"

Pattern 5: Hire / start date
- Single date (no time)
- Min: today (or onboarding date)
- No timezone (Pattern 1 from above)

Pattern 6: Birthday
- Single date (no time, no timezone)
- Year scrolling for older years
- Allow no year (just month/day) for some flows

For [USE CASE]: which pattern + library config + UX details.

The analytics-pattern detail: include "Compare to previous period" toggle from day one. Once your dashboards have it, users find it indispensable. Adding later breaks URL backward compat.

What Done Looks Like

A v1 date-picker system for B2B SaaS in 2026:

  • date-fns or Day.js (not Moment) for date math
  • shadcn Calendar / react-day-picker / react-aria DatePicker for UI
  • Single date and range pickers as needed
  • Preset ranges for analytics filters
  • Locale-aware formatting (Intl.DateTimeFormat)
  • Timezone handling matched to data semantics (Pattern 1/2/3)
  • Keyboard navigation + screen-reader support
  • Min / max / disabled-days validation client + server
  • Mobile-friendly (bottom sheet or native)
  • URL state for filter ranges (analytics)

Add later when product is mature:

  • Saved date ranges (user-defined presets)
  • Comparison ranges (period-over-period)
  • Recurring date selection (every Tuesday)
  • Multi-date selection (multiple distinct dates)
  • Working-calendar awareness (skip company holidays)

The mistake to avoid: using Moment.js. Bloated, mutable, deprecated. Migrate to date-fns / Day.js / Temporal.

The second mistake: timezone math without thinking. The "birthday bug" hits everyone once. Pick the right pattern (date-only vs datetime-with-tz vs UTC) before storing.

The third mistake: rolling your own date picker. Accessibility is hard; libraries solve it. Use react-day-picker / react-aria; don't build from scratch.

See Also