Back to Blog
Minimalist workspace with laptop and planner
Tutorial
7 min read

How to Build a JavaScript Weekly Calendar (Week View Guide)

By SimpleCalendarJS Team

SimpleCalendarJS~14 KB gzipped · Zero dependencies · Any framework
Short answer

To build a JavaScript weekly calendar, find the start of the week from getDay() and your chosen first weekday. Render seven day columns next to an hour axis with CSS Grid, then position each event from its start time and duration. If you need drag & drop, overlap handling and locales without writing them yourself, use a library with a week view.

A month grid can't show when an event happens during the day. For shift rosters, class timetables or meeting rooms, you need a JavaScript weekly calendar: seven day columns next to an hour axis, with each event drawn at its real start time and length. This guide builds one in plain JavaScript with CSS Grid, explains the edge cases that make week views harder than they look, and covers when a library is the better choice.

What a JavaScript Weekly Calendar Has to Do

Most week views, whether in FullCalendar's timeGridWeek, DayPilot's viewType: "Week" or a hand-rolled grid, solve the same problems:

  1. Find the week. Given any date, find the first day of its week. Some users start the week on Sunday, others on Monday, and some locales on Saturday.
  2. Draw the time axis. One row per hour (or per 15 or 30 minutes), usually limited to working hours so the grid isn't mostly empty night.
  3. Position events. An event's top offset comes from its start time and its height from its duration.
  4. Handle overlaps. Two meetings at 9:00 have to sit side by side instead of on top of each other.
  5. Navigate. Previous week, next week, today.
  6. Localize. Weekday names, date formats and 12- vs 24-hour times should follow the user's locale.

A first version that does all six fits in about 150 lines. Full-featured libraries are much larger because of what comes after that: drag & drop, resizing, all-day rows, touch, accessibility.

Building a Weekly Calendar in Vanilla JS

Step 1: Find the start of the week

Date.prototype.getDay() returns 0 for Sunday through 6 for Saturday, in the runtime's local time zone. To go back to the first day of the week, subtract the difference between today's weekday and your chosen start day, modulo 7:

function startOfWeek(date, weekStartsOn = 0) { const d = new Date(date.getFullYear(), date.getMonth(), date.getDate()); const diff = (d.getDay() - weekStartsOn + 7) % 7; d.setDate(d.getDate() - diff); return d; } startOfWeek(new Date(2026, 10, 12), 1); // Mon Nov 09 2026 00:00 (local)

Using setDate() instead of subtracting milliseconds matters. Around a daylight saving change, a day is 23 or 25 hours long, so date - 7 * 24 * 60 * 60 * 1000 can land on 23:00 of the wrong day. setDate() works in calendar days and stays correct.

If you'd rather follow the locale than hard-code Monday, Intl.Locale.prototype.getWeekInfo() returns firstDay as 1 (Monday) to 7 (Sunday). MDN lists it as Baseline 2026, newly available, so keep a fallback for older browsers.

Step 2: Lay out the grid with CSS Grid

The grid has eight columns: a narrow time column and seven equal day columns, grid-template-columns: 56px repeat(7, 1fr). The first row holds the day headers and the second holds the hour labels and the day columns. Each day column is position: relative, so events can be absolutely positioned inside it in pixels. That gives minute-level precision without needing a grid row for every 15 minutes.

Step 3: Position events and resolve overlaps

An event's top is (startHour + startMinutes / 60 - firstVisibleHour) * hourHeight, and its height follows from the end time in the same way. Reading the wall-clock hours with getHours()/getMinutes() instead of subtracting timestamps keeps the layout right on daylight saving change days.

For overlaps, sort the events by start time and give each one the first "lane" that's free at its start. Events that overlap form a cluster, and every event in a cluster gets width 100% / lanes.

The complete example

Save this as an HTML file and open it. The sample events are generated relative to the current week, so something always shows.

<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>Weekly calendar</title> <style> body { font: 14px/1.3 system-ui, sans-serif; margin: 24px; } .toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; } .toolbar h2 { font-size: 18px; margin: 0 0 0 12px; } .week-scroll { max-height: 600px; overflow-y: auto; border: 1px solid #e5e7eb; } .week-grid { display: grid; grid-template-columns: 56px repeat(7, 1fr); } .corner, .day-head { position: sticky; top: 0; z-index: 2; background: #fff; padding: 8px 4px; text-align: center; border-bottom: 1px solid #e5e7eb; } .day-head.is-today { color: #4f46e5; font-weight: 600; } .times, .day-col { height: calc(var(--hours) * var(--hour-height)); } .time-label { height: var(--hour-height); box-sizing: border-box; padding: 2px 6px 0 0; text-align: right; font-size: 12px; color: #6b7280; } .day-col { position: relative; border-left: 1px solid #e5e7eb; background: repeating-linear-gradient(to bottom, transparent 0, transparent calc(var(--hour-height) - 1px), #f3f4f6 calc(var(--hour-height) - 1px), #f3f4f6 var(--hour-height)); } .event { position: absolute; box-sizing: border-box; overflow: hidden; padding: 2px 4px; border: 1px solid #fff; border-radius: 4px; background: #4f46e5; color: #fff; font-size: 12px; } .event small { display: block; opacity: 0.85; } </style> </head> <body> <div class="toolbar"> <button id="prev" aria-label="Previous week">&lsaquo;</button> <button id="today">Today</button> <button id="next" aria-label="Next week">&rsaquo;</button> <h2 id="title"></h2> </div> <div class="week-scroll"><div id="grid" class="week-grid"></div></div> <script> const START_HOUR = 7; const END_HOUR = 20; const HOUR_HEIGHT = 48; const WEEK_STARTS_ON = 1; // 0 = Sunday, 1 = Monday const locale = navigator.language; function startOfWeek(date, weekStartsOn = 0) { const d = new Date(date.getFullYear(), date.getMonth(), date.getDate()); d.setDate(d.getDate() - ((d.getDay() - weekStartsOn + 7) % 7)); return d; } function addDays(date, n) { const d = new Date(date); d.setDate(d.getDate() + n); return d; } function sameDay(a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); } function el(tag, className) { const node = document.createElement(tag); node.className = className; return node; } // Sample data relative to the current week const base = startOfWeek(new Date(), WEEK_STARTS_ON); const at = (day, h, m = 0) => { const d = addDays(base, day); d.setHours(h, m, 0, 0); return d; }; const events = [ { title: 'Standup', start: at(0, 9), end: at(0, 9, 15) }, { title: 'Design review', start: at(0, 9), end: at(0, 10, 30) }, { title: 'Client call', start: at(2, 14), end: at(2, 15) }, { title: 'Deploy window', start: at(4, 16, 30), end: at(4, 19) }, ]; // Assign side-by-side lanes to overlapping events function layoutLanes(dayEvents) { const sorted = [...dayEvents].sort((a, b) => a.start - b.start || b.end - a.end); const result = []; let lanes = []; // end time of the last event in each lane let cluster = []; let clusterEnd = 0; const flush = () => { const count = Math.max(...cluster.map(item => item.lane)) + 1; cluster.forEach(item => { item.lanes = count; }); }; for (const ev of sorted) { if (cluster.length && ev.start >= clusterEnd) { flush(); cluster = []; lanes = []; } let lane = lanes.findIndex(end => end <= ev.start); if (lane === -1) { lane = lanes.length; lanes.push(ev.end); } else { lanes[lane] = ev.end; } const item = { ev, lane, lanes: 1 }; cluster.push(item); result.push(item); clusterEnd = Math.max(clusterEnd, ev.end); } if (cluster.length) flush(); return result; } const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric' }); const hourFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric' }); const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }); const titleFmt = new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }); let weekStart = startOfWeek(new Date(), WEEK_STARTS_ON); function render() { const grid = document.getElementById('grid'); grid.innerHTML = ''; grid.style.setProperty('--hours', END_HOUR - START_HOUR); grid.style.setProperty('--hour-height', HOUR_HEIGHT + 'px'); const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)); const today = new Date(); grid.append(el('div', 'corner')); for (const day of days) { const head = el('div', 'day-head' + (sameDay(day, today) ? ' is-today' : '')); head.textContent = dayFmt.format(day); grid.append(head); } const times = el('div', 'times'); for (let h = START_HOUR; h < END_HOUR; h++) { const label = el('div', 'time-label'); label.textContent = hourFmt.format(new Date(2000, 0, 1, h)); times.append(label); } grid.append(times); for (const day of days) { const col = el('div', 'day-col'); const dayStart = new Date(day); dayStart.setHours(START_HOUR, 0, 0, 0); const dayEnd = new Date(day); dayEnd.setHours(END_HOUR, 0, 0, 0); const visible = events.filter(ev => ev.start < dayEnd && ev.end > dayStart); for (const { ev, lane, lanes } of layoutLanes(visible)) { const s = ev.start < dayStart ? dayStart : ev.start; const e = ev.end > dayEnd ? dayEnd : ev.end; const toHours = d => (sameDay(d, day) ? d.getHours() + d.getMinutes() / 60 : END_HOUR); const top = (toHours(s) - START_HOUR) * HOUR_HEIGHT; const height = Math.max((toHours(e) - toHours(s)) * HOUR_HEIGHT, 18); const block = el('div', 'event'); block.style.top = top + 'px'; block.style.height = height + 'px'; block.style.left = (lane / lanes) * 100 + '%'; block.style.width = 100 / lanes + '%'; block.textContent = ev.title; const time = document.createElement('small'); time.textContent = `${timeFmt.format(ev.start)} – ${timeFmt.format(ev.end)}`; block.append(time); col.append(block); } grid.append(col); } document.getElementById('title').textContent = titleFmt.formatRange(weekStart, addDays(weekStart, 6)); } document.getElementById('prev').onclick = () => { weekStart = addDays(weekStart, -7); render(); }; document.getElementById('next').onclick = () => { weekStart = addDays(weekStart, 7); render(); }; document.getElementById('today').onclick = () => { weekStart = startOfWeek(new Date(), WEEK_STARTS_ON); render(); }; render(); </script> </body> </html>

Some details worth noticing:

  • Titles use textContent, not innerHTML, so an event called <img onerror=...> can't run script.
  • All labels come from Intl.DateTimeFormat. Change locale to 'de-DE' and you get German weekday names and 24-hour times with no other code changes. Pass hourCycle: 'h23' if you want to force 24-hour times.
  • formatRange prints "November 2026" for a week inside one month and "November – December 2026" for a week that crosses a month boundary.
  • Events that cross the visible window are clamped to START_HOUR–END_HOUR, so a 6:00 meeting still shows, cut off at the top.

Need a calendar in your app?

SimpleCalendarJS: ~14 KB gzipped, zero dependencies, works in any framework. Free for personal use.

The Hard Parts This Version Skips

The example works, but it isn't a product yet. Before you commit to maintaining your own week view, here's what's still missing:

  • All-day and multi-day events. These need a separate row above the time grid where bars span several columns. That means a second layout algorithm, closer to a month view.
  • Overnight events. A 22:00–02:00 shift has to be split across two columns. The filter above handles the clamping per day, but only if your visible hours include those times.
  • Drag & drop and resizing. You need pointer events, snapping to 15-minute slots, auto-scroll near the edges, touch support with a long-press threshold so scrolling still works, and a callback that reports the new times. This is usually the biggest chunk of code in any calendar library.
  • A "now" indicator that moves every minute and only appears in today's column.
  • Narrow screens. Seven columns at 360 px wide leave about 40 px per day. Most real week views switch to a day or list view on phones.
  • Accessibility. Events need to be focusable, have accessible names that include their times, and work with the keyboard.
  • Data loading. You need to fetch events for the visible week, cache them, and refetch after edits without flicker.

If you need a static timetable, like a class schedule or opening hours, the hand-rolled version may be all you need. Once users have to move things around, building all of the above yourself is rarely worth it.

JavaScript Weekly Calendar Libraries Compared

These libraries ship a working week view. Facts are from each project's own pages, as of November 2026.

LibraryWeek viewLicenseNotes
SimpleCalendarJSdefaultView: 'week'Free for personal, educational and open source use; commercial license required~14 KB gzipped JS, zero dependencies; month, week, day and list views; no resource views
EventCalendar (vkurko)timeGridWeek, plus resourceTimeGridWeekMIT~35 kB Brotli, zero dependencies in the standalone bundle; FullCalendar-like options; resource and timeline views
DayPilot Lite for JavaScriptviewType: "Week"Apache 2.0Day, week, work week, month and resource calendar views
FullCalendartimeGridWeek via timeGridPluginNon-premium plugins MIT, premium plugins paidslotDuration, slotMinTime, slotMaxTime control the time grid

Here's how to choose:

  • People or rooms side by side (a resource week view): EventCalendar, DayPilot Lite or FullCalendar's premium plugins. SimpleCalendarJS doesn't have resource views.
  • Custom slot length or visible hours, such as 15-minute slots from 08:00 to 18:00: FullCalendar's slotDuration/slotMinTime/slotMaxTime, or the similar options in EventCalendar.
  • A lightweight, framework-agnostic week view with month, day and list views, drag & drop and picker modes in one small package: that's where SimpleCalendarJS fits.

For a broader look at lightweight options, see Vanilla JS Calendar Libraries: No Framework Required.

Building a Weekly Calendar With SimpleCalendarJS

SimpleCalendarJS handles the parts the hand-rolled version skipped: the time grid, overlaps, drag to move, drag to resize (mouse and touch), tooltips, locale-aware labels and dark mode. You give it one async function that returns events for the visible range.

Weekly calendars often contain weekly repeating items, like a yoga class every Tuesday or a standup every weekday. SimpleCalendarJS has no RRULE support, so the example expands simple weekly rules inside fetchEvents. That function is called with the visible start and end, so you only generate instances for the week on screen.

npm install simple-calendar-js
<div id="calendar" style="height: 700px"></div>
import SimpleCalendarJs from 'simple-calendar-js'; import 'simple-calendar-js/dist/simple-calendar-js.min.css'; // One-off events (in a real app these come from your API) const events = [ { id: 1, title: 'Client call', start: '2026-11-11T14:00', end: '2026-11-11T15:00', color: '#4f46e5' }, { id: 2, title: 'Deploy window', start: '2026-11-13T16:30', end: '2026-11-13T19:00', color: '#dc2626' }, ]; // Simple weekly rules: weekday uses getDay() numbering (0 = Sunday) const weeklyRules = [ { id: 'yoga', title: 'Yoga class', weekday: 2, from: [18, 0], to: [19, 0], color: '#059669' }, { id: 'review', title: 'Sprint review', weekday: 5, from: [11, 0], to: [12, 0], color: '#d97706' }, ]; function expandWeekly(rules, rangeStart, rangeEnd) { const out = []; const day = new Date(rangeStart.getFullYear(), rangeStart.getMonth(), rangeStart.getDate()); while (day < rangeEnd) { for (const rule of rules) { if (day.getDay() !== rule.weekday) continue; const start = new Date(day); start.setHours(rule.from[0], rule.from[1], 0, 0); const end = new Date(day); end.setHours(rule.to[0], rule.to[1], 0, 0); out.push({ id: `${rule.id}-${day.toDateString()}`, title: rule.title, start, end, color: rule.color, recurring: true, // extra fields are passed back in callbacks }); } day.setDate(day.getDate() + 1); } return out; } const calendar = new SimpleCalendarJs('#calendar', { defaultView: 'week', enabledViews: ['week', 'day', 'list'], weekStartsOn: 1, locale: 'en-GB', use24Hour: true, enableDragDrop: true, enableResize: true, autoContrastText: true, fetchEvents: async (start, end) => [ ...events.filter(e => new Date(e.start) < end && new Date(e.end) > start), ...expandWeekly(weeklyRules, start, end), ], onEventDrop: (event, oldStart, oldEnd, newStart, newEnd) => { if (event.recurring) { // Store an exception for this occurrence, or just reload to snap it back calendar.refresh(); return; } const target = events.find(e => e.id === event.id); if (target) { target.start = newStart; target.end = newEnd; // Persist here, e.g. fetch('/api/events/' + target.id, { method: 'PATCH', ... }) } }, onSlotClick: (date) => { const title = prompt('New event title'); if (!title) return; events.push({ id: Date.now(), title, start: date, end: new Date(date.getTime() + 60 * 60 * 1000), }); calendar.refresh(); }, onEventClick: (event) => { alert(`${event.title}\n${new Date(event.start).toLocaleString('en-GB')}`); }, });

To change the look of the time grid, use CSS custom properties. --cal-hour-height sets the height of each hour row and --cal-time-col-width sets the width of the time column:

.uc-calendar { --cal-hour-height: 56px; --cal-time-col-width: 64px; --cal-primary: #4f46e5; --cal-now-color: #dc2626; }

What it doesn't do, so you can decide early:

  • No option to hide weekends or show a five-day work week, and no setting for slot length or visible hours.
  • No resource columns (one column per person or room). For that, use EventCalendar, DayPilot Lite or FullCalendar's premium plugins.
  • No timezone option. Events show in the browser's local time, so convert server timestamps before you return them from fetchEvents.
  • No built-in event form. onSlotClick and onEventClick are where you open your own modal. The prompt() above is only a placeholder.

In React, Vue, Svelte or Angular, create the instance in the component's mount hook and call destroy() on unmount. For complete weekly use cases, see How to Build a School Class Schedule Calendar and How to Build a Shift Scheduling Calendar. To match your brand or add a dark theme, Customising Your JavaScript Calendar lists every variable. You can try the week view in the sandbox or read the full option list in the docs.

SimpleCalendarJS is free for personal, educational and open source projects. Commercial projects need a license; see pricing.

Need a calendar in your app?

SimpleCalendarJS: ~14 KB gzipped, zero dependencies, works in any framework. Free for personal use.

Hand-Rolled or Library: A Quick Decision Guide

Your situationRecommendation
Read-only timetable, fixed hours, few eventsHand-rolled grid like the example above
Users drag, resize or create eventsA library; drag & drop is the hardest part to get right
One column per person, room or machineEventCalendar, DayPilot Lite or FullCalendar premium
Week, month, day and list views plus a date picker, small bundle, any frameworkSimpleCalendarJS
Complex recurrence (every second Tuesday, exceptions, RRULE import)Expand with a recurrence library, then pass the instances to any calendar

Either way, handle dates the same way: work in local calendar days with setDate(), read wall-clock times with getHours(), avoid date-only ISO strings (they parse as UTC midnight), and let Intl.DateTimeFormat produce every label you show.

Sources & Further Reading

Image Credits

Image free to use under the Pexels License.

Frequently Asked Questions

How do I make a JavaScript week start on Monday?

Use the offset (date.getDay() - 1 + 7) % 7. That is how many days to go back from any date to reach Monday. In browsers that support it, new Intl.Locale(locale).getWeekInfo().firstDay gives the locale's first day as 1 (Monday) to 7 (Sunday), and firstDay % 7 turns it into the 0–6 numbering getDay() uses.

How do I show localized weekday names in a weekly calendar?

Don't hard-code an array of day names. Use new Intl.DateTimeFormat(locale, { weekday: 'short' }).format(date). Use 'long' for full names and 'narrow' for single letters. In some locales the narrow forms repeat: Tuesday and Thursday are both 'T' in en-US, so narrow labels work best next to the date number.

Why do my events appear on the wrong day in the week view?

Date-only strings like '2026-11-13' are parsed as UTC midnight, so in time zones west of UTC they land on the previous local day. Use new Date(2026, 10, 13) or a local date-time string such as '2026-11-13T09:00', or convert server timestamps to local time before you render.

Can I hide weekends in a JavaScript week view?

In a hand-rolled grid, render five columns instead of seven and change grid-template-columns to match. SimpleCalendarJS has no option to hide weekends or show a five-day work week. DayPilot Lite's calendar offers a work week view if that matters to you.

How do I add recurring weekly events to a calendar?

Store the rule, such as a weekday plus start and end times, and expand it into concrete events for the visible date range before rendering. With SimpleCalendarJS, do the expansion inside fetchEvents(start, end). It has no built-in RRULE support, so a library like rrule.js can help with complex patterns.

How do I show 24-hour times in a weekly calendar?

In your own code, pass hourCycle: 'h23' or hour12: false to Intl.DateTimeFormat along with hour and minute options. In SimpleCalendarJS, set use24Hour: true. If you leave both unset, the locale decides, so en-US gets 12-hour times and most European locales get 24-hour times.

Add a calendar to your app today

Free for personal projects. $49/year or $199 lifetime per commercial project.

Keep Reading