JavaScript Calendar: Build a Month Grid with Events

A JavaScript calendar month grid showing event markers and navigation controls

Build a JavaScript calendar that renders a navigable month grid and marks events from structured data with native JavaScript. Keep month state separate from event data so each navigation click can rerender the grid without losing the current view.

Keep the calendar JavaScript in one module: the same render function can serve the initial load and every previous or next month action.

Set up the JavaScript calendar HTML and event data

Give the interface three hooks: a month label, previous and next buttons, and an empty grid. For example, use data-calendar-label, data-calendar-previous, data-calendar-next, and data-calendar-grid attributes. Query them with document.querySelector so the widget does not depend on a particular framework.

Store events with normalized local date keys. The date uses a four-digit year, two-digit month, and two-digit day:

const events = [{ date: ‘2025-01-08’, title: ‘Planning review’ }, { date: ‘2025-01-21’, title: ‘Release’ }];

Build an index once, outside the renderer, so multiple events can share a date:

const eventsByDate = events.reduce((map, event) => { const list = map.get(event.date) || []; list.push(event); map.set(event.date, list); return map; }, new Map());

Use a state object such as const state = { year: 2025, month: 0 }; JavaScript months are zero-based: 0 means January and 11 means December. The displayed month number is therefore the internal month plus one.

Calculate first weekdays, month lengths, and grid cells

Calculate the first weekday and number of days whenever the month is rendered:

const firstWeekday = new Date(state.year, state.month, 1).getDay();
const daysInMonth = new Date(state.year, state.month + 1, 0).getDate();

getDay() returns Sunday as 0 and Saturday as 6. The second expression uses day 0 of the following month, which returns the final day of the selected month. This avoids hard-coded month lengths and handles leap years.

Reserve enough cells for the leading blank cells before day 1 and the trailing cells after the final day:

const cellCount = Math.ceil((firstWeekday + daysInMonth) / 7) * 7;

For cell index i, calculate the visible day with const day = i – firstWeekday + 1;. Values below 1 are leading cells; values above daysInMonth are trailing cells. Render those positions as empty, noninteractive cells rather than inventing dates.

Render a JavaScript calendar widget with event markers

Clear the grid at the start of every render, then loop from 0 to cellCount – 1. For each valid day, create a button or cell and generate a stable key:

const toKey = (year, month, day) => `${year}-${String(month + 1).padStart(2, ‘0’)}-${String(day).padStart(2, ‘0’)}`;

For each valid day, create a button, set button.dataset.date = key, and set its visible text to the day number. Retrieve matching events with const dayEvents = eventsByDate.get(key) || []; Then add an event marker when the array is not empty, such as a bullet or count. Use the event titles for the button’s accessible label or title, for example, dayEvents.map(event => event.title).join(‘, ‘).

This stable date key lets click handlers identify a day without parsing its displayed number. It also keeps events attached correctly when the month changes. A JavaScript calendar widget can use the same structure for birthdays, appointments, or release dates.

Add previous and next controls to a JavaScript event calendar

Attach click handlers that update the zero-based month and then call render():

previous.addEventListener(‘click’, () => { if (state.month === 0) { state.month = 11; state.year–; } else state.month–; render(); });

next.addEventListener(‘click’, () => { if (state.month === 11) { state.month = 0; state.year++; } else state.month++; render(); });

Update the label inside render() with new Intl.DateTimeFormat(‘en’, { month: ‘long’, year: ‘numeric’ }).format(new Date(state.year, state.month, 1)). Keep the state object and event index outside the renderer. Reinitializing either one inside render() would reset navigation or discard event data. Call render() once after wiring the controls to display the initial month.