EmpireUI
Get Pro
← Blog8 min read#calendar#grid#react

Calendar Grid in React: Month View, Event Dots, Date Selection

Build a fully functional React calendar grid from scratch — month navigation, event dot indicators, and controlled date selection. No heavy library required.

React calendar grid component with month view and event indicators

Why Build Your Own Calendar Grid?

Most teams reach for react-big-calendar or a full-blown date picker library and then spend three days fighting its opinionated CSS. You don't need that. A month-view calendar is genuinely a CSS Grid problem with a bit of date arithmetic on top — both of which you already know how to solve.

The popular libraries also ship a ton you'll never use. react-big-calendar is 178 kB minified before you add moment.js or date-fns as the required peer. If all you need is a month grid, an event dot per day, and a selectable date, you're loading roughly 160 kB of dead code. That hurts your Core Web Vitals and it hurts your bundle analysis every time you open it.

Honestly, the custom build is also just more fun. You end up understanding exactly how the grid offsets work — why February 2026 starts on a Sunday means you need 0 leading empty cells, while March starts on a Sunday so you need... also 0. Getting that wrong is a rite of passage. Getting it right feels good.

Worth noting: once you have the base component, you can layer any visual style on top. Pair it with a glassmorphism surface for a sleek SaaS dashboard look, or go full neobrutalism with heavy borders and offset shadows. The logic never changes — only the classes do.

Generating the Month Grid: The Math That Matters

A month-view calendar is a 7-column grid. The tricky part isn't rendering the days — it's figuring out how many empty cells to prepend so that day 1 lands on the right weekday column. new Date(year, month, 1).getDay() gives you a 0–6 index (Sunday=0). That number is your leading offset.

// utils/calendar.ts
export interface CalendarDay {
  date: Date;
  isCurrentMonth: boolean;
  isToday: boolean;
}

export function buildMonthGrid(year: number, month: number): CalendarDay[] {
  const today = new Date();
  today.setHours(0, 0, 0, 0);

  const firstDay = new Date(year, month, 1);
  const lastDay = new Date(year, month + 1, 0);

  // How many empty cells before day 1 (Sunday-first week)
  const leadingOffset = firstDay.getDay();

  const cells: CalendarDay[] = [];

  // Fill leading empty days from previous month
  for (let i = leadingOffset - 1; i >= 0; i--) {
    const d = new Date(year, month, -i);
    cells.push({ date: d, isCurrentMonth: false, isToday: false });
  }

  // Current month days
  for (let d = 1; d <= lastDay.getDate(); d++) {
    const date = new Date(year, month, d);
    date.setHours(0, 0, 0, 0);
    cells.push({
      date,
      isCurrentMonth: true,
      isToday: date.getTime() === today.getTime(),
    });
  }

  // Trailing days to fill the last row to 7
  const trailingCount = (7 - (cells.length % 7)) % 7;
  for (let d = 1; d <= trailingCount; d++) {
    cells.push({
      date: new Date(year, month + 1, d),
      isCurrentMonth: false,
      isToday: false,
    });
  }

  return cells;
}

One quirk to watch: new Date(year, month, 0) gives you the last day of the *previous* month — a handy trick for generating the trailing previous-month cells without needing a separate month calculation. Date arithmetic in JavaScript is weird like that, and knowing the 0-day trick will save you a few headaches.

The trailing fill ensures you always have complete rows. Without it you get a ragged last row in your 7-column grid which looks terrible. The (7 - (cells.length % 7)) % 7 expression handles the case where the last day already falls on a Saturday — the double modulo makes sure you add 0 trailing cells rather than 7.

In practice, this function is the only real logic in the whole component. Everything else is rendering and state management. Keep it pure and unit-test it with a few known months — January 2023 (Sunday start), February 2024 (leap year), and December 2025 are good canaries.

The Calendar Grid Component

With the grid generator in hand, the React component is mostly layout work. CSS Grid with grid-template-columns: repeat(7, 1fr) does the heavy lifting. The cells map directly — no nested arrays, no week-row chunking needed.

// components/CalendarGrid.tsx
import { useState } from 'react';
import { buildMonthGrid } from '@/utils/calendar';

const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

const MONTH_NAMES = [
  'January','February','March','April','May','June',
  'July','August','September','October','November','December',
];

interface CalendarGridProps {
  events?: Record<string, string[]>; // key: 'YYYY-MM-DD', value: event labels
  onDateSelect?: (date: Date) => void;
}

export function CalendarGrid({ events = {}, onDateSelect }: CalendarGridProps) {
  const today = new Date();
  const [viewYear, setViewYear] = useState(today.getFullYear());
  const [viewMonth, setViewMonth] = useState(today.getMonth());
  const [selected, setSelected] = useState<Date | null>(null);

  const cells = buildMonthGrid(viewYear, viewMonth);

  function prevMonth() {
    if (viewMonth === 0) { setViewYear(y => y - 1); setViewMonth(11); }
    else setViewMonth(m => m - 1);
  }

  function nextMonth() {
    if (viewMonth === 11) { setViewYear(y => y + 1); setViewMonth(0); }
    else setViewMonth(m => m + 1);
  }

  function handleSelect(date: Date) {
    setSelected(date);
    onDateSelect?.(date);
  }

  return (
    <div className="w-full max-w-md mx-auto font-sans">
      {/* Header */}
      <div className="flex items-center justify-between mb-4 px-1">
        <button
          onClick={prevMonth}
          className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
          aria-label="Previous month"
        >
          &#8249;
        </button>
        <h2 className="text-base font-semibold text-gray-900 dark:text-white">
          {MONTH_NAMES[viewMonth]} {viewYear}
        </h2>
        <button
          onClick={nextMonth}
          className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
          aria-label="Next month"
        >
          &#8250;
        </button>
      </div>

      {/* Weekday headers */}
      <div className="grid grid-cols-7 mb-1">
        {WEEKDAYS.map(day => (
          <div key={day} className="text-center text-xs font-medium text-gray-400 uppercase py-1">
            {day}
          </div>
        ))}
      </div>

      {/* Day cells */}
      <div className="grid grid-cols-7 gap-px bg-gray-100 dark:bg-white/5 rounded-xl overflow-hidden">
        {cells.map(({ date, isCurrentMonth, isToday }) => {
          const key = date.toISOString().slice(0, 10);
          const dayEvents = events[key] ?? [];
          const isSelected = selected?.toISOString().slice(0, 10) === key;

          return (
            <button
              key={key}
              onClick={() => handleSelect(date)}
              className={[
                'relative flex flex-col items-center pt-1 pb-2 min-h-[52px]',
                'bg-white dark:bg-gray-900 transition-colors',
                isCurrentMonth ? 'hover:bg-indigo-50 dark:hover:bg-indigo-900/20' : 'opacity-30',
                isSelected ? 'bg-indigo-600 dark:bg-indigo-500 hover:bg-indigo-600' : '',
              ].join(' ')}
            >
              <span className={[
                'text-sm w-7 h-7 flex items-center justify-center rounded-full',
                isToday && !isSelected ? 'font-bold text-indigo-600 dark:text-indigo-400' : '',
                isSelected ? 'text-white font-semibold' : 'text-gray-700 dark:text-gray-200',
              ].join(' ')}>
                {date.getDate()}
              </span>
              {/* Event dots */}
              {dayEvents.length > 0 && (
                <div className="flex gap-0.5 mt-0.5">
                  {dayEvents.slice(0, 3).map((_, i) => (
                    <span
                      key={i}
                      className={`w-1 h-1 rounded-full ${
                        isSelected ? 'bg-white/70' : 'bg-indigo-400'
                      }`}
                    />
                  ))}
                </div>
              )}
            </button>
          );
        })}
      </div>
    </div>
  );
}

The gap-px bg-gray-100 trick on the grid container is a classic — you set the gap to 1px and let the container background color show through, giving you a hairline grid line without any border property on individual cells. This keeps the cell styling simple and avoids double-border artifacts at the intersections.

One more thing — the event dot capping at 3 (dayEvents.slice(0, 3)) is intentional. More than 3 dots in a 52px cell just looks like noise. If a day has 8 events, the user still sees 3 dots and knows to click. You can expand to a popover or a day-detail panel on selection — which we'll cover next.

Quick aside: the aria-label on the navigation buttons matters more than you'd think. Screen reader users navigate calendars constantly. Adding descriptive labels (aria-label="Previous month") and an aria-pressed or aria-selected state on the active day cell is a two-minute fix that makes the component WCAG 2.1 AA compliant.

Wiring Up Events: The Data Shape

The events prop takes a flat object keyed by ISO date strings — '2026-09-09': ['Team standup', 'Deploy v2.4']. That's intentional. It's trivially serializable, easy to derive from any API response, and you never need to parse it — just look up events[key] where key is date.toISOString().slice(0, 10).

// How to build the events map from an API response
interface ApiEvent {
  id: string;
  title: string;
  startDate: string; // ISO 8601
}

function groupEventsByDate(apiEvents: ApiEvent[]): Record<string, string[]> {
  return apiEvents.reduce<Record<string, string[]>>((acc, event) => {
    const key = event.startDate.slice(0, 10); // 'YYYY-MM-DD'
    if (!acc[key]) acc[key] = [];
    acc[key].push(event.title);
    return acc;
  }, {});
}

// Usage:
// const eventsMap = groupEventsByDate(await fetchEvents());
// <CalendarGrid events={eventsMap} />

If you're fetching events from Supabase, a simple select('title, start_date').gte('start_date', firstOfMonth).lte('start_date', lastOfMonth) query and then running it through groupEventsByDate is all you need. Re-fetch when viewMonth or viewYear changes by putting both in the useEffect dependency array.

That said, don't over-engineer the refetch. For most calendar use cases a 5-minute stale time with TanStack Query is plenty. Users don't expect real-time event updates while browsing months — they expect it when they open a day detail. That's where you'd add a websocket or a shorter polling interval, not at the month-grid level.

Look, if your events have colors or categories, extend the data shape to Record<string, Array<{ title: string; color: string }>> and swap the static bg-indigo-400 dot class for an inline style. Don't reach for a full event schema until you actually need it — you'll know when you do.

Date Selection: Controlled vs Uncontrolled

The component above manages selection internally with useState. That's the right default. But if you're embedding the calendar in a form — say, a booking flow or a task-creation modal — you'll want to lift that state up and control it externally. Same pattern as any other controlled input in React.

// Controlled CalendarGrid with value + onChange
interface ControlledCalendarProps {
  value: Date | null;
  onChange: (date: Date) => void;
  events?: Record<string, string[]>;
}

export function ControlledCalendar({ value, onChange, events }: ControlledCalendarProps) {
  const today = new Date();
  const [viewYear, setViewYear] = useState(
    value ? value.getFullYear() : today.getFullYear()
  );
  const [viewMonth, setViewMonth] = useState(
    value ? value.getMonth() : today.getMonth()
  );

  // Sync view to external value when it changes
  useEffect(() => {
    if (value) {
      setViewYear(value.getFullYear());
      setViewMonth(value.getMonth());
    }
  }, [value]);

  // ... rest is the same as CalendarGrid above,
  // but call onChange(date) instead of setSelected(date)
}

The useEffect that syncs view month to an external value change is important. Without it, if the parent sets the selected date to December 2026 while the user is viewing September 2026, the grid stays on September and the selection appears invisible — nothing highlights. That's a confusing UX bug. The sync effect fixes it.

For range selection — picking a start and end date — you need two Date | null pieces of state and a bit more rendering logic to highlight the in-between cells. That's a meaningful step up in complexity. If you need ranges, check out the date picker article which goes deep on range selection with keyboard navigation.

One thing the community gets wrong a lot: they store Date objects in URL state. Don't. Store ISO strings and parse them client-side. new Date('2026-09-09') always parses correctly and serializes cleanly. Date objects in URL params get coerced to strings anyway, and the coercion isn't always what you expect across time zones.

Keyboard Navigation and Accessibility

A calendar grid is a widget with well-defined keyboard expectations from the ARIA authoring practices guide (APG). Users expect: arrow keys to move between days, Enter/Space to select, Page Up/Down to navigate months, Home/End to jump to first/last day of the current week. In 2026 there's no excuse for a calendar that only works with a mouse.

// Add to each day button
onKeyDown={(e) => {
  const keyMap: Record<string, number> = {
    ArrowLeft: -1, ArrowRight: 1,
    ArrowUp: -7, ArrowDown: 7,
  };
  const offset = keyMap[e.key];
  if (offset !== undefined) {
    e.preventDefault();
    const next = new Date(date);
    next.setDate(next.getDate() + offset);
    // Update focused date — you'll need a focusedDate state for this
    setFocusedDate(next);
    // If next date is outside current month, navigate there
    if (next.getMonth() !== viewMonth) {
      setViewMonth(next.getMonth());
      setViewYear(next.getFullYear());
    }
  }
}}

You'll also want tabIndex={0} on the currently focused day and tabIndex={-1} on all others so Tab moves into the grid once and then arrow keys take over. This is called the "roving tabindex" pattern and it's the correct approach for any 2D grid widget. See the react-aria guide for a full walkthrough of roving tabindex with React.

Beyond keyboard, make sure your day cells have role="gridcell", the weekday headers have role="columnheader", and the calendar container has role="grid". These roles allow screen readers like NVDA and VoiceOver to announce the day of the week as users navigate — without them, users just hear a number with no context.

The Empire UI component library ships accessible versions of interactive components out of the box, so if you'd rather not wire all of this yourself, you can grab a polished starting point and customize from there rather than building accessibility from scratch on a deadline.

Styling the Calendar: Themes and Variants

The base component uses Tailwind with indigo as the accent. Swapping to a different visual style is mostly a find-and-replace job on color tokens. But theme-level differences go beyond just color — a glassmorphism calendar, for example, needs backdrop-blur-md on the cell container, a semi-transparent background, and a gradient behind the whole component to have anything to blur against.

// Glassmorphism wrapper — drop this around CalendarGrid
<div className="relative p-1 rounded-2xl bg-white/10 backdrop-blur-md border border-white/20 shadow-xl">
  {/* Gradient background visible through the glass */}
  <div className="absolute inset-0 -z-10 rounded-2xl bg-gradient-to-br from-violet-500 via-purple-600 to-indigo-700" />
  <CalendarGrid events={events} onDateSelect={setSelectedDate} />
</div>

For a neobrutalism variant, drop the blur entirely and go with border-2 border-black shadow-[4px_4px_0px_#000] on the container, border border-black on each cell, and a flat saturated accent color like bg-yellow-400 for today's highlight. The neobrutalism style hub has the full token set if you want to stay consistent with an existing design system.

Dark mode is handled automatically if you're using Tailwind's dark: variant — which the component code above already does with classes like dark:bg-gray-900 and dark:text-gray-200. Make sure your tailwind.config.ts has darkMode: 'class' and you're toggling the dark class on <html>. Check the dark mode implementation guide if that's not already in your setup.

That said, whatever style direction you pick, keep the day cell height consistent. 52px minimum height works at all common screen sizes and gives event dots enough breathing room below the date number. Go below 44px and you'll start violating WCAG's 44x44px touch target minimum on mobile — a real problem on a component users tap constantly.

FAQ

Do I need a library like react-big-calendar for a month view?

No. A month view is CSS Grid plus date arithmetic — you can build it from scratch in under 200 lines. Libraries make sense for complex views like week or agenda, but for month-only calendars the custom build is smaller and easier to style.

How do I handle time zones in a React calendar?

Store and compare dates as UTC ISO strings, and construct Date objects only for display. Never use new Date() for comparison without normalizing hours to midnight first — time zone offsets will cause off-by-one errors on day boundaries.

How do I show a popover with event details on day click?

Lift selectedDate to the parent, then conditionally render a popover or sheet component positioned relative to the clicked cell. Pass the events[key] array for that date as props. A useRef on the cell button gives you an anchor for positioning.

Can I use this calendar in a React form with react-hook-form?

Yes — use the controlled variant (value + onChange props) and wrap it in a Controller from react-hook-form. The onChange callback receives a Date object; call field.onChange with the ISO string or timestamp depending on your schema.

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

Read next

Date Picker in React: react-day-picker v9 and Custom ApproachDate Range Picker in React: Calendar Grid, Presets, Time ZonePortfolio Layout in Tailwind: Grid, Project Cards, About SectionServer-Sent Events in React: Real-Time Data Without WebSockets