GODRICH

9 css calendar design patterns that really move

css calendar design often stops at restyling the date picker the browser gives you. These nine build the whole month grid, and every one really works.

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine are ordered by what a calendar actually goes through on screen, not by popularity. First the month turns over (01), then today stands out (02), events pile onto a single day (03), several days are selected at once (04), clashing hours become visible (05), an event is picked up and moved (06), a year reads as one picture (07), a range crosses month boundaries (08), and a narrow screen folds the grid into an agenda (09). All nine carry role="grid", the five with a selectable day expose it through aria-selected, and the arrow keys really do move between cells.

01Two-track month slide

Press an arrow and the outgoing and incoming month slide together on two tracks, one carrying the month grids and one the month labels. Swapping the numbers alone tells you nothing about which way you moved, so the track is laid out at 300% width with the current month in the middle, and navigation pushes it left or right. When the transition ends, the track resets in the single frame where transition is switched off, so the eye never catches it.

translateXaria-live@keyframes cal-slide
function go(dir) {
  if (busy) return;
  busy = true; cal.classList.remove('is-demo'); cal.classList.add('is-anim');
  var to = BASE - dir * (100 / 3);
  track.style.transform = strip.style.transform = 'translateX(' + to + '%)';
  var done = function () { track.removeEventListener('transitionend', done); finish(dir); };
  track.addEventListener('transitionend', done);
  window.setTimeout(done, 700);
}
function finish(dir) {
  busy = false; cal.classList.remove('is-anim');
  cur = new Date(cur.getFullYear(), cur.getMonth() + dir, 1);
  render();
  track.style.transform = ''; strip.style.transform = '';
  live.textContent = label(cur);
}

02Today ring with count badge

Only today's cell gets a blue disc, and a ring expands out of it once per loop and fades. Days with events carry that count as a small pill in the corner, ringed in white so the number stays readable on top of the disc. The ring is drawn on a square the same height as the cell with border-radius: 50%, so it never renders as an ellipse.

box-shadowaria-current=datescale
.cal__cell.is-today::after {
  content: "";
  position: absolute; left: 50%; top: 50%;
  width: clamp(24px, 7.5vw, 32px); height: clamp(24px, 7.5vw, 32px);
  border: 3px solid $color;
  border-radius: 50%;
  transform: translate(-50%, -50%) scale(.9);
  opacity: 0;
  pointer-events: none;
  animation-name: today-ring;
  animation-duration: $duration;
  animation-timing-function: $ease-out;
  animation-iteration-count: infinite;
}

03Event chips with +N overflow

Each cell stacks up to two colored chips and folds the rest into a +N row. Clicking that row expands them for real, as grid-template-rows grows from 0fr to 1fr. The expanding list is an absolutely positioned card, so it never pushes the grid taller, and on a 320px phone the neighboring rows do not budge.

aria-expandedgrid-template-rows@keyframes more-open
.cal__morewrap {
  position: absolute;
  left: 0; right: 0; top: calc(100% + 2px);
  display: grid;
  grid-template-rows: 0fr;
  background: #fff;
  border-radius: $r-sm;
  box-shadow: $shadow-press;
  transition: grid-template-rows $duration $easing;
  z-index: 2;
}
.cal__more { min-height: 0; overflow: hidden; }
.cal__cell.is-open .cal__morewrap { grid-template-rows: 1fr; }

04Drag-painted date range

Hold the start cell and drag, and every cell you pass is painted in order. All the painting lives in one function, paint(a, b), which takes the two end indices, marks every cell in the span with is-in, and adds is-start and is-end to the two ends, so dragging the other way runs the exact same code. On the keyboard, Shift plus an arrow key grows the range from the anchor cell.

pointerdownaria-selectedborder-radius
function paint(a, b) {
  var lo = Math.min(a, b), hi = Math.max(a, b);
  for (var i = 0; i < cells.length; i++) {
    var on = i >= lo && i <= hi;
    cells[i].classList.toggle('is-in', on);
    cells[i].classList.toggle('is-start', on && i === lo);
    cells[i].classList.toggle('is-end', on && i === hi);
    cells[i].setAttribute('aria-selected', String(on));
  }
  live.textContent = cells[lo].getAttribute('data-d') + ' – ' + cells[hi].getAttribute('data-d');
}

05Week timeline on a time axis

Event blocks sit against a left-hand hour ruler at their start time. Two blocks that overlap split the width in half and stand side by side, so the clashes a month grid hides become obvious at a glance. The now-line travels down at a steady translateY rate and fades only at the two ends.

@keyframes wt-nowrole=gridtranslateY
.wt.is-demo .wt__now {
  animation-name: wt-now;
  animation-duration: $dur-loop;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
}
@keyframes wt-now {
  0%   { transform: translateY(calc(var(--rowh) * .4));  opacity: 0; }
  10%  { opacity: 0; }
  22%  { opacity: 1; }
  78%  { opacity: 1; }
  90%  { opacity: 0; }
  100% { transform: translateY(calc(var(--rowh) * 4.7)); opacity: 0; }
}

06Pick-up and drop event card

Grab a chip and it lifts, tilts, and casts a deeper shadow. Cells the cursor crosses show a dashed drop target, and releasing re-parents the chip into that cell with appendChild. There is a keyboard path too — pick up with Space or Enter, choose a cell with the arrow keys, press either again to drop — so events move without a mouse.

pointermoverotatearia-grabbed
document.addEventListener('pointermove', function (e) {
  if (!drag) return;
  drag.chip.style.transform = 'translate(' + (e.clientX - drag.x) + 'px,' + (e.clientY - drag.y - 6) + 'px) scale(1.06) rotate(-4deg)';
  var el = document.elementFromPoint(e.clientX, e.clientY);
  var c = el && el.closest ? el.closest('.dd__c') : null;
  if (c !== drag.over) {
    if (drag.over) drag.over.classList.remove('is-over');
    drag.over = c;
    if (c) c.classList.add('is-over');
  }
});

07Contribution heatmap calendar

A year is laid out as 7 rows of 53 columns of small squares, and busier days render darker. The five shade steps climb from a faint mix of the ink and the stage color up to the ink itself, and because one row is one weekday and one column is one week, the board declares all 53 columns up front and the squares flow in row by row. The loop fills the squares from the leftmost column onward, and each cell is staggered with individual animation properties rather than the animation: shorthand.

grid-template-columnsaria-labelscale
.hm__board {
  display: grid;
  grid-template-columns: repeat(53, 1fr);
}
.hm__row { display: contents; }
.hm__cell { background: color.mix($color, $stage-yellow, 14%); }
.hm__cell.lv1 { background: color.mix($color, $stage-yellow, 30%); }
.hm__cell.lv2 { background: color.mix($color, $stage-yellow, 48%); }
.hm__cell.lv3 { background: color.mix($color, $stage-yellow, 70%); }
.hm__cell.lv4 { background: $color; }

08Mini three-month strip

The previous, current, and next month stand side by side as three shrunken grids. Type sizes use cqw units, so all three shrink together as the box narrows and still fit at 320px. The selected range keeps its state on the cells of all three cards, even across month boundaries, so one range visibly spans three months.

container-typearia-selectedclamp()
.m3 { container-type: inline-size; width: min(100%, 436px); }
.m3__strip { display: flex; gap: $sp-2; }
.m3__card { flex: 1 1 0; min-width: 0; padding: $sp-1; border-radius: $r-sm; }
.m3__label { font-size: clamp(9px, 2.6cqw, 13px); }
.m3__cell  { font-size: clamp(7px, 2.2cqw, 11px); }
.m3__card--side .m3__num { font-weight: 500; color: color.mix($color, transparent, 45%); }

09Grid to agenda fold

The same September data appears as either a seven-column grid or a day-by-day list depending on the box width. An @container (max-width: 176px) rule picks the view, and a ResizeObserver mirrors that same switch in JS, rewriting role between grid and list. Two icon buttons announce the active view through aria-pressed, and focus sitting on a now-hidden cell moves to the first cell of the new view.

@containerrole=listtranslateY
function setMode(narrow) {
  if (narrow === isAgenda) return;
  isAgenda = narrow;
  root.classList.toggle('is-agenda', narrow);
  view.setAttribute('role', narrow ? 'list' : 'grid');
  bgrid.classList.toggle('is-on', !narrow);
  blist.classList.toggle('is-on', narrow);
  bgrid.setAttribute('aria-pressed', String(!narrow));
  blist.setAttribute('aria-pressed', String(narrow));
  var a = document.activeElement;
  if (!a || a === document.body || !a.offsetParent) focusFirst();
}
new ResizeObserver(function () { setMode(frame.getBoundingClientRect().width <= 176); }).observe(frame);

Where it breaks — the trap

The first trap turned up in the measurements for 04. Setting only animation-delay on staggered cells leaves them sitting in their base state, already painted, for the length of the delay, so the screen jumped once at the start of every loop. Diffing the frames against each other showed a clearly visible mismatch between the first and last frame, and adding animation-fill-mode: backwards shrank it to almost nothing. Staggered elements need the fill mode as much as the delay itself.

The second trap came from the stage budget. Keeping the document from outgrowing a 320 by 200 phone viewport caps the grid at five week rows, but some months need six. August 2026 starts on a Saturday and runs 31 days, so the 30th and 31st fall into a sixth week that a five-row grid never draws at all. The demo anchors on September to sidestep that, but a real screen needs a height budget that assumes six rows. All nine sources, fixes included, sit inside the zip that opens with the archive password 4zdx6kfa. Third, the now-line in 05 left a half-drawn line on the first frame of the capture, because its transparent window was only 3% of the loop. Compositor delay can make a recorder pick up the opening frames late, so that window was widened to 10% and 90%.

Accessibility (reduced-motion)

All nine turn off their autoplay loops under prefers-reduced-motion: reduce and hold their state. The ring stops spreading and the today disc simply stays, months switch instantly instead of sliding, and the heatmap freezes with its colors in place. Grids carry role="grid", week rows role="row", and day cells role="gridcell"; the five where a day can be selected announce that state through aria-selected, and the eight that mark today use aria-current="date". Outside 06, where the chip itself is carried, a roving tabindex drives real arrow-key movement, one cell sideways and one row up or down — seven days in the month grids — with Home and End in all eight, and 01's month name is read out by an aria-live="polite" region. In 07 every dated square carries an aria-label with its date and count. Focus is always restored with focus({ preventScroll: true }), because these demos sit inside iframes within the article and a bare focus() yanks the reader's page down. The definitions of the roles used here are in MDN's grid role document.

Calendar parts that belong on dashboards are collected in the dashboard category, and parts that respond to a press in the click category.

FAQ

The browser already gives me a date picker, so why build a calendar at all?

For picking a single day, you shouldn't. The reason to build one shows up on booking and scheduling screens, where the whole month has to stay visible with event chips on it, or where several days are selected together. Even then, you have to fill in the keyboard movement and the role names by hand to match what the browser used to give you for free.

Doesn't a seven-column grid get too cramped on phones?

At 320px a cell shrinks to roughly 40px, and no amount of styling changes that, so 09 folds into a day-by-day list once the box gets narrow enough. The list shows the same data and re-aims the arrow keys along its own axis, which reads better than a grid squeezed until nothing fits.

What happens when ten events land on one day?

The pattern in 03 — two visible chips plus a +N row — is what keeps the grid height stable. Expanding opens an absolutely positioned card that floats over the rows below it, so the grid never grows and the neighboring rows stay exactly where they were.

Enter the archive password

The password is inside this article. You will find it as you read.