Building a Google-Calendar-Style Week View Scheduler With One Coordinate Transform

Build a Google-Calendar-style week view scheduler in vanilla JS: CSS Grid frame, draggable events, 30-minute snapping and a live now-line explained.
Google-Calendar-Style Week View Scheduler

A week-view calendar looks like the kind of component you install rather than write. Blocks floating at exact times, dragging one from Tuesday afternoon to Thursday morning, times snapping to half-hours as your hand moves, a red line marking right now — it reads as "go get FullCalendar." The Week View Scheduler snippet does all of it in about a hundred lines of vanilla JavaScript, and the reason it can is that the entire component is one linear equation, written once forwards for rendering and once backwards for dragging.

Here it is live — grab any event block and drag it around the week:

Grab the code, or open the full editor with live HTML/CSS/JS panels: Week View Scheduler on FWD Tools. The snippet is plain HTML, CSS, and JavaScript, but the same editor has one-click export buttons for React, Vue, Angular, and React + Tailwind if you'd rather drop it into a component-based project.

In this post I'll walk through the whole thing A to Z: the grid frame, the trick that draws every hour line without a single extra element, the data shape that makes the math trivial, and then the drag interaction line by line — grab offset, pointer capture, column hit-testing, snapping, and clamping. By the end you'll understand not just this snippet but the mechanism inside every scheduler you'll ever inherit.

What the component actually is

Strip away the colors and it's this: a fixed-height box representing a range of hours, with events absolutely positioned inside it. An event at 9:30 isn't "in a 9:30 slot" — there are no slots. It's a div whose top happens to be the number of pixels that 9:30 works out to.

That's the whole idea, and it's worth saying plainly because it's the part people expect to be harder than it is:

top = (event.start - START_HOUR) * HOUR_HEIGHT

With the day starting at 8am and each hour drawn 44px tall, an event at 9.5 (9:30) sits at (9.5 - 8) × 44 = 66px from the top of its column. Height is even simpler: duration × 44. Two multiplications and you have a calendar.

Dragging is the same equation solved for the other variable. You know the pixel position (the pointer), you want the time — so you divide instead of multiply. Everything else in this snippet is polish on top of those two lines.

Where you'd actually use this

Week grids show up far outside literal calendars, because the underlying question — "what is happening, when, in which lane?" — is everywhere:

  • Booking and appointment systems. Consultants, clinics, salons, tutoring platforms: staff see the week, drag an appointment to move it, and the block's live time label confirms the new slot before they let go.
  • Shift and rota planning. Relabel the columns as people instead of days and the exact same component becomes a staff rota — drag a shift from Priya's column to Alex's and the "day" you're changing is really an assignee.
  • Resource scheduling. Meeting rooms, machines, studio bays, delivery vans. Columns are resources, blocks are bookings, and the cross-column drag is the dispatch action.
  • Classroom and conference timetables. Rooms across the top, sessions as blocks, with color encoding the track.
  • Project and capacity planning. A person's week of allocated work, where dragging is re-planning rather than re-booking.
  • Any dashboard that needs "this week at a glance." It composes cleanly into an admin shell beside your stats row.

In all of these, the reason to build rather than install is the same: a scheduling library is a large dependency with strong opinions about your data, and the customization you'll inevitably need — business rules about what may be dropped where — is exactly where those opinions hurt.

The tech stack: CSS Grid, absolute positioning, pointer events

There's no CDN script tag in this snippet. Three browser features carry the whole thing:

  • CSS Grid for the frame — one grid definition produces the gutter, the day headers, and the columns.
  • Absolute positioning for events, inside position: relative day columns, which is what makes the transform above meaningful.
  • Pointer events (pointerdown / pointermove / pointerup plus setPointerCapture) for the drag — deliberately not HTML5 drag and drop, for reasons we'll get to in Step 5.

One more thing worth flagging before we start: the coordinate system is defined twice, once in CSS and once in JS, and they must agree. The snippet handles this by making both sides loud about it.

Building it, step by step

Step 1 — The frame: one CSS Grid, two custom properties

The markup is a single grid containing a corner cell, five day headers, a time gutter, and five day columns. That's it — no wrapper per row, no nested grids:

<div class="cal" id="cal">
  <div class="corner"></div>
  <div class="day-head">Mon <span>14</span></div>
  <div class="day-head">Tue <span>15</span></div>
  <div class="day-head today">Wed <span>16</span></div>
  <div class="day-head">Thu <span>17</span></div>
  <div class="day-head">Fri <span>18</span></div>

  <div class="gutter" id="gutter"></div>

  <div class="day-col" data-day="0"></div>
  <div class="day-col" data-day="1"></div>
  <div class="day-col today-col" data-day="2"><div class="now-line" id="now-line"><span></span></div></div>
  <div class="day-col" data-day="3"></div>
  <div class="day-col" data-day="4"></div>
</div>

Twelve children, six per row, and the grid slices them into place:

.cal {
  display: grid;
  grid-template-columns: 48px repeat(5, 1fr);
  --hour-h: 44px;      /* one hour of vertical space */
  --start-hour: 8;     /* day begins at 08:00 */
}

Those two custom properties are the coordinate system. Everything vertical derives from them — the columns are height: calc(var(--hour-h) * 10) for a 10-hour day, and the gutter matches. Change --hour-h to 60px and the entire calendar zooms, with events, hour lines, gutter labels, and the now-line all moving together, because nothing is hard-coded in pixels anywhere else.

Step 2 — Hour lines for free with a repeating gradient

The obvious way to draw hour lines is a div per hour. Ten hours × five columns is fifty elements that exist purely to be looked at, and every one of them is another thing that can fall out of sync with the row height.

The snippet paints them instead:

.day-col {
  position: relative;
  height: calc(var(--hour-h) * 10);
  border-left: 1px solid #1c2940;
  /* hour lines drawn with a repeating gradient — zero extra elements */
  background: repeating-linear-gradient(
    to bottom,
    transparent 0 calc(var(--hour-h) - 1px),
    #1c2940 calc(var(--hour-h) - 1px) var(--hour-h)
  );
}

Read it as one repeating band of height --hour-h: transparent for the first --hour-h - 1px, then 1px of line. Because the band length is the hour height, the lines can't drift — they're derived from the same variable that positions the events, so alignment is structural rather than something you maintain. It's the lined-paper CSS trick doing real work.

The position: relative on the same rule is the other half of the foundation: it makes each column the containing block for the absolutely positioned events dropped into it, so an event's top: 66px means "66px from the top of this day," not from the page.

Gutter labels use the forward transform for the first time, generated in a loop:

for (let h = START + 1; h <= END - 1; h++) {
  const t = document.createElement('span');
  t.className = 'tick';
  t.style.top = (h - START) * HOUR_H + 'px';
  t.textContent = (h % 12 || 12) + (h < 12 ? 'am' : 'pm');
  gutter.appendChild(t);
}

Note (h % 12 || 12) — the standard 24-to-12 hour conversion, where the || 12 catches noon and midnight, which would otherwise render as "0". The tick is pulled up by transform: translateY(-50%) in CSS so the text centers on its line rather than hanging below it.

Step 3 — Events as data, with times as fractional hours

Here's the decision that makes everything downstream easy:

const HOUR_H = 44;        // must match --hour-h
const START = 8;          // must match --start-hour
const END = 18;
const SNAP = 0.5;         // 30-minute snapping

/* Events: day 0–4, start/dur in fractional hours. */
let EVENTS = [
  { id: 1, title: 'Design sync',  day: 0, start: 9,   dur: 1,   color: 'indigo' },
  { id: 3, title: '1:1 with Sam', day: 2, start: 9.5, dur: 0.5, color: 'pink' },
  { id: 4, title: 'Deep work',    day: 2, start: 13,  dur: 2.5, color: 'indigo' },
  // ...three more
];

Times are numbers, not dates and not "09:30" strings. 9:30 is 9.5. A two-and-a-half-hour block is dur: 2.5.

That single choice removes an entire category of code. There's no minutes arithmetic, no carrying when a 45-minute meeting starts at 9:45, no date-library import. Adding a duration to a start time is +. Snapping to half-hours is one rounding expression. Comparing two events for overlap is <. Minutes only reappear at the very last moment, in the display formatter:

function fmt(hFrac) {
  const h = Math.floor(hFrac), m = Math.round((hFrac - h) * 60);
  return (h % 12 || 12) + ':' + String(m).padStart(2, '0') + (h < 12 ? 'am' : 'pm');
}

The comments on HOUR_H and START — "must match --hour-h" — are the honest acknowledgement of the one duplication in the design. The CSS needs the numbers to lay out the grid; the JS needs them to do math. If you'd rather have one source of truth, read them from the computed style on load with parseFloat(getComputedStyle(cal).getPropertyValue('--hour-h')) and delete the constants.

Step 4 — The forward transform: render()

Rendering is a pure function of the data. Wipe the events, then place each one:

function render() {
  cal.querySelectorAll('.event').forEach(el => el.remove());
  EVENTS.forEach(ev => {
    const el = document.createElement('div');
    el.className = 'event ev-' + ev.color;
    el.dataset.id = ev.id;
    el.style.top = (ev.start - START) * HOUR_H + 'px';
    el.style.height = ev.dur * HOUR_H - 3 + 'px';
    el.innerHTML = '<b>' + ev.title + '</b><span class="ev-time">' +
      fmt(ev.start) + ' – ' + fmt(ev.start + ev.dur) + '</span>';
    cols[ev.day].appendChild(el);
  });
}

Three details worth pausing on. ev.day selects the column by index into the cols array — the day axis is pure array indexing, no math at all. The - 3 on the height is a visual gutter so consecutive events don't touch and read as one block. And el.dataset.id is the thread back from a DOM node to its data object, which the drag handler pulls on in the next step.

Because position is derived entirely from the data, there's no such thing as a stale layout here. Change any event's start or day, call render(), and the display is correct by construction. There's no second copy of "where things are" to keep in sync.

Step 5 — Grabbing: pointer capture and the offset that stops the jump

The drag is three listeners on the calendar container — delegation, so events created later need no wiring. Here's the grab:

let drag = null;

cal.addEventListener('pointerdown', e => {
  const el = e.target.closest('.event');
  if (!el) return;
  const ev = EVENTS.find(x => x.id === +el.dataset.id);
  drag = { el, ev, offsetY: e.clientY - el.getBoundingClientRect().top };
  el.classList.add('dragging');
  el.setPointerCapture(e.pointerId);
});

offsetY is the detail that makes dragging feel right. It records how far down the block you grabbed. Without it, every drag would begin by snapping the block's top edge to your cursor — grab a two-hour meeting near its bottom and it leaps upward before it moves anywhere. Subtracting the offset later means the block stays exactly where your hand caught it.

setPointerCapture is what makes the drag survive. Once the element captures the pointer, every subsequent pointermove is routed to it regardless of what's physically under the cursor — another column, another event, or briefly outside the calendar. Without capture, dragging across a column border would hand the pointer to a different element and the gesture would die mid-flight, which for a component whose main interaction is crossing columns would be fatal.

This is also the answer to "why not HTML5 drag and drop?" Native DnD is built for discrete drop targets. It gives you a browser-rendered ghost image you can't restyle, coarse coordinates, and no practical way to update the block's time label as it moves. Scheduling needs continuous positional feedback — you're not choosing a bucket, you're choosing a position — so pointer events are the right primitive. (The opposite case, discrete reordering, is where native DnD shines: that's what the Dashboard Widget Grid uses.) The CSS completes the setup with touch-action: none on .event, without which mobile browsers claim the vertical gesture for page scrolling before your handler ever sees it.

Step 6 — The day axis: hit-testing columns

Movement resolves two independent things. Horizontally, which day is the pointer over?

const colIdx = cols.findIndex(c => {
  const r = c.getBoundingClientRect();
  return e.clientX >= r.left && e.clientX < r.right;
});
if (colIdx >= 0 && colIdx !== ev.day) {
  ev.day = colIdx;
  cols[colIdx].appendChild(el);   // move live between columns
}

There's no math here at all — just asking each column for its rectangle and testing whether the pointer's x falls inside it. The >= left / < right pairing is deliberate: half-open intervals mean a pointer exactly on a boundary belongs to exactly one column, never two.

The appendChild is the nice part. Because events are absolutely positioned inside their column, re-parenting the node is moving it to another day — its top is already correct for any column, since every column shares the same coordinate system. One line, and the block visibly hops days the instant you cross the border. No placeholder, no ghost, no animation — the real element goes where it's going.

Step 7 — The time axis: the inverse transform, snap, clamp

Vertically, we run Step 4's equation backwards:

const colTop = cols[ev.day].getBoundingClientRect().top;
let h = START + (e.clientY - colTop - offsetY) / HOUR_H;
h = Math.round(h / SNAP) * SNAP;
h = Math.max(START, Math.min(h, END - ev.dur));
if (h !== ev.start) {
  ev.start = h;
  el.style.top = (h - START) * HOUR_H + 'px';
  el.querySelector('.ev-time').textContent = fmt(h) + ' – ' + fmt(h + ev.dur);
}

Four steps, four jobs:

  1. Convert. (clientY - colTop - offsetY) turns a viewport coordinate into a distance from the top of the day, corrected for where you grabbed. Divide by HOUR_H to get hours, add START to get a clock time. It is exactly the render equation rearranged.
  2. Snap. Math.round(h / 0.5) * 0.5 — the universal quantize-to-a-step idiom. Divide by the step, round, multiply back. Set SNAP to 0.25 and you have 15-minute precision with no other change.
  3. Clamp. Math.min(h, END - ev.dur) is the subtle one: the ceiling isn't the end of the day, it's the last start time that still fits the event. A 2.5-hour block in a day ending at 18 can't start later than 15:30, and the clamp enforces that automatically for every duration.
  4. Commit — but only on change. The if (h !== ev.start) guard means the DOM is touched only when the snapped value actually moves, so ~60 pointer events per second collapse into a handful of writes. In vanilla JS that's a nice optimization; in React, as we'll see, it's the difference between smooth and janky.

Notice the order — convert, snap, clamp, then commit. Snapping before clamping means the clamp gets the final say, so an event can never be pushed out of the day by rounding.

And notice what's being updated: ev.start, the data. The DOM styling follows from it, and so does the time label inside the block, which is why the event reads its own new time while you're still dragging it.

Step 8 — Letting go, and where your API call goes

cal.addEventListener('pointerup', () => {
  if (!drag) return;
  drag.el.classList.remove('dragging');
  // Persist point: EVENTS already holds the new day/start.
  drag = null;
});

There is deliberately no math here. Because every move committed to the data as it went, the array is already the new schedule by the time you release — so the release handler drops the lift styling and clears the drag state, nothing more.

That comment marks where a real app plugs in. Fire PATCH /events/:id with { day, start } right there, and prefer optimistic updates: the block is already where the user put it, so on failure you re-render from the server's version rather than making them watch a spinner. If a drag can cross many intermediate positions, debounce — you only care about where it landed.

Step 9 — The now-line

The red line uses the same transform a third time, this time on the clock:

function positionNow() {
  const now = new Date();
  let h = now.getHours() + now.getMinutes() / 60;
  if (h < START || h > END) h = 11.33;
  document.getElementById('now-line').style.top = (h - START) * HOUR_H + 'px';
}
positionNow();
setInterval(positionNow, 60000);

getHours() + getMinutes() / 60 is how you convert a real Date into this component's fractional-hour representation — the same one-liner you'll use to map your API's timestamps onto the grid. The fallback pins the line mid-morning when the current time falls outside working hours, purely so the demo always shows the feature; in production you'd hide the line instead. A one-minute interval is the right refresh rate for a line whose smallest meaningful movement is under a pixel.

Customizing it for your own project

Almost every reasonable change is a constant, not a rewrite:

  • Different hours. Change START/END in JS and --start-hour plus the * 10 column height in CSS. A 7am–7pm day is START = 7, END = 19, calc(var(--hour-h) * 12).
  • Different granularity. SNAP = 0.25 for 15-minute slots, SNAP = 1 for hourly-only booking.
  • Seven days. Add two .day-head and two .day-col divs and widen the grid to repeat(7, 1fr). The JS discovers columns with querySelectorAll, so it needs no edit at all.
  • Columns as something other than days. Nothing in the JS knows what a column means. Put staff names in the headers and ev.day becomes an assignee index — the drag is now reassignment.
  • Zoom. Change --hour-h and HOUR_H together, or better, derive one from the other on load.
  • Business rules. The pointermove handler is your veto point: run a check before committing, and simply don't assign ev.start when a drop is illegal. The block stops moving and the user feels the constraint.

The two things deliberately left out

This snippet is a chassis, and it's honest about its edges. Two features are missing on purpose, both marked as extension points:

Overlapping events. Right now two events at the same time on the same day sit on top of each other. The fix is the algorithm every real calendar uses: per column, sort events by start, then greedily assign each to the first "lane" whose previous event ended before this one starts, opening a new lane when none fits. Then set each block's left to laneIndex / laneCount × 100% and its width to 100% / laneCount. That's greedy interval partitioning, it provably uses the minimum number of lanes, and it's roughly fifteen lines inside render() — which also means collisions re-pack live as you drag. If your domain forbids overlaps (one room, one machine), skip packing entirely and reject the drop instead.

Drag-to-create and resize. Both reuse machinery that already exists. To create: on pointerdown over empty column space, make an event at the snapped pointer hour with dur: SNAP, then let pointermove grow dur using the same inverse transform applied to height instead of top. To resize: same math, triggered from a bottom-edge handle. Neither needs a new concept — just the one equation pointed at a different property.

Using it in React, Vue, or Angular

The editor exports all four, but it's worth knowing what genuinely changes. In React, EVENTS becomes state and blocks render from the array with style={{ top: (ev.start - START) * HOUR_H, height: ev.dur * HOUR_H }}. Two things then differ from the vanilla version:

First, the appendChild re-parenting disappears. Each column renders events.filter(e => e.day === dayIndex), so changing ev.day in state moves the block between columns automatically. The imperative DOM move was only ever a vanilla-JS convenience.

Second, that if (h !== ev.start) guard becomes load-bearing. Committing every pointer move to state means a re-render per frame; keep the drag in a ref and flush to state only when the snapped value changes, which is a handful of updates per drag instead of hundreds. Angular mirrors the same shape with a signal array and @for per filtered column, running pointermove outside the zone if change detection shows up in a profile.

Build, understand, optimize, and extend it with AI

This component rewards being interrogated rather than just pasted. Give the snippet to an AI assistant like Claude and ask it to trace a single drag gesture end to end — the offsetY capture on pointerdown, the findIndex column hit-test, the inverse transform, the Math.round(h / SNAP) * SNAP snap, the END - ev.dur clamp, and the data commit — then ask what visibly breaks if you delete setPointerCapture, and separately if you set offsetY to zero. Both are worth actually trying in a copy: the first kills the drag the moment you cross a column border, the second makes every block jump under your cursor, and feeling those two failures teaches the design better than any paragraph can. For performance, ask it to profile whether getBoundingClientRect() on every column on every pointermove matters at your column count, and to show the cached-rects version that recomputes only on pointerdown and resize. Then extend, in order of value: the greedy lane-packing pass inside render() for overlapping events, drag-to-create on empty column space, bottom-edge resize handles applying the same vertical math to dur, and — the one thing the UI layer genuinely can't absorb — the mapping from your real event API, including timezone normalization, into fractional day/start/dur. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the component from scratch, or as a jumping-off point for your own variant:

Build a Google-Calendar-style week view scheduler in plain HTML, CSS, and JavaScript — a time grid with draggable, snapping event blocks. No libraries.

Requirements:
- One CSS Grid frame (48px time gutter + five equal day columns) with a day-header row where today's date sits in an accent circle; define the coordinate system with two custom properties (--hour-h: 44px, --start-hour: 8) mirrored by JS constants, with columns 10 hours tall for an 8am-6pm day.
- Paint hour lines with a repeating-linear-gradient on the columns (zero extra elements, inherently synced to --hour-h), and generate gutter time labels (9am...5pm) positioned at (hour - START) * HOUR_H with a -50% translate.
- Events are plain data — { id, title, day 0-4, start, dur, color } with times as FRACTIONAL hours (9.5 = 9:30) — rendered by a pure function into absolutely positioned blocks: top = (start - START) * HOUR_H, height = dur * HOUR_H, styled as tinted translucent cards with accent left borders, a bold title, and a live time-range line. Include six varied demo events, one 30 minutes, one 2.5 hours.
- Implement drag-to-reschedule with pointer events and setPointerCapture (comment why native drag & drop is wrong for continuous positional feedback): record the grab offset within the block so it never jumps to the cursor; hit-test the pointer against column rects each move and re-parent the block the moment it crosses into a new day; convert pointer-y back to hours with the inverse transform, snap via Math.round(h / 0.5) * 0.5, clamp to [START, END - dur], and update the block's position AND its time label live from the mutated data. pointerup should only clean up, with a comment marking it as the PATCH persist point since the data already holds the new schedule.
- Only touch the DOM when the snapped hour actually changes, so ~60 pointer events per second collapse into a few writes.
- Add touch-action: none on blocks, a dragging lift state (shadow, brightness, z-index), a red now-line with a dot in today's column positioned by the same transform and refreshed each minute (pin it mid-morning when outside working hours so the demo always shows it), and a subtle tint on today's column.
- Comment the two marked extension points: greedy lane-packing for overlapping events and drag-to-create on empty column space.

Final thought

What makes this snippet worth studying isn't the calendar — it's that a component with a reputation for being hard turns out to be one equation used three times. Forward, it renders events. Inverted, it drags them. Applied to new Date(), it draws the now-line. Every other line in the file is presentation or plumbing around those two directions.

Once that clicks, the pattern starts showing up everywhere: video timeline scrubbers, Gantt charts, audio waveform selections, range sliders, anything where a value has a pixel and a pixel has a value. Learn to write the transform and its inverse cleanly, put the snap and the clamp in the right order, and you can build the whole family.

Grab the full HTML, CSS, and JS — or export it straight to React, Vue, Angular, or Tailwind — at Week View Scheduler on FWD Tools. It's free, runs entirely in your browser, and needs no sign-up. Browse more components like this one at FWD Tools UI Snippets.

About the author

Puneet Sharma
Puneet Sharma is a freelance web developer, tech writer, and blogger. He is the founder of FWD Tools and runs WebDevPuneet and The Tech Watcher.

Post a Comment