DateRangePicker
Preview
Basic
Loading…
Preview
Code
ts
import DateRangePicker from '@/components/form/DateRangePicker';src/components/form/DateRangePicker.tsx
AI prompt
text
Build a two-month date range picker with quick presets and Apply/Cancel in React + TypeScript + Tailwind CSS, using date-fns.
## Look
- Trigger: a button with the house input recipe; label left (`min-w-0 truncate`), 14px CalendarDays in slate-400 right. The label is compact so the control fits ~13rem on a filter bar: same year "Mar 30 - Mar 31, 2026", across years "Dec 28, 2025 - Jan 3, 2026", half-picked "Mar 30 - Select end date", empty the placeholder in slate-400. A `title` carries the full "MMM d, yyyy - MMM d, yyyy".
- Popover: absolute `right-0 top-full z-50 mt-1` (right-anchored, so it opens inward from a toolbar's right end), opaque floating surface, `p-4`; `w-[300px]` on mobile, `sm:w-auto sm:min-w-[800px]`.
- Body `flex flex-col sm:flex-row gap-4 sm:gap-8`:
- Left (`sm:w-48`): section title "Quick Selection" and presets — a 2-column grid on mobile, a stack from `sm`: `px-3 py-1.5 text-xs text-left rounded-lg text-slate-600 hover:bg-indigo-50 hover:text-indigo-700` (dark `text-slate-400 hover:bg-indigo-500/10 hover:text-indigo-300`). Today, Yesterday, Last 7 Days (today and the 6 before), Week to Date (Sunday start), Month to Date, This Month (whole month), Last Month. Below, once anything is picked, a `p-2.5 bg-slate-50 dark:bg-slate-800/50 rounded-lg` box: section title "Selected Range" and the full range in xs slate-800 / dark slate-200.
- Right: a nav row — prev/next icon buttons (`p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-indigo-600`, `active:scale-90`, next disabled at 30%) around "August 2026 → September 2026" (sm semibold; the arrow and second month hidden on mobile) — then two month grids side by side (`gap-8`, the second hidden below `sm`), each titled "August 2026" (`mb-3 text-center text-sm font-semibold`).
- Month grid: weekday row Su…Sa (`grid-cols-7 gap-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400`), then Sunday-first whole weeks of 32px round day buttons (`h-8 w-8 rounded-full text-xs active:scale-90`, slate-700, hover slate-100 / dark slate-700). Today: `border border-indigo-500 font-semibold`. Both range ends `bg-indigo-600 text-white font-semibold`; days between `bg-indigo-100 text-indigo-800` (dark `bg-indigo-900/50 text-indigo-200`). Future days `opacity-40 cursor-not-allowed`. Padding days from neighbouring months are slate-300, DISABLED and never highlighted — the same day is clickable on the other grid.
- Footer `mt-4 pt-4 border-t border-slate-200 dark:border-slate-700`, `sm:flex-row justify-between` (`flex-col-reverse` on mobile): "Clear Selection" text button (xs slate-600) left; Cancel (`border border-slate-300 dark:border-slate-600`, xs medium, hover slate-50 / dark slate-700) and primary Apply (`px-4`) right.
## Behaviour
- First click sets the start, the second the end (swapped if earlier), a third starts over. Between the two clicks, hovering previews the range live.
- Future days are disabled and the next arrow stops once the month after the right-hand grid would be in the future. Opens with the start's month on the left (else the current month).
- DRAFT until Apply: clicks, presets and Clear Selection edit only the draft. Apply emits `onChange({ from, to })` and closes. Apply is enabled when both ends are set OR both are empty (applying empty is how the filter is cleared) — disabled only for a half-made range. Cancel, outside click, Escape and re-clicking the trigger roll the draft back to `value`, so the trigger never shows a range that isn't applied.
- Re-sync when `value.from` / `value.to` change, compared as strings so a new-but-equal object doesn't stomp an in-progress pick.
- The value is an inclusive `{ from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' }`, `''` when unset — what URL filters carry. Parse into local-field Dates (reject impossible days like 2026-02-31) and format back from local fields, never `toISOString()`, which shifts the day.
- "Today" (for presets, the today ring and the future guard) is taken in a fixed business timezone (UTC+8), not the browser's, so every viewer agrees.
## API
`value: { from: string; to: string }`, `onChange(range)` (Apply only, never per click), `className`, `placeholder = 'All dates'`. Default export; export the `IsoDayRange` type.
## Demo
Seeded `{ from: '2026-08-01', to: '2026-08-31' }`, with the applied value echoed below.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
/* Origin: bonus-adjustment (96S2), verbatim. */
// Ported from marketing-stats' components/ui/DateRangePicker.tsx. Three
// changes, all at the boundary only — the quick options and the hover-preview
// range logic below are unmodified:
//
// - "today" and the future-day guard come from dateUtils' business timezone,
// the same source DatePicker and the month pickers read, so every picker
// on a page agrees about which day today is.
// - the value in and out is the `from`/`to` pair the list-tab filters already
// keep in the URL (`YYYY-MM-DD`, inclusive), instead of UTC-anchored Dates.
// - the TRIGGER label is compact (a same-year range prints its year once)
// and truncates, so the control sits at 13rem on the filter bar instead of
// 16rem without ever clipping a date. Nothing about which days the range
// covers changes — see formatDisplayDate / formatFullRange.
//
// The day grid itself is DayGrid, shared with DatePicker.
'use client';
import { useState, useRef, useEffect } from 'react';
import {
format,
startOfMonth,
endOfMonth,
isSameDay,
isBefore,
addMonths,
subMonths,
startOfDay,
endOfDay,
subDays,
startOfWeek as weekStart,
subMonths as subtractMonths
} from 'date-fns';
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react';
import { DayGrid } from './DayGrid';
import {
businessTodayAsLocalFields,
isFutureBusinessDay,
isFutureBusinessMonth,
isoDayToLocalFields,
localFieldsToIsoDay,
} from '@/lib/dateUtils';
import { useDismiss } from '@/lib/use-dismiss';
/** Internal, local-fielded — see dateUtils' bridge section for why the grid works this way. */
interface DateRange {
startDate: Date | null;
endDate: Date | null;
}
/** The public shape: an inclusive pair of business calendar days, '' when unset. */
export interface IsoDayRange {
from: string;
to: string;
}
interface DateRangePickerProps {
value: IsoDayRange;
/** Fired on Apply only — never per click, so one pick is one navigation. */
onChange: (range: IsoDayRange) => void;
className?: string;
/** Trigger text when nothing is picked. */
placeholder?: string;
}
type QuickOption = 'today' | 'yesterday' | 'last7days' | 'weekToDate' | 'monthToDate' | 'thisMonth' | 'lastMonth';
function toLocalFieldedRange(value: IsoDayRange | undefined): DateRange {
return {
startDate: isoDayToLocalFields(value?.from),
endDate: isoDayToLocalFields(value?.to),
};
}
function toIsoDayRange(range: DateRange): IsoDayRange {
return {
from: localFieldsToIsoDay(range.startDate),
to: localFieldsToIsoDay(range.endDate),
};
}
export default function DateRangePicker({
value,
onChange,
className = '',
placeholder = 'All dates',
}: DateRangePickerProps) {
const [isOpen, setIsOpen] = useState(false);
const normalizedInitialRange = toLocalFieldedRange(value);
const [leftMonth, setLeftMonth] = useState(normalizedInitialRange.startDate || businessTodayAsLocalFields());
const [dateRange, setDateRange] = useState<DateRange>(normalizedInitialRange);
const [tempEndDate, setTempEndDate] = useState<Date | null>(null);
const popupRef = useRef<HTMLDivElement>(null);
// Re-sync whenever the committed range changes — including back to empty,
// which is what "Clear" on the filter bar does. Keyed on the two strings
// rather than an object identity so a re-render with an equal-but-new
// `value` object does not stomp an in-progress pick.
useEffect(() => {
const normalized = toLocalFieldedRange(value);
setDateRange(normalized);
setTempEndDate(null);
setLeftMonth(normalized.startDate || businessTodayAsLocalFields());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value.from, value.to]);
const rightMonth = addMonths(leftMonth, 1);
// Caps the "next month" arrow once the right-hand calendar would reach the
// current business month — no browsing into a future month at all, not just
// greying out its individual days.
const afterRight = addMonths(rightMonth, 1);
const isNextMonthNavDisabled = isFutureBusinessMonth(afterRight.getFullYear(), afterRight.getMonth());
const handleDateClick = (date: Date) => {
if (isFutureBusinessDay(date)) return;
if (!dateRange.startDate || (dateRange.startDate && dateRange.endDate)) {
// Start new selection
const newRange = { startDate: date, endDate: null };
setDateRange(newRange);
setTempEndDate(null);
} else if (dateRange.startDate && !dateRange.endDate) {
// Complete the selection
let start = dateRange.startDate;
let end = date;
// Ensure start is before end
if (isBefore(end, start)) {
[start, end] = [end, start];
}
const newRange = { startDate: start, endDate: end };
setDateRange(newRange);
setTempEndDate(null);
}
};
const handleMouseEnter = (date: Date) => {
if (dateRange.startDate && !dateRange.endDate) {
setTempEndDate(date);
}
};
const isInRange = (date: Date) => {
if (!dateRange.startDate) return false;
const start = dateRange.startDate;
const end = tempEndDate || dateRange.endDate;
if (!end) return false;
const actualStart = isBefore(start, end) ? start : end;
const actualEnd = isBefore(start, end) ? end : start;
return date >= actualStart && date <= actualEnd && !isSameDay(date, actualStart) && !isSameDay(date, actualEnd);
};
const isRangeStart = (date: Date) => {
if (!dateRange.startDate) return false;
const start = dateRange.startDate;
const end = tempEndDate || dateRange.endDate;
if (!end) return isSameDay(date, start);
const actualStart = isBefore(start, end) ? start : end;
return isSameDay(date, actualStart);
};
const isRangeEnd = (date: Date) => {
if (!dateRange.startDate || (!dateRange.endDate && !tempEndDate)) return false;
const start = dateRange.startDate;
const end = tempEndDate || dateRange.endDate;
if (!end) return false;
const actualEnd = isBefore(start, end) ? end : start;
return isSameDay(date, actualEnd);
};
const dayState = (date: Date) => ({
selected: isRangeStart(date) || isRangeEnd(date),
inRange: isInRange(date),
});
const navigateMonths = (direction: 'prev' | 'next') => {
setLeftMonth(current =>
direction === 'prev' ? subMonths(current, 1) : addMonths(current, 1)
);
};
const handleQuickSelect = (option: QuickOption) => {
// The business day's "today", as local fields so the date-fns arithmetic
// below is unchanged — otherwise "Today"/"Yesterday" name the wrong day
// during the hours where the viewer's zone and the business zone sit on
// different calendar dates.
const today = businessTodayAsLocalFields();
let startDate: Date;
let endDate: Date;
switch (option) {
case 'today':
startDate = startOfDay(today);
endDate = endOfDay(today);
break;
case 'yesterday':
const yesterday = subDays(today, 1);
startDate = startOfDay(yesterday);
endDate = endOfDay(yesterday);
break;
case 'last7days':
startDate = startOfDay(subDays(today, 6));
endDate = endOfDay(today);
break;
case 'weekToDate':
startDate = startOfDay(weekStart(today, { weekStartsOn: 0 }));
endDate = endOfDay(today);
break;
case 'monthToDate':
startDate = startOfMonth(today);
endDate = endOfDay(today);
break;
case 'thisMonth':
startDate = startOfMonth(today);
endDate = endOfMonth(today);
break;
case 'lastMonth':
const lastMonth = subtractMonths(today, 1);
startDate = startOfMonth(lastMonth);
endDate = endOfMonth(lastMonth);
break;
default:
return;
}
const newRange = { startDate, endDate };
setDateRange(newRange);
};
const handleApply = () => {
onChange(toIsoDayRange(dateRange));
setIsOpen(false);
};
// Closing without applying must not leave the trigger showing a range the
// list is not actually filtered by — so Cancel/outside-click roll the draft
// back to the committed `value`.
const handleCancel = () => {
setDateRange(toLocalFieldedRange(value));
setTempEndDate(null);
setIsOpen(false);
};
// Outside click and Escape are a Cancel, not a close: the draft rolls back too.
useDismiss(popupRef, isOpen, handleCancel);
// An empty range is a legitimate thing to apply — it is how the range filter
// is cleared. Only a HALF-made selection (a start with no end) is not
// applicable, since there is no sensible end to infer.
const isApplicable = Boolean(dateRange.startDate) === Boolean(dateRange.endDate);
/** Both ends spelled out in full — the trigger's tooltip, never truncated. */
const formatFullRange = () => {
if (dateRange.startDate && dateRange.endDate) {
return `${format(dateRange.startDate, 'MMM d, yyyy')} - ${format(dateRange.endDate, 'MMM d, yyyy')}`;
} else if (dateRange.startDate) {
return `${format(dateRange.startDate, 'MMM d, yyyy')} - Select end date`;
}
return placeholder;
};
// What the trigger shows. Same information in fewer characters, which is
// what lets the control be narrow: a range that stays inside ONE year prints
// that year once ("Mar 30 - Mar 31, 2026"), and only a range straddling two
// years spells both out — so no year is ever dropped, just not repeated.
// Every state now fits the 13rem trigger without ellipsis; `truncate` below
// is the safety net for a font whose metrics run wider, not the normal case.
const formatDisplayDate = () => {
const { startDate, endDate } = dateRange;
if (startDate && endDate) {
return startDate.getFullYear() === endDate.getFullYear()
? `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d, yyyy')}`
: `${format(startDate, 'MMM d, yyyy')} - ${format(endDate, 'MMM d, yyyy')}`;
}
if (startDate) {
return `${format(startDate, 'MMM d')} - Select end date`;
}
return placeholder;
};
const quickOptions = [
{ key: 'today' as QuickOption, label: 'Today' },
{ key: 'yesterday' as QuickOption, label: 'Yesterday' },
{ key: 'last7days' as QuickOption, label: 'Last 7 Days' },
{ key: 'weekToDate' as QuickOption, label: 'Week to Date' },
{ key: 'monthToDate' as QuickOption, label: 'Month to Date' },
{ key: 'thisMonth' as QuickOption, label: 'This Month' },
{ key: 'lastMonth' as QuickOption, label: 'Last Month' },
];
return (
<div className={`relative ${className}`} ref={popupRef}>
{/* Trigger Button */}
<button
type="button"
onClick={() => (isOpen ? handleCancel() : setIsOpen(true))}
title={formatFullRange()}
className="field-input text-left"
>
<div className="flex items-center justify-between gap-2">
{/* `min-w-0 truncate`: without it a label wider than the trigger
pushes the calendar icon past the rounded border instead of
ellipsising, since a flex item will not shrink below its content
by default. The tooltip above carries the untruncated range. */}
<span
className={`min-w-0 truncate ${dateRange.startDate ? '' : 'text-slate-400 dark:text-slate-500'}`}
>
{formatDisplayDate()}
</span>
<CalendarDays className="h-3.5 w-3.5 shrink-0 text-slate-400" aria-hidden />
</div>
</button>
{/* Popup Calendar. Right-anchored, as in the marketing-stats original —
the trigger sits near the right end of its toolbar in both, so the
800px panel has to open inward (leftward) to stay on the page. */}
{isOpen && (
<div data-overlay="picker" className="absolute right-0 top-full z-50 mt-1 w-[300px] panel panel-solid p-4 sm:w-auto sm:min-w-[800px]">
<div className="flex flex-col sm:flex-row gap-4 sm:gap-8">
{/* Quick Selection Panel */}
<div className="w-full sm:w-48 flex-shrink-0">
<h4 className="panel-title mb-2">Quick Selection</h4>
<div className="grid grid-cols-2 sm:flex sm:flex-col gap-1.5 sm:gap-0 sm:space-y-1">
{quickOptions.map(option => (
<button
key={option.key}
type="button"
onClick={() => handleQuickSelect(option.key)}
className="w-full text-left px-3 py-1.5 text-xs text-slate-600 dark:text-slate-400 hover:bg-indigo-50 dark:hover:bg-indigo-500/10 hover:text-indigo-700 dark:hover:text-indigo-300 rounded-lg transition-colors"
>
{option.label}
</button>
))}
</div>
{/* Selected Dates Display */}
{(dateRange.startDate || dateRange.endDate) && (
<div className="mt-3 sm:mt-4 p-2.5 bg-slate-50 dark:bg-slate-800/50 rounded-lg">
<h5 className="panel-title mb-1">Selected Range</h5>
<div className="text-xs text-slate-800 dark:text-slate-200">
{dateRange.startDate && format(dateRange.startDate, 'MMM d, yyyy')}
{dateRange.endDate && ` - ${format(dateRange.endDate, 'MMM d, yyyy')}`}
</div>
</div>
)}
</div>
{/* Calendars */}
<div className="flex-1">
{/* Month Navigation */}
<div className="flex items-center justify-between mb-3">
<button
type="button"
onClick={() => navigateMonths('prev')}
aria-label="Previous month"
className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-indigo-600 dark:hover:bg-slate-700 dark:hover:text-indigo-400 transition-colors active:scale-90"
>
<ChevronLeft className="h-4 w-4" />
</button>
<div className="flex gap-2 items-center">
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">
{format(leftMonth, 'MMMM yyyy')}
</span>
<span className="text-sm text-slate-400 hidden sm:inline">→</span>
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200 hidden sm:inline">
{format(rightMonth, 'MMMM yyyy')}
</span>
</div>
<button
type="button"
onClick={() => navigateMonths('next')}
disabled={isNextMonthNavDisabled}
aria-label="Next month"
className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-indigo-600 dark:hover:bg-slate-700 dark:hover:text-indigo-400 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-slate-400 transition-colors active:scale-90"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
{/* Two Calendars Side by Side. Padding days are inert here: the
same day is clickable on the neighbouring grid. */}
<div className="flex flex-col sm:flex-row gap-8">
<div className="flex-1">
<DayGrid month={leftMonth} title outsideDays="disabled" onPick={handleDateClick} onHover={handleMouseEnter} dayState={dayState} />
</div>
<div className="hidden sm:block flex-1">
<DayGrid month={rightMonth} title outsideDays="disabled" onPick={handleDateClick} onHover={handleMouseEnter} dayState={dayState} />
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col-reverse sm:flex-row justify-between items-center mt-4 pt-4 border-t border-slate-200 dark:border-slate-700 gap-4 sm:gap-0">
<button
type="button"
onClick={() => {
setDateRange({ startDate: null, endDate: null });
setTempEndDate(null);
}}
className="px-3 py-2 text-xs text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors"
>
Clear Selection
</button>
<div className="flex gap-3 w-full sm:w-auto justify-end">
<button
type="button"
onClick={handleCancel}
className="px-3 py-2 text-xs font-medium rounded-lg border border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleApply}
disabled={!isApplicable}
className="btn-primary px-4"
>
Apply
</button>
</div>
</div>
</div>
)}
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value* | IsoDayRange | — | |
onChange* | (range: IsoDayRange) => void | — | Fired on Apply only — never per click, so one pick is one navigation. |
className | string | '' | |
placeholder | string | 'All dates' | Trigger text when nothing is picked. |