GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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 work as a css calendar template rather than a screenshot: they carry role="grid", the five with a selectable day expose it through aria-selected, and the arrow keys really do move between cells. When the selection is a range rather than a day, the dropdown of Select Dropdown Design Patterns comes first, and picking a point on a map splits off into Map UI Design Patterns.

01Two-track month slide

What this css calendar grid shows

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.

Two-track month slide
Open in a new tab

01 · Two-track month slide — Measured area 19.754% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateXaria-live@keyframes cal-slide

Where to look in the code

When the transition ends, the track resets in the single frame where transition is switched off, so the eye never catches it.

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

What happens on screen

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.

Today ring with count badge
Open in a new tab

02 · Today ring with count badge — Measured area 0.972% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

box-shadowaria-current=datescale

How to read the code

The ring is drawn on a square the same height as the cell with border-radius: 50%, so it never renders as an ellipse.

.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

What moves

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.

Event chips with +N overflow
Open in a new tab

03 · Event chips with +N overflow — Measured area 1.95% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-expandedgrid-template-rows@keyframes more-open

What to change

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.

.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

What you see

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.

Drag-painted date range
Open in a new tab

04 · Drag-painted date range — Measured area 6.149% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointerdownaria-selectedborder-radius

Where to look in the code

On the keyboard, Shift plus an arrow key grows the range from the anchor cell.

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

What happens on screen

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.

Week timeline on a time axis
Open in a new tab

05 · Week timeline on a time axis — Measured area 11.513% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

@keyframes wt-nowrole=gridtranslateY

How to read the code

The now-line travels down at a steady translateY rate and fades only at the two ends.

.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

What moves

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.

Pick-up and drop event card
Open in a new tab

06 · Pick-up and drop event card — Measured area 4.262% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointermoverotatearia-grabbed

What to change

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.

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

What you see

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.

Contribution heatmap calendar
Open in a new tab

07 · Contribution heatmap calendar — Measured area 10.424% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

grid-template-columnsaria-labelscale

Where to look in the code

The loop fills the squares from the leftmost column onward, and each cell is staggered with individual animation properties rather than the animation: shorthand.

.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

What happens on screen

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.

Mini three-month strip
Open in a new tab

08 · Mini three-month strip — Measured area 1.369% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

container-typearia-selectedclamp()

How to read the code

The selected range keeps its state on the cells of all three cards, even across month boundaries, so one range visibly spans three months.

.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

What moves

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.

Grid to agenda fold
Open in a new tab

09 · Grid to agenda fold — Measured area 14.776% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

@containerrole=listtranslateY

What to change

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.

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. A calendar clipping on a narrow screen maps directly onto the case where the card of Login Form Design Patterns meets the same width. 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

Why build a calendar ui design when the browser already gives me a date picker?

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. Opening items by command instead of a calendar is solved from another angle in Command Palette UI Design.

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.