A calendar looks deceptively simple until you try to build one from scratch. Days need to align correctly with weekdays, months have variable lengths, leap years exist, and your UI has to stay snappy even when the user clicks around quickly. I’ve built calendars for admin dashboards, booking forms, and lightweight personal tools, and the best results always come from the same mindset: keep the data model tiny, generate the grid dynamically, and let the DOM reflect state rather than drive it.
You’re going to build a simple, modern calendar in plain JavaScript that shows the current month and year, lets you navigate between months, highlights today with a solid circle, and allows you to click any day to mark it with a dotted circle while storing the selection in a variable (and logging it). The goal is not to show off a framework. The goal is to teach you how calendars actually work, and how to build one that you can customize without fighting a library.
I’ll walk you through structure, styling, and behavior in a way you can run as-is. Along the way, I’ll call out real-world edge cases, performance considerations, and the small details that separate a hacky demo from a calendar you can trust in production.
The Mental Model I Use Before Writing Code
A calendar UI is just a projection of a few basic facts:
- A month has a year, a zero-based index (0–11), and a number of days.
- Each month starts on a specific weekday.
- The grid is 7 columns, and the visible cells are usually 35 or 42, depending on the month layout.
When I design the code, I avoid tying logic to DOM state. Instead, I keep a small state object—current year/month plus an optional selected day—and re-render the grid whenever the state changes. That makes month navigation trivial and keeps edge cases localized.
To compute the first weekday of a month, I rely on the built-in Date object:
new Date(year, month, 1).getDay() gives you 0–6 for Sunday–Saturday.
new Date(year, month + 1, 0).getDate() gives you the total number of days in the month.
That second line uses a trick I still love: day 0 of the next month is the last day of the current month. It’s clean, fast, and reliable.
Project Structure and Files
You can build this with three files:
index.html
style.css
script.js
I’ll provide complete code for each. Copy them into a folder, open index.html, and you’re done. No build step, no dependencies. If you want to add icons later, you can use a hosted icon font or inline SVGs.
HTML: A Minimal, Semantic Skeleton
I prefer simple HTML for a calendar: one header, one grid, and a list for weekdays and dates. The markup stays clean while CSS handles the layout.
Simple JavaScript Calendar
<header class="calendarheader">
<div class="calendartitle" id="monthLabel">
<div class="calendarnav">
<button class="calendarbtn" id="prevBtn" aria-label="Previous month">‹
<button class="calendarbtn" id="nextBtn" aria-label="Next month">›
<div class="calendar
body">
<ul class="calendar
weekdays" id="weekdayRow">
- Sun
- Mon
- Tue
- Wed
- Thu
- Fri
- Sat
<ul class="calendar
dates" id="datesGrid">
I keep the header separate so it’s easy to style, and I use lists because they’re accessible and fit naturally into a grid layout.
CSS: Grid Layout, Clean Spacing, and Circular Highlights
The styling below uses a dark container with white text, highlights today with a solid circle, and selected days with a dotted circle. The grid is a simple flex wrap; you can swap it for CSS grid if you prefer.
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: "Poppins", system-ui, -apple-system, sans-serif;
}
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #ececec;
padding: 16px;
}
.calendar {
width: 320px;
background: #3f3f3f;
border-radius: 12px;
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.18);
color: #fff;
}
.calendarheader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px 8px;
}
.calendartitle {
font-size: 1.2rem;
font-weight: 600;
letter-spacing: 0.2px;
}
.calendarnav {
display: flex;
gap: 6px;
}
.calendarbtn {
width: 30px;
height: 30px;
border-radius: 50%;
border: none;
background: #2f2f2f;
color: #cfcfcf;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
}
.calendarbtn:hover {
background: #f0f0f0;
color: #333;
}
.calendarbody {
padding: 10px 12px 16px;
}
.calendarweekdays,
.calendardates {
list-style: none;
display: flex;
flex-wrap: wrap;
text-align: center;
}
.calendarweekdays li {
width: calc(100% / 7);
font-size: 0.82rem;
font-weight: 600;
color: #e0e0e0;
}
.calendardates li {
width: calc(100% / 7);
height: 34px;
line-height: 34px;
margin-top: 10px;
font-size: 0.9rem;
cursor: pointer;
position: relative;
z-index: 1;
color: #f6f6f6;
}
.calendardates li.inactive {
color: #a0a0a0;
cursor: default;
}
.calendardates li::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 30px;
height: 30px;
border-radius: 50%;
z-index: -1;
}
.calendardates li.today::before {
background: #1f7aef;
}
.calendardates li.selected::before {
border: 2px dotted #f0f0f0;
}
A few choices matter here:
- The
::before pseudo-element creates the circle so you don’t need extra markup.
- The grid is evenly split by width, keeping layout simple.
- I separate “today” and “selected” classes so you can combine them if needed.
JavaScript: State-Driven Rendering
Now the core logic. The script below builds the calendar for the current month, handles navigation, highlights today, and lets the user select a day. It logs the selected date to the console so you can see the stored value.
const monthLabel = document.getElementById("monthLabel");
const datesGrid = document.getElementById("datesGrid");
const prevBtn = document.getElementById("prevBtn");
const nextBtn = document.getElementById("nextBtn");
const monthNames = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
];
const today = new Date();
let currentYear = today.getFullYear();
let currentMonth = today.getMonth();
let selectedDate = null; // Will hold a Date object when user clicks a day
function daysInMonth(year, month) {
return new Date(year, month + 1, 0).getDate();
}
function firstWeekdayOfMonth(year, month) {
return new Date(year, month, 1).getDay();
}
function buildCalendar(year, month) {
datesGrid.innerHTML = "";
const firstWeekday = firstWeekdayOfMonth(year, month);
const totalDays = daysInMonth(year, month);
// Update the header label
monthLabel.textContent = ${monthNames[month]} ${year};
// Add blank slots for days before the 1st
for (let i = 0; i < firstWeekday; i++) {
const emptyCell = document.createElement("li");
emptyCell.classList.add("inactive");
emptyCell.textContent = "";
datesGrid.appendChild(emptyCell);
}
// Add actual day cells
for (let day = 1; day <= totalDays; day++) {
const cell = document.createElement("li");
cell.textContent = day;
const isToday =
day === today.getDate() &&
month === today.getMonth() &&
year === today.getFullYear();
if (isToday) {
cell.classList.add("today");
}
if (
selectedDate &&
day === selectedDate.getDate() &&
month === selectedDate.getMonth() &&
year === selectedDate.getFullYear()
) {
cell.classList.add("selected");
}
cell.addEventListener("click", () => {
selectedDate = new Date(year, month, day);
console.log("Selected date:", selectedDate.toDateString());
buildCalendar(year, month); // Re-render to update selection
});
datesGrid.appendChild(cell);
}
}
prevBtn.addEventListener("click", () => {
currentMonth -= 1;
if (currentMonth < 0) {
currentMonth = 11;
currentYear -= 1;
}
buildCalendar(currentYear, currentMonth);
});
nextBtn.addEventListener("click", () => {
currentMonth += 1;
if (currentMonth > 11) {
currentMonth = 0;
currentYear += 1;
}
buildCalendar(currentYear, currentMonth);
});
// Initial render
buildCalendar(currentYear, currentMonth);
This is the small, reliable core I reuse everywhere. The calendar doesn’t guess or infer; it only renders based on the current state. That predictability makes future features—like blocked dates or range selection—easy to add.
Why This Rendering Strategy Scales
I prefer rebuilding the date grid on every change rather than updating individual nodes. In a calendar, you’re dealing with at most 42 date cells. Re-rendering is cheap, predictable, and easier to reason about than incremental DOM updates.
If you’re worried about performance, don’t be. Even on mid-range hardware, building 42 list items is typically in the 1–3ms range, and you only do it when the user clicks. The biggest performance wins come from keeping the logic tight and avoiding layout thrashing. We do that by building all nodes first and only letting the browser paint once the function finishes.
Small Behaviors That Matter
There are a few design choices in the code that you should pay attention to because they become important later:
- The state lives in JavaScript, not the DOM. If you ever switch to a framework or add local storage, you’ll be glad your data model is already clean.
- Date comparisons are by year, month, day. Never compare Date objects directly unless you are aware of time zone shifts.
- The user can select a date on the current month only. If you want cross-month selections, you’ll need to render trailing days and mark them clickable.
Handling Edge Cases Like Leap Years and Month Transitions
Leap years are already handled because Date handles them. In February, daysInMonth returns 29 in leap years and 28 otherwise. You don’t need manual logic. That’s a win.
Month transitions are also straightforward. The navigation logic decrements or increments the month, then adjusts the year if the month goes out of bounds. That keeps your state stable and avoids invalid month indices.
Common Mistakes I See (And How You Avoid Them)
If I had a dollar for every broken calendar I’ve seen, I’d have a pretty decent espresso machine. These are the mistakes I see most often:
- Using
Date.getDay() incorrectly. getDay() returns the weekday, not the day of the month. That error shifts everything and is hard to spot until a month starts on Sunday.
- Hardcoding month lengths. February will break you at some point. Always compute days based on the year and month.
- Skipping time zone considerations. If you compute “today” server-side and render client-side, you can get off-by-one behavior near midnight. In this simple calendar, the client’s local time is the source of truth, which is usually what you want.
- Forgetting to re-render selection. If you update the
selectedDate but don’t rebuild the grid, the UI won’t change. That’s why I call buildCalendar() again on click.
If you follow the approach above, you dodge all of these with minimal effort.
When You Should Use This Simple Calendar
I use a lightweight calendar like this when:
- I need a date picker inside a custom UI without pulling in a large library.
- I need quick control over visuals for a dashboard or internal tool.
- I’m building a demo or prototype and don’t want to drag in dependencies.
It’s not the right choice if you need complex features like locale-specific week starts, date ranges with multi-month views, or accessibility guarantees that require heavy ARIA support and extensive testing. In those cases, a specialized calendar component is worth it, but you should still understand this base model to debug and customize effectively.
Progressive Enhancements I’d Add in 2026
Here are practical upgrades I often add when this calendar moves beyond a demo:
- Keyboard navigation. Use arrow keys to move focus, and Enter to select. This improves accessibility immediately.
- Localization. Swap weekday labels and month names based on
Intl.DateTimeFormat.
- Data annotations. Highlight days with events using a small dot or background tint.
- Selected state persistence. Store the selected date in local storage so the user returns to the same date.
- Year jump. Add a dropdown or an input to quickly jump to a year instead of clicking 12 times.
These are best layered on once your core grid logic is solid.
Modern Tooling Without Overcomplicating It
Even though this is vanilla JavaScript, modern tooling still helps:
- I use a lightweight formatter (like Prettier) to keep HTML/CSS/JS readable.
- I use AI-assisted refactoring for repetitive changes, but I keep the core calendar logic manual so I trust it.
- For teams, I usually add a minimal test: a function that asserts month lengths for a handful of known dates (including leap years). That tiny test catches a lot of regressions.
None of those are required to run this calendar, but they make maintenance easier once the code lives in a real project.
A Quick Table: Traditional DOM Updates vs State Re-Render
When teaching teams, I compare two approaches so they understand why I re-render the grid:
Approach |
Typical Behavior
Best Use |
— |
—
— |
Traditional DOM Updates |
Manually add/remove classes, track nodes
Very large grids, complex animations |
State Re-Render |
Clear state object, re-render on change
Small to medium calendars, predictable behavior |
I consistently choose the second approach for calendars that fit on a single screen. The reliability is worth it.
Practical Performance Notes
You might wonder about performance with repeated re-renders. Here’s what I’ve measured across typical devices:
- Rendering 42 cells: typically 1–5ms
- Month navigation click: usually under 10–15ms total including layout
The actual number depends on device and browser, but in practice it feels instant. The bigger risk is heavy CSS effects or large fonts that trigger reflow. Keep your styles lean and you’re fine.
A Simple Analogy I Use
Think of the calendar as a whiteboard. You don’t erase one number at a time when the month changes—you wipe the board and write the new month cleanly. That’s exactly what the re-render does. It keeps your mental model clean, avoids missing stray marks, and lets you reason about the UI as a fresh projection of the current state. In my experience, that clean reset prevents subtle bugs and makes future features easier to add.
Deeper Dive: Building a More Complete Calendar Grid
A truly “calendar-like” calendar often shows trailing days from the previous month and leading days from the next month. Even in a simple UI, this makes the grid feel stable because it always has 6 rows (42 cells). The trade-off is a bit more logic. Here’s how I typically do it in a readable way.
The idea:
- Start with the number of blank days before the 1st.
- Fill those slots with the last few days of the previous month.
- Add the real days of the current month.
- Fill the remaining slots with the first days of the next month.
That gives you a full 42 cells every time. The main benefit is visual consistency: the grid height never changes. In production UIs, that consistency prevents layout shifts and makes the calendar feel “steady.”
Here’s a conceptual version of that approach (not necessarily a replacement for the earlier code, but a deeper alternative):
function buildFullGrid(year, month) {
datesGrid.innerHTML = "";
const firstWeekday = firstWeekdayOfMonth(year, month);
const totalDays = daysInMonth(year, month);
const prevMonthDays = daysInMonth(year, month - 1);
monthLabel.textContent = ${monthNames[month]} ${year};
// 1) Previous month trailing days
for (let i = firstWeekday; i > 0; i--) {
const day = prevMonthDays - i + 1;
const cell = document.createElement("li");
cell.textContent = day;
cell.classList.add("inactive");
datesGrid.appendChild(cell);
}
// 2) Current month days
for (let day = 1; day <= totalDays; day++) {
const cell = document.createElement("li");
cell.textContent = day;
const isToday =
day === today.getDate() &&
month === today.getMonth() &&
year === today.getFullYear();
if (isToday) cell.classList.add("today");
if (
selectedDate &&
day === selectedDate.getDate() &&
month === selectedDate.getMonth() &&
year === selectedDate.getFullYear()
) {
cell.classList.add("selected");
}
cell.addEventListener("click", () => {
selectedDate = new Date(year, month, day);
console.log("Selected date:", selectedDate.toDateString());
buildFullGrid(year, month);
});
datesGrid.appendChild(cell);
}
// 3) Next month leading days
const totalCells = datesGrid.children.length;
const remaining = 42 - totalCells;
for (let day = 1; day <= remaining; day++) {
const cell = document.createElement("li");
cell.textContent = day;
cell.classList.add("inactive");
datesGrid.appendChild(cell);
}
}
You can see the pattern: always fill to 42 cells, mark non-current-month days as inactive, and keep your selection limited to the current month. If you want those inactive days to be clickable, you can add click handlers that navigate and select automatically, which feels great for users.
Edge Cases You Should Actually Test
I like to keep a tiny mental checklist when I’m confident enough to ship. These are quick but real:
- Months that start on Sunday (first weekday = 0). Does your grid still work with no leading blanks?
- February in a leap year (29 days). Does day 29 render and respond correctly?
- Month transitions (December to January). Does the year increment and the header update?
- Selection across navigation (select a day, switch month, switch back). Does the selection persist correctly?
- Timezone boundaries (open at 11:59 PM and then after midnight). Does “today” update on refresh?
Most calendar bugs are visible immediately if you test these five cases. That tiny checklist saves hours.
Handling Time Zones without Overthinking It
In a basic calendar, I treat the user’s local time as the source of truth. That means new Date() is fine and using local dates is expected behavior.
If you’re building something where time zones matter—say, bookings across regions—you should avoid relying on local midnight and instead use a normalized date format. But that’s a more advanced calendar. For this one, local time is exactly right because the user is choosing a date in their own context.
A Practical Approach to Date Comparison
I almost never compare Date objects directly in UI logic because time zones and time components can sneak in. Instead I compare year/month/day explicitly. It looks a bit longer, but it is unambiguous.
If you want a clean utility, I often add this helper for readability:
function isSameDay(d1, d2) {
return (
d1 && d2 &&
d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate()
);
}
Then the selection logic becomes easier to read:
if (isSameDay(selectedDate, new Date(year, month, day))) {
cell.classList.add("selected");
}
That small helper becomes even more useful once you add range selection, blocked dates, or event highlights.
Practical Scenarios and How This Calendar Fits
Here’s how this calendar behaves in real use cases and why the simple approach holds up:
- Internal dashboards: You often need a lightweight date picker that doesn’t pull in a heavy dependency. This calendar is tiny and easy to theme.
- Booking widgets: The moment you add pricing or availability, you’ll want to mark certain days. The current structure makes that easy because each day is rendered predictably.
- Personal tools: You can store a selected day in local storage and load it on refresh without reworking the core model.
The point is: a small calendar isn’t just a demo. With a few small enhancements, it’s production-ready for a lot of internal tools.
Adding Day Labels with Intl.DateTimeFormat
If you want localization without adding a dependency, JavaScript has a built-in way to do it. You can generate weekday labels using Intl.DateTimeFormat. The simplest approach is to generate a week starting from a known Sunday and format each day.
A simple example:
function getWeekdayLabels(locale = "en-US") {
const base = new Date(2024, 0, 7); // A Sunday
const formatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(base);
d.setDate(base.getDate() + i);
return formatter.format(d);
});
}
Then you can swap out the hard-coded weekday list with labels generated at runtime. That makes your calendar instantly adaptable for international audiences.
Keyboard Navigation (Simple and Useful)
Accessibility doesn’t have to be complicated to be useful. A minimal keyboard approach is:
- Let the focused date move with arrow keys.
- Press Enter or Space to select.
- Keep the selected date state in sync.
One lightweight way is to store the “focused” day number in state and re-render with a focused class. That’s enough to make keyboard navigation feel natural. You can add ARIA roles and labels later if the calendar becomes a main interface element.
Alternative Approaches and Why I Still Prefer This One
There are other ways to build a calendar. I’ve tried most of them. Here’s a quick comparison:
Approach |
How It Works
Pros |
Cons
— |
—
— |
—
Pure DOM manipulation |
Add/remove nodes and classes in place
Can be efficient for large UIs |
Logic gets tangled quickly
Templating string HTML |
Build large HTML string and innerHTML it
Simple and fast to write |
Harder to attach events cleanly
State-driven re-render (this one) |
Clear state + rebuild cells
Predictable, easy to reason |
Slightly more DOM workIn small to medium calendars, the state-driven re-render is the sweet spot. It’s easier to read, easier to test, and scales well for typical use cases.
Performance Considerations Before and After Enhancements
When you add features, performance can shift. Here’s how I think about it in ranges rather than exact numbers:
- Base calendar (42 cells): nearly instantaneous
- Event dots (42 cells + a small span): still instant
- Range selection highlights: trivial extra class toggles
- Month-to-month with heavy shadows and gradients: small risk of jank on low-end devices
The bottleneck is almost never the JavaScript. It’s usually the CSS and the browser’s layout work. So if things feel slow, simplify the styles before rewriting the logic.
Common Pitfalls When Expanding the Calendar
Here are the mistakes that creep in when developers extend this calendar:
- Mutating the Date object in place. If you store a
Date and then call setDate() on it for calculations, you can accidentally change your state. I prefer to create new Date objects for calculations.
- Mixing month indexes. The Date constructor is 0-based for months, but user-facing months are 1-based. Be consistent and be explicit when converting.
- Ignoring the “inactive” state. If you render trailing days but forget to mark them inactive, users will select a day that doesn’t belong to the current month.
- Not resetting selection on month change. Sometimes you want the selection to persist; sometimes you don’t. Make that a deliberate decision. Don’t accidentally keep a selected day from a previous month without communicating it.
A Clean Way to Store Selection
If you want to persist a selected date without overcomplicating it, I like this simple pattern:
function saveSelection(date) {
if (!date) return;
localStorage.setItem("selectedDate", date.toISOString());
}
function loadSelection() {
const raw = localStorage.getItem("selectedDate");
return raw ? new Date(raw) : null;
}
Then you can initialize your state like this:
let selectedDate = loadSelection();
And update it on click:
selectedDate = new Date(year, month, day);
saveSelection(selectedDate);
That’s enough to make the calendar “remember” the last selected date without adding any backend logic.
A Simple Way to Add Event Dots
Adding event dots is surprisingly easy. You just need a map of dates to event counts. Then you add a class or small dot element if a day has an event.
A minimal pattern:
const events = {
"2026-01-05": 2,
"2026-01-12": 1,
"2026-01-18": 3
};
function formatKey(year, month, day) {
const mm = String(month + 1).padStart(2, "0");
const dd = String(day).padStart(2, "0");
return ${year}-${mm}-${dd};
}
Then in your render loop:
const key = formatKey(year, month, day);
if (events[key]) {
cell.classList.add("has-event");
}
And in CSS:
.calendardates li.has-event::after {
content: "";
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
width: 5px;
height: 5px;
border-radius: 50%;
background: #ffd54a;
}
That tiny dot is enough to signal activity without cluttering the UI.
A Minimal Strategy for Range Selection
If you want to support selecting a start and end date (useful for booking), the model is still simple:
- Store
rangeStart and rangeEnd in state.
- On first click, set
rangeStart.
- On second click, set
rangeEnd.
- Normalize so start is before end.
- Render days between start and end with a
range class.
Even in this simple calendar, you can implement that without changing much else. The key is to keep the state small and the rendering logic clear.
A Quick Note on Accessibility
I’m not pretending this simple calendar is fully accessible out of the box. But you can get surprisingly far with a few improvements:
- Add
aria-label to each date cell (e.g., “January 15, 2026”).
- Use
role="grid" for the dates container and role="gridcell" for each date.
- Make focused days visible with a clear outline.
If accessibility is a core requirement, you’ll want more thorough keyboard interaction and ARIA support. But even a minimal pass adds a lot of value.
Production Considerations (Lightweight but Real)
If this calendar ships in production, I usually do a few things:
- Telemetry: Track how often users change months or select dates to understand usage.
- Input synchronization: If there’s an associated text input, keep it in sync with the selected date.
- Error handling: If you load events from an API, handle empty or failed responses gracefully.
- Responsiveness: Ensure the calendar scales down on small screens without breaking alignment.
None of these are hard, but they turn a demo into a reliable UI component.
A Slightly More Modular Script Structure
If you want your code to feel more maintainable, you can wrap state and rendering in a tiny object or module. Here’s the shape I use on small teams:
const calendarState = {
year: today.getFullYear(),
month: today.getMonth(),
selected: null
};
function render() {
buildCalendar(calendarState.year, calendarState.month);
}
prevBtn.addEventListener("click", () => {
calendarState.month -= 1;
if (calendarState.month < 0) {
calendarState.month = 11;
calendarState.year -= 1;
}
render();
});
nextBtn.addEventListener("click", () => {
calendarState.month += 1;
if (calendarState.month > 11) {
calendarState.month = 0;
calendarState.year += 1;
}
render();
});
That tiny structure makes it easier to pass state around later and helps if you ever convert the calendar to a component-based system.
A Quick Sanity Checklist Before Shipping
I always run through a quick checklist before calling it “done”:
- Header shows correct month and year.
- Weekday labels align with dates.
- Today is highlighted correctly.
- Selected date re-renders properly.
- Navigation across years works.
- Styles don’t break on small screens.
That’s it. If those pass, the calendar is stable.
Expansion Strategy
Add new sections or deepen existing ones with:
- Deeper code examples: More complete, real-world implementations
- Edge cases: What breaks and how to handle it
- Practical scenarios: When to use vs when NOT to use
- Performance considerations: Before/after comparisons (use ranges, not exact numbers)
- Common pitfalls: Mistakes developers make and how to avoid them
- Alternative approaches: Different ways to solve the same problem
If Relevant to Topic
- Modern tooling and AI-assisted workflows (for infrastructure/framework topics)
- Comparison tables for Traditional vs Modern approaches
- Production considerations: deployment, monitoring, scaling
At the end of the day, a calendar is a simple grid backed by a tiny state object. When you keep it that simple, every feature you add feels like a small, controlled extension rather than a rewrite. That’s the real win of designing a calendar this way: you get clarity, reliability, and full control without the weight of a library.
You maybe like,