Date pickers are a common feature in web applications, but sometimes the off-the-shelf solutions don't quite fit your needs. In this post, we'll walk through how to build a custom, reusable date range picker using React, Next.js, and the powerful date-fns
library.
We'll break down the component structure from the inside out, starting with the core logic and building up to the final implementation on the main page.
Here's a quick look at what we'll be building: a clean, modern date range picker with hover previews and easy navigation.
Core Technologies
- React & Next.js: For building our UI components.
- date-fns: A lightweight and powerful library for date manipulation.It's like Lodash, but for dates.
- Tailwind CSS: For styling our components.
- TypeScript: For type safety.
Project Structure
the Date range picker is broken down into several key components:
-
app/page.tsx
: The main page where ourDateRange
component is used. -
components/DateRange/index.tsx
: The main wrapper component that handles the state and logic for the date picker. -
components/DateRange/Calendar.tsx
: Renders the calendar grid, including the month header and days of the week. -
components/DateRange/Days.tsx
: Represents an individual day in the calendar. This is where most of the selection logic happens. -
components/DateRange/PreviewMonth.tsx
: A simple component to display the non-interactive days from the previous and next months. -
types/DateRange.ts
: A file to define TypeScript interfaces. -
lib/utils.ts
: A home for our utility functions.
Defining Types with TypeScript
To ensure our component is type-safe and that we're passing the correct props, we define an interface in types/DateRange.ts
. This makes our code more predictable and easier to debug.
export interface DateRangeProps { startDate?: string; endDate?: string; onStart: (date: string) => void; onEnd: (date: string) => void; disablePast?: boolean; }
This interface specifies that startDate
and endDate
are optional strings, while onStart
and onEnd
are required functions. The disablePast
prop is also an optional boolean. We'll import and use this interface across our components.
Creating a Class Name Utility (cn)
When working with Tailwind CSS in React, you often need to apply classes conditionally. A common pattern is to use a helper function to merge class names cleanly and avoid conflicts. We'll create a utility file at lib/utils.ts
for this purpose.
This function uses two popular libraries:
-
clsx
: For easily toggling classes based on state. -
tailwind-merge
: To intelligently merge Tailwind classes, preventing style conflicts (e.g., merging p-2 and p-4 results in p-4). You'll need to install them first:
npm install clsx tailwind-merge
Then, create the utility file:
import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }
We import this cn function in our components to build dynamic className strings for styling elements based on their state.
The DateRange
Wrapper
This component is the brain of our date picker. It manages the state for the currently displayed month (monthOffset
) and the hoveredDate
for the preview effect. It accepts the disablePast
prop and passes it down to the Calendar
.
'use client'; import React, { useState, useCallback, useMemo } from 'react'; import { addMonths } from 'date-fns'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import Calendar from './Calendar'; import { DateRangeProps } from '@/types/DateRange'; const DateRange = ({ startDate, endDate, onStart, onEnd, disablePast, }: DateRangeProps) => { const [monthOffset, setMonthOffset] = useState(0); const [hoveredDate, setHoveredDate] = useState<Date | null>(null); const currentDate = useMemo( () => addMonths(new Date(), monthOffset), [monthOffset] ); const handlePrevMonth = useCallback(() => { setMonthOffset((prev) => prev - 1); }, []); const handleNextMonth = useCallback(() => { setMonthOffset((prev) => prev + 1); }, []); const handleHoveredDateChange = useCallback((date: Date | null) => { setHoveredDate(date); }, []); return ( <div className="relative rounded-lg shadow-lg border p-6 bg-white max-w-md mx-auto"> {/* Navigation Header */} <div className="flex justify-between items-center mb-4"> <button type="button" onClick={handlePrevMonth} className="p-2 absolute cursor-pointer left-7 top-8 hover:bg-gray-100 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-200" aria-label="Previous month" > <ChevronLeft size={20} /> </button> <button type="button" onClick={handleNextMonth} className="p-2 absolute cursor-pointer right-7 top-8 hover:bg-gray-100 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-200" aria-label="Next month" > <ChevronRight size={20} /> </button> </div> <Calendar date={currentDate} startDate={startDate} endDate={endDate} onStart={onStart} onEnd={onEnd} hoveredDate={hoveredDate} setHoveredDate={handleHoveredDateChange} disablePast={disablePast} /> </div> ); }; export default DateRange;
The Calendar
Component: Assembling the Grid
The Calendar
component is responsible for laying out the grid of days. It calculates which days belong to the previous, current, and next months, and then uses our Days
and PreviewMonth
components to render the complete calendar view.
'use client'; import React, { useMemo } from 'react'; import { format, startOfMonth, getDaysInMonth, getDay, setDate, subMonths, addMonths, } from 'date-fns'; import Days from './Days'; import PreviewMonth from './PreviewMonth'; import { DateRangeProps } from '@/types/DateRange'; // --- Type Definitions --- interface CalendarProps extends DateRangeProps { date: Date; hoveredDate: Date | null; setHoveredDate: (date: Date | null) => void; } // --- Constants --- const WEEK_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; // --- Helper Functions for Calendar Grid Generation --- /** * Generates the array of day elements for the visible portion of the previous month. */ const generatePrevMonthDays = (date: Date, firstDayOfWeek: number) => { const prevMonth = subMonths(date, 1); const daysInPrevMonth = getDaysInMonth(prevMonth); return Array.from({ length: firstDayOfWeek }, (_, i) => { const dayNumber = daysInPrevMonth - firstDayOfWeek + i + 1; const day = setDate(prevMonth, dayNumber); return <PreviewMonth key={`prev-${dayNumber}`} day={day} />; }); }; /** * Generates the array of selectable day elements for the current month. */ const generateCurrentMonthDays = ( date: Date, props: Omit<CalendarProps, 'setHoveredDate'> & { setHoveredDate: (date: Date | null) => void; } ) => { const daysInCurrentMonth = getDaysInMonth(date); return Array.from({ length: daysInCurrentMonth }, (_, i) => { const day = setDate(date, i + 1); return ( <Days key={`current-${i + 1}`} day={day} startDate={props.startDate} endDate={props.endDate} onStart={props.onStart} onEnd={props.onEnd} hoveredDate={props.hoveredDate} setHoveredDate={props.setHoveredDate} disablePast={props.disablePast} /> ); }); }; /** * Generates the array of day elements for the visible portion of the next month. */ const generateNextMonthDays = (date: Date, totalDays: number) => { const remainingSlots = (7 - (totalDays % 7)) % 7; if (remainingSlots === 0) return []; const nextMonth = addMonths(date, 1); return Array.from({ length: remainingSlots }, (_, i) => { const day = setDate(nextMonth, i + 1); return <PreviewMonth key={`next-${i + 1}`} day={day} />; }); }; // --- Main Calendar Component --- const Calendar = React.memo<CalendarProps>(({ date, ...props }) => { const calendarDays = useMemo(() => { const firstDayOfMonth = startOfMonth(date); const firstDayOfWeek = getDay(firstDayOfMonth); // 0 (Sun) - 6 (Sat) const prevMonthDays = generatePrevMonthDays(date, firstDayOfWeek); const currentMonthDays = generateCurrentMonthDays(date, { date, ...props }); const nextMonthDays = generateNextMonthDays( date, prevMonthDays.length + currentMonthDays.length ); return [...prevMonthDays, ...currentMonthDays, ...nextMonthDays]; }, [date, props.startDate, props.endDate, props.hoveredDate, props]); return ( <div className="flex flex-col"> {/* Month/Year Header */} <h3 className="text-neutral-800 text-center font-semibold mb-4 text-lg"> {format(date, 'MMMM yyyy')} </h3> {/* Week Day Headers */} <div className="grid grid-cols-7 gap-1 mb-2"> {WEEK_DAYS.map((day) => ( <div key={day} className="text-center text-xs font-medium text-gray-500 py-2" > {day} </div> ))} </div> {/* Calendar Grid */} <div className="grid grid-cols-7 gap-1">{calendarDays}</div> </div> ); }); Calendar.displayName = 'Calendar'; export default Calendar;
The PreviewMonth
Component: Filler Days
To create a standard calendar grid, we often need to display the last few days of the previous month and the first few days of the next month. The PreviewMonth
component handles this. It's a simple, non-interactive component that just displays a day number with a faded style.
import React from 'react'; import { format } from 'date-fns'; const PreviewMonth = ({ day }: { day: Date }) => { return ( <button type="button" className="text-gray-300 relative z-0 flex w-full aspect-square transition duration-200 ease-in-out items-center justify-center text-sm font-medium rounded-lg" disabled > {format(day, 'd')} </button> ); }; export default PreviewMonth;
The Days
Component: Core Logic
The Days
component is where the magic happens. It handles user interactions and determines the styling for each day based on its state (e.g., start date, end date, in-range, hovered). This is also where we implement the logic for the disablePast
prop. If disablePast
is true, any date before today is rendered as a disabled button.
'use client'; import React, { useMemo, useCallback } from 'react'; import { format, isWithinInterval, isSameDay, startOfDay, isBefore, parseISO, isValid, isAfter, } from 'date-fns'; import { cn } from '@/lib/utils'; import { DateRangeProps } from '@/types/DateRange'; // --- Type Definitions --- interface DaysProps extends DateRangeProps { day: Date; // The specific day to render hoveredDate: Date | null; setHoveredDate: (date: Date | null) => void; } // --- Main Days Component --- const Days = React.memo<DaysProps>( ({ day, startDate: startStr, endDate: endStr, onStart, onEnd, hoveredDate, setHoveredDate, disablePast, }) => { // --- Memoized Date Logic --- const { startDate, endDate, disablePastDay, isStartDay, isEndDay, isInRange, isPreviewing, } = useMemo(() => { const today = startOfDay(new Date()); const internaldisablePastDay = disablePast && isBefore(day, today); const start = startStr && isValid(parseISO(startStr)) ? parseISO(startStr) : null; const end = endStr && isValid(parseISO(endStr)) ? parseISO(endStr) : null; const internalIsStartDay = start && isSameDay(day, start); const internalIsEndDay = end && isSameDay(day, end); const internalIsInRange = start && end && isAfter(end, start) && isWithinInterval(day, { start, end }); // Is a date range being actively selected (i.e., start is set, but end is not) const internalIsPreviewing = start && !end && hoveredDate && isAfter(hoveredDate, start) && isWithinInterval(day, { start, end: hoveredDate }); return { startDate: start, endDate: end, disablePastDay: internaldisablePastDay, isStartDay: internalIsStartDay, isEndDay: internalIsEndDay, isInRange: !!internalIsInRange, isPreviewing: !!internalIsPreviewing, }; }, [startStr, endStr, day, hoveredDate, disablePast]); // --- Event Handlers --- const handleClick = useCallback(() => { if (disablePastDay) return; const formattedDay = format(day, 'yyyy-MM-dd'); // If a start date exists, an end date doesn't, and the clicked day is after the start date if (startDate && !endDate && isAfter(day, startDate)) { onEnd(formattedDay); } else { // Otherwise, start a new selection. // This covers cases where: // 1. No dates are selected. // 2. Both dates are selected (resetting the range). // 3. The clicked date is before the current start date. onStart(formattedDay); onEnd(''); // Clear the end date } }, [disablePastDay, day, startDate, endDate, onStart, onEnd]); const handleMouseEnter = useCallback(() => { if (!disablePastDay && startDate && !endDate) { setHoveredDate(day); } }, [disablePastDay, startDate, endDate, day, setHoveredDate]); // --- Dynamic Class Names --- const dayClassName = cn( 'relative z-0 flex w-full aspect-square items-center justify-center rounded-lg text-sm font-medium transition-colors duration-200 ease-in-out', { // Base states 'cursor-pointer hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-neutral-300': !disablePastDay, 'text-gray-300 cursor-not-allowed': disablePastDay, // In-range and preview styling 'bg-gray-100': (isInRange || isPreviewing) && !isStartDay && !isEndDay, 'hover:bg-neutral-800 hover:text-white': isInRange || isPreviewing, // Start and End day styling (highest priority) 'bg-neutral-800 text-white hover:bg-neutral-700': isStartDay || isEndDay, } ); return ( <button type="button" onClick={handleClick} onMouseEnter={handleMouseEnter} onMouseLeave={() => setHoveredDate(null)} // Simplified mouse leave className={dayClassName} disabled={disablePastDay} tabIndex={disablePastDay ? -1 : 0} > {format(day, 'd')} </button> ); } ); Days.displayName = 'Days'; export default Days;
The Main Component: Putting It All Together
Finally, we use our DateRange
component in our main page. The state for the startDate
, endDate
, and the disablePast
prop is managed here.
'use client'; import DateRange from '@/components/DateRange'; import { useState } from 'react'; export default function Home() { const [startDate, setStartDate] = useState<string>(''); const [endDate, setEndDate] = useState<string>(''); const [disablePast, setDisablePast] = useState<boolean>(true); const handleReset = () => { setStartDate(''); setEndDate(''); }; return ( <div className="p-8 max-w-2xl mx-auto"> <h1 className="text-2xl font-bold mb-8 text-center">Date Range Picker</h1> <div className="mb-8"> <DateRange startDate={startDate} endDate={endDate} onStart={setStartDate} onEnd={setEndDate} disablePast={disablePast} /> </div> {/* Control to toggle past dates */} <div className="flex items-center justify-center mb-8"> <input type="checkbox" id="disable-past" checked={disablePast} onChange={(e) => setDisablePast(e.target.checked)} className="h-4 w-4 rounded border-gray-300 text-neutral-600 focus:ring-neutral-500" /> <label htmlFor="disable-past" className="ml-2 block text-sm text-gray-900" > Disable Past Dates </label> </div> {/* Selected Range Display */} <div className="bg-gray-50 rounded-lg p-6"> <div className="flex justify-between items-start mb-4"> <h2 className="text-lg font-semibold">Selected Range</h2> {(startDate || endDate) && ( <button onClick={handleReset} className="text-sm text-neutral-600 hover:text-neutral-800 underline" > Clear </button> )} </div> <div className="space-y-2 text-sm"> <div> <span className="font-medium">Start:</span>{' '} <span className="text-neutral-700"> {startDate || 'Not selected'} </span> </div> <div> <span className="font-medium">End:</span>{' '} <span className="text-neutral-700"> {endDate || 'Not selected'} </span> </div> {startDate && endDate && ( <div className="pt-2 border-t border-gray-200"> <span className="font-medium">Duration:</span>{' '} <span className="text-neutral-700"> {Math.ceil( (new Date(endDate).getTime() - new Date(startDate).getTime()) / (1000 * 60 * 60 * 24) ) + 1}{' '} days </span> </div> )} </div> </div> </div> ); }
By managing the state in the parent component, DateRange
picker becomes a controlled component, making it more reusable and predictable.
Result
Conclusion
Building a custom date range picker from scratch is a rewarding challenge. By breaking the problem down into smaller, manageable components, we can create a flexible and user-friendly solution. The use of date-fns
simplifies date manipulation, while React's hooks (useState
, useCallback
, useMemo
) help us manage state and optimize performance effectively.
Feel free to use this as a starting point for your own custom date pickers. You can extend it with features like presets, time selection, or multi-month views. Happy coding!
You can see the implementation in this GitHub: link
or live preview link
Top comments (0)