Learn how to create a calendar in React JS using Vite and date-fns. Follow this easy step-by-step guide to build a modern, responsive calendar app.
Calendars are important in many web apps, such as booking systems, to-do apps, and schedules. In this blog, you will learn how to create a calendar in React JS using Vite (for faster setup) and the date-fns library (to manage dates easily). We will keep the UI clean and responsive so it works well on all devices.
Whether you're a beginner or intermediate developer, this guide will help you build your own custom calendar in React step by step.
Environment Setup
Before you begin, make sure Node.js and npm (Node Package Manager) are installed on your computer. If not, you can download them from the official Node.js website. Once you’re ready, follow these steps:
We’ll use Vite to set up the React project, as it’s much faster and more modern than Create React App.
Step 1: Install Vite with React
Open your terminal and run:
npm create vite@latest react-calendar-app -- --template react
cd react-calendar-app
npm install
Step 2: Install date-fns
Now install date-fns for date formatting and manipulation:
npm install date-fns
Step 3: Start Development Server
Run your app:
npm run dev
Now your React app is ready to build the calendar.
Step-by-Step Guide to Build the Calendar
Add Tailwind CDN
Open index.html in your public folder (or root in Vite) and add inside <head>:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React Calendar</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
This will load Tailwind CSS via CDN.
Remove Unused CSS Files (src/assets/App.css & src/assets/Index.css)
Since we're using 100% Tailwind CSS via CDN, you can safely delete the default App.css and Index.css files to keep your project clean and lightweight.
Replace src/App.jsx with the Calendar Component Code:
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { format, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addMonths, subMonths, addWeeks, subWeeks, addDays, subDays, isSameMonth, isSameDay, parseISO, eachDayOfInterval, getISOWeek, // For week number if needed } from 'date-fns'; import { enUS } from 'date-fns/locale'; // ----------------------------------------------------------------------------- // 1. Custom Hook for Calendar Logic (useCalendar) // This hook abstracts all date manipulation and view state. // ----------------------------------------------------------------------------- const useCalendar = (initialDate = new Date(), initialView = 'month') => { const [currentDate, setCurrentDate] = useState(initialDate); const [currentView, setCurrentView] = useState(initialView); // Memoized date range for the current view const currentPeriod = useMemo(() => { switch (currentView) { case 'month': const monthStart = startOfMonth(currentDate); const monthEnd = endOfMonth(monthStart); const startDate = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday start const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 }); return { start: startDate, end: endDate }; case 'week': const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 }); const weekEnd = endOfWeek(weekStart, { weekStartsOn: 1 }); return { start: weekStart, end: weekEnd }; case 'day': return { start: currentDate, end: currentDate }; default: return { start: currentDate, end: currentDate }; } }, [currentDate, currentView]); // Memoized days to render in the current view const daysInView = useMemo(() => { return eachDayOfInterval(currentPeriod); }, [currentPeriod]); // Navigation handlers const navigate = useCallback((direction) => { setCurrentDate((prevDate) => { switch (currentView) { case 'month': return direction === 'next' ? addMonths(prevDate, 1) : subMonths(prevDate, 1); case 'week': return direction === 'next' ? addWeeks(prevDate, 1) : subWeeks(prevDate, 1); case 'day': return direction === 'next' ? addDays(prevDate, 1) : subDays(prevDate, 1); default: return prevDate; } }); }, [currentView]); const goToToday = useCallback(() => { setCurrentDate(new Date()); }, []); return { currentDate, currentView, setCurrentView, currentPeriod, daysInView, navigate, goToToday, }; }; // ----------------------------------------------------------------------------- // 2. Main App Component // Handles overall layout, state, and passes props to sub-components. // ----------------------------------------------------------------------------- const App = () => { const { currentDate, currentView, setCurrentView, daysInView, navigate, goToToday, } = useCalendar(); const [events, setEvents] = useState([]); const [showEventModal, setShowEventModal] = useState(false); const [selectedDateForModal, setSelectedDateForModal] = useState(null); // Opens the event modal for a specific date const openEventModal = useCallback((date) => { setSelectedDateForModal(date); setShowEventModal(true); }, []); // Closes the event modal const closeEventModal = useCallback(() => { setShowEventModal(false); setSelectedDateForModal(null); }, []); // Adds a new event to the state const addEvent = useCallback((newEvent) => { setEvents((prevEvents) => [...prevEvents, newEvent]); closeEventModal(); }, [closeEventModal]); // Filters events for a given date const getEventsForDate = useCallback((date) => { // Ensuring comparison is robust by parsing ISO string return events.filter(event => isSameDay(parseISO(event.date), date)); }, [events]); return ( <div className="min-h-screen bg-gradient-to-br from-blue-50 via-gray-100 to-green-50 p-4 md:p-8 font-sans antialiased text-gray-800"> <div className="max-w-7xl mx-auto bg-white bg-opacity-95 backdrop-filter backdrop-blur-lg rounded-xl shadow-2xl overflow-hidden transition-all duration-300"> {/* Calendar Header */} <CalendarHeader currentDate={currentDate} currentView={currentView} setCurrentView={setCurrentView} navigate={navigate} goToToday={goToToday} /> {/* Calendar Content based on view */} <div className="p-4 md:p-6 lg:p-8"> {currentView === 'month' && ( <MonthView currentDate={currentDate} daysInView={daysInView} openEventModal={openEventModal} getEventsForDate={getEventsForDate} /> )} {currentView === 'week' && ( <WeekView currentDate={currentDate} daysInView={daysInView} openEventModal={openEventModal} getEventsForDate={getEventsForDate} /> )} {currentView === 'day' && ( <DayView currentDate={currentDate} openEventModal={openEventModal} getEventsForDate={getEventsForDate} /> )} </div> {/* Event Modal */} {showEventModal && ( <EventModal selectedDate={selectedDateForModal} closeModal={closeEventModal} addEvent={addEvent} /> )} </div> </div> ); }; // ----------------------------------------------------------------------------- // 3. CalendarHeader Component // Manages view toggles and navigation. // ----------------------------------------------------------------------------- const CalendarHeader = React.memo(({ currentDate, currentView, setCurrentView, navigate, goToToday }) => { // Determine header title based on current view const headerTitle = useMemo(() => { switch (currentView) { case 'month': return format(currentDate, 'MMMM yyyy', { locale: enUS }); case 'week': const startOfWeekDate = startOfWeek(currentDate, { weekStartsOn: 1 }); const endOfWeekDate = endOfWeek(currentDate, { weekStartsOn: 1 }); return `${format(startOfWeekDate, 'MMM d', { locale: enUS })} - ${format(endOfWeekDate, 'MMM d, yyyy', { locale: enUS })}`; case 'day': return format(currentDate, 'EEEE, MMMM d, yyyy', { locale: enUS }); default: return ''; } }, [currentDate, currentView]); return ( <div className="flex flex-col md:flex-row items-center justify-between p-4 md:p-6 bg-gray-800 text-white shadow-lg rounded-t-xl"> <h2 className="text-3xl font-extrabold mb-4 md:mb-0 min-w-[200px]"> {headerTitle} </h2> <div className="flex items-center space-x-3 mb-4 md:mb-0"> <button onClick={() => navigate('prev')} className="p-2 rounded-full hover:bg-white hover:bg-opacity-10 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-gray-400" aria-label="Previous period" > <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 19l-7-7 7-7"></path></svg> </button> <button onClick={goToToday} className="px-4 py-2 bg-blue-200 text-blue-800 font-semibold rounded-full shadow-md hover:bg-blue-300 transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-blue-400" > Today </button> <button onClick={() => navigate('next')} className="p-2 rounded-full hover:bg-white hover:bg-opacity-10 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-gray-400" aria-label="Next period" > <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7"></path></svg> </button> </div> <div className="flex space-x-3"> <button onClick={() => setCurrentView('month')} className={`px-5 py-2 rounded-full font-semibold transition-all duration-300 ${currentView === 'month' ? 'bg-blue-500 text-white shadow-md' : 'text-white hover:bg-white hover:bg-opacity-10'}`} > Month </button> <button onClick={() => setCurrentView('week')} className={`px-5 py-2 rounded-full font-semibold transition-all duration-300 ${currentView === 'week' ? 'bg-blue-500 text-white shadow-md' : 'text-white hover:bg-white hover:bg-opacity-10'}`} > Week </button> <button onClick={() => setCurrentView('day')} className={`px-5 py-2 rounded-full font-semibold transition-all duration-300 ${currentView === 'day' ? 'bg-blue-500 text-white shadow-md' : 'text-white hover:bg-white hover:bg-opacity-10'}`} > Day </button> </div> </div> ); }); // ----------------------------------------------------------------------------- // 4. MonthView Component // Renders the monthly calendar grid. // ----------------------------------------------------------------------------- const MonthView = React.memo(({ currentDate, daysInView, openEventModal, getEventsForDate }) => { const weekDays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; return ( <div className="grid grid-cols-7 gap-1 lg:gap-2"> {weekDays.map((day) => ( <div key={day} className="text-center font-bold text-gray-700 py-3 text-sm md:text-base"> {day} </div> ))} {daysInView.map((day) => { const dayEvents = getEventsForDate(day); const isToday = isSameDay(day, new Date()); const isCurrentMonth = isSameMonth(day, currentDate); return ( <div key={day.toISOString()} className={` relative h-24 md:h-28 lg:h-36 p-1 md:p-2 rounded-lg cursor-pointer flex flex-col justify-between border border-gray-200 transition-all duration-200 ${isCurrentMonth ? 'bg-white hover:bg-gray-50' : 'bg-gray-50 text-gray-400 opacity-70'} ${isToday ? 'border-2 border-blue-400 bg-blue-50 font-semibold' : ''} `} onClick={() => openEventModal(day)} role="gridcell" aria-label={`Date ${format(day, 'MMM d, yyyy')}`} > <span className={`text-right text-sm md:text-base ${isToday ? 'text-blue-700' : 'text-gray-800'}`}> {format(day, 'd', { locale: enUS })} </span> <div className="flex flex-col flex-grow overflow-y-auto mt-1 no-scrollbar"> {dayEvents.slice(0, 2).map((event) => ( // Show max 2 events directly <div key={event.id} className="bg-teal-100 text-teal-800 text-xs font-medium px-2 py-1 rounded-md mb-1 truncate" title={`${event.title} (${event.time})`} > {event.title} </div> ))} </div> {dayEvents.length > 2 && ( <div className="text-center text-xs text-gray-600 mt-1"> +{dayEvents.length - 2} more </div> )} </div> ); })} </div> ); }); // ----------------------------------------------------------------------------- // 5. WeekView Component // Renders the weekly calendar grid with time slots. // ----------------------------------------------------------------------------- const WeekView = React.memo(({ currentDate, daysInView, openEventModal, getEventsForDate }) => { // Generate time slots for the vertical axis (00:00 to 23:00) const timeSlots = useMemo(() => Array.from({ length: 24 }, (_, i) => `${i < 10 ? '0' : ''}${i}:00`), []); return (( <div key={day.toISOString()} className="col-span-1 text-center font-bold text-gray-700 text-sm md:text-base"> {format(day, 'EEE d', { locale: enUS })} {isSameDay(day, new Date()) && <span className="block text-blue-600 text-xs">(Today)</span>} </div> ))} </div> <div className="relative"> {/* Horizontal time grid lines */} {timeSlots.map((_, index) => ( <div key={`time-line-${index}`} className="absolute left-0 right-0 border-t border-gray-200" style={{ top: `${(index * 60) / (24 * 60) * 100}%`, height: '1px', zIndex: 0 }} ></div> ))} {/* Main week grid with time labels and day columns */} <div className="grid grid-cols-8 gap-1 md:gap-2"> {/* Time Labels Column */} <div className="col-span-1 flex flex-col"> {timeSlots.map((time) => ( <div key={time} className="h-10 flex items-center justify-end pr-2 text-xs text-gray-500"> {time} </div> ))} </div> {/* Day Columns for events */} {daysInView.map((day) => { const dayEvents = getEventsForDate(day); const isToday = isSameDay(day, new Date()); return ( <div key={day.toISOString()} className={` relative col-span-1 min-h-[600px] border border-gray-200 rounded-lg p-1 transition-all duration-200 ${isToday ? 'bg-blue-50 border-2 border-blue-400' : 'bg-white'} hover:bg-gray-50 cursor-pointer `} onClick={() => openEventModal(day)} role="gridcell" aria-label={`Day ${format(day, 'MMM d, yyyy')}`} > {/* Render events within the day column */} {dayEvents.map((event) => ( <div key={event.id} className="absolute w-[calc(100%-8px)] bg-teal-100 text-teal-800 text-xs font-medium px-2 py-1 rounded-md truncate shadow-sm z-10" style={{ // Position event based on its start time (assuming 40px height per hour) top: `${parseInt(event.time.split(':')[0]) * 40}px`, height: '35px', // Fixed height for visual consistency left: '4px', }} title={`${event.title} (${event.time})`} > {event.time} - {event.title} </div> ))} </div> ); })} </div> </div> </div> ); }); // ----------------------------------------------------------------------------- // 6. DayView Component // Renders events for a single selected day. // ----------------------------------------------------------------------------- const DayView = React.memo(({ currentDate, openEventModal, getEventsForDate }) => { const dayEvents = getEventsForDate(currentDate); const timeSlots = useMemo(() => Array.from({ length: 24 }, (_, i) => `${i < 10 ? '0' : ''}${i}:00`), []); return (+ Add Event </button> <div className="grid grid-cols-1 md:grid-cols-[100px_1fr] gap-4"> {/* Time Labels Column */} <div className="flex flex-col"> {timeSlots.map((time) => ( <div key={time} className="h-10 flex items-center justify-end pr-2 text-xs text-gray-500 border-b border-gray-100"> {time} </div> ))} </div> {/* Events List for the day */} <div className="relative flex flex-col"> {/* Horizontal time grid lines */} {timeSlots.map((_, index) => ( <div key={`day-time-line-${index}`} className="absolute left-0 right-0 border-t border-gray-200" style={{ top: `${(index * 60) / (24 * 60) * 100}%`, height: '1px', zIndex: 0 }} ></div> ))} <div className="min-h-[600px] pt-2 relative"> {dayEvents.length > 0 ? ( dayEvents.map((event) => ( <div key={event.id} className="bg-teal-100 text-teal-800 text-sm font-medium px-3 py-2 rounded-lg mb-2 shadow-sm relative z-10" style={{ // Position event based on its start time top: `${parseInt(event.time.split(':')[0]) * 40}px`, position: 'absolute', width: 'calc(100% - 16px)', left: '8px', }} > <p className="font-bold">{event.title}</p> <p className="text-xs">{event.time}</p> </div> )) ) : ( <p className="text-center text-gray-500 mt-20">No events for this day. Click '+ Add Event' to add one!</p> )} </div> </div> </div> </div> ); }); // ----------------------------------------------------------------------------- // 7. EventModal Component // Form for adding new events. // ----------------------------------------------------------------------------- const EventModal = React.memo(({ selectedDate, closeModal, addEvent }) => { const [title, setTitle] = useState(''); const [time, setTime] = useState('09:00'); // Default time const handleSubmit = useCallback((e) => { e.preventDefault(); if (title.trim() === '') { // Basic validation - in a real app, this would be more robust console.error("Event title cannot be empty."); // Use console.error instead of alert return; } const newEvent = { id: Date.now(), // Simple unique ID for demo title, date: format(selectedDate, 'yyyy-MM-dd', { locale: enUS }), time, }; addEvent(newEvent); }, [title, time, selectedDate, addEvent]); return ( <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md transform transition-all duration-300 scale-100 opacity-100"> <div className="flex justify-between items-center mb-4"> <h3 className="text-2xl font-bold text-gray-800">Add Event for {format(selectedDate, 'MMM d, yyyy', { locale: enUS })}</h3> <button onClick={closeModal} className="text-gray-500 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-300 rounded-full p-1" aria-label="Close modal" > <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path></svg> </button> </div> <form onSubmit={handleSubmit} className="space-y-4"> <div> <label htmlFor="eventTitle" className="block text-gray-700 text-sm font-semibold mb-2"> Event Title </label> <input type="text" id="eventTitle" value={title} onChange={(e) => setTitle(e.target.value)} className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-colors duration-200" placeholder="e.g., Team Meeting" required /> </div> <div> <label htmlFor="eventTime" className="block text-gray-700 text-sm font-semibold mb-2"> Time </label> <input type="time" id="eventTime" value={time} onChange={(e) => setTime(e.target.value)} className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-colors duration-200" required /> </div> <div className="flex justify-end space-x-3"> <button type="button" onClick={closeModal} className="px-5 py-2 bg-gray-200 text-gray-800 rounded-full font-semibold hover:bg-gray-300 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-gray-400" > Cancel </button> <button type="submit" className="px-5 py-2 bg-blue-600 text-white rounded-full font-semibold shadow-md hover:bg-blue-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500" > Add Event </button> </div> </form> </div> </div> ); }); export default App;
Run Your App
Go to your terminal:
npm run devOpen http://localhost:5173/ and see your calendar.
Conclusion
You’ve just built a modern and responsive calendar in React JS using Vite and date-fns! This is perfect for scheduling apps, event planners, or booking platforms. You can extend this by adding features like:
- Time slot selection
- Integration with Google Calendar API
This setup is fast, clean, and great for SEO. Make sure to explore more with date-fns for advanced date features.
That’s a wrap!
I hope you enjoyed this article
Did you like it? Let me know in the comments below 🔥 and you can support me by buying me a coffee.
And don’t forget to sign up to our email newsletter so you can get useful content like this sent right to your inbox!
Thanks!
Faraz 😊

