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

Date Picker UI: 9 Popover & Calendar Patterns

Date picker UI: One of nine opens a calendar popover when a focus value turns on, one fades out-of-range dates to unselectable, and one swaps that popover for

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

None of these nine demos talk to a server, touch localStorage, or roll a random number — every one is a small custom date picker html css build driven by one boolean, string, or numeric state value plus a handful of constants deciding how that value's change looks on screen. That narrow scope is what makes a date-picker collection like this readable as a checklist: name the value that flips — focus, range, mode, or width — and the pattern names itself before the code is even open.

Anatomy of nine patterns — which value changes and when

Only two of the nine run a requestAnimationFrame loop: item 05's pulseAt() multiplies an exponential decay by a cosine oscillation every frame to drive the today-ring's scale and alpha, and item 08's shakeAt() runs the identical decay-times-cosine formula on a cell's translateX instead. Every other pattern settles through a single CSS transition, so constants like STAGGER, FADE_STEP, and SHAKE_ZETA describe an intended feel rather than anything recomputed frame by frame outside those two. Four of the nine measure real layout before painting: item 01's measureAnchor() reads the field's getBoundingClientRect() so the popover sits exactly ANCHOR_GAP pixels below its measured bottom edge, item 02's slideTo() reads the viewport's rendered width to size the month-to-month slide distance, item 03's moveRingTo() reads a focused cell's position to move the keyboard ring there, and item 07's measureMode() reads the viewport's rendered width against THRESHOLD to choose between a popover and a sheet. The other five — 04, 05, 06, 08, and 09 — work off fixed constants alone (FADE_STEP/FADE_FLOOR, DUR/ZETA/FREQ, PRESETS/STAGGER, SHAKE_ZETA/BANNER_MS, and a plain boolean swap) with no DOM measurement call, so nothing in their math adapts to viewport size the way the measured four do. Every listener answers a real interaction: items 04, 06, 07, 08, and 09 listen only for click; item 01 pairs a field click with a document keydown for Escape and a document click that closes the popover when the target lands outside it; item 02 pairs header and navigation click handlers with a keydown for the left and right arrow keys; item 03 combines focusin, a keydown that moves the cursor by 1 for the left and right arrows and by 7 for up and down, and click; and item 05 pairs a badge click with a keydown. Six of the nine — 01, 03, 04, 05, 06, and 08 — declare the identical fixed BASE = { y: 2026, m: 3, d: 14 } rather than call new Date(); item 02 fixes only a YEAR = 2026 constant, and items 07 and 09 hardcode their sample date straight into the markup instead — so today never actually changes no matter which of the nine is open or when the page loads.

Code per pattern

01Input-popover value toggle — when an input's focus value turns on, a calendar unfolds as a popover, and folds away when it turns off

As the focus value turns on, a popover unfolds below the input, its height overshooting the target slightly before settling, and folds back when the focus value turns off. A booking form's date field or a filter panel's period input suits this pattern, where the popover itself needs to feel anchored to the exact field that opened it.

Input-popover value toggle — when an input's focus value turns on, a calendar unfolds as a popover, and folds away when it turns off
Open in a new tab

01 · Input-popover value toggle — when an input's focus value turns on, a calendar unfolds as a popover, and folds away when it turns off — Measured area 25.682% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

measureAnchor()ANCHOR_GAP$ease-elastic
  function measureAnchor() {
    // calc layer: measured position — reads the field's real rendered rect and anchors the popover to it
    var fr = field.getBoundingClientRect();
    var pr = dp.getBoundingClientRect();
    pop.style.left = (fr.left - pr.left) + 'px';
    pop.style.top = (fr.bottom - pr.top + ANCHOR_GAP) + 'px';
    pop.style.width = fr.width + 'px';
  }

  function setOpen(v) {
    dp.classList.toggle('dp--open', v);
    field.setAttribute('aria-expanded', v ? 'true' : 'false');
    pop.setAttribute('aria-hidden', v ? 'false' : 'true');
    if (v) measureAnchor();
  }

What the numbers say

Item 01 fills 25.68% of the frame cumulatively at 84.3 average intensity while moving in 8 of 23 sampled frames, the popover's slight height overshoot past its target adding a beat of motion on top of a plain reveal.

02Month-year unit value toggle — when the display-unit value switches from month to year, several years can be skipped at once

Each time the center label value is engaged, the display-unit value toggles between month and year, and jumping while in year mode covers a larger span at once. A birth-date input or a period picker reaching for far-back records benefits from this pattern, since paging month by month to a decade-old date is its own kind of friction.

Month-year unit value toggle — when the display-unit value switches from month to year, several years can be skipped at once
Open in a new tab

02 · Month-year unit value toggle — when the display-unit value switches from month to year, several years can be skipped at once — Measured area 5.122% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

AUTO_SEQCELL_TOTALmode
  function stepMonth(delta) {
    var target = current + delta;
    if (target < 1) target = 12;
    if (target > 12) target = 1;
    slideTo(target);
  }

  function toggleYear() {
    mode = mode === 'year' ? 'month' : 'year';
    root.classList.toggle('mj--year', mode === 'year');
    if (mode === 'year') renderYear(current);
  }

What the numbers say

Item 02 fills 5.12% of the frame at 109.6 average intensity while moving in 19 of 23 sampled frames, the sliding month view and the occasional switch into the year grid keeping motion running across nearly every sampled frame even while the visible area per frame stays small.

03Keyboard-arrow value toggle — each time a direction value changes, a highlighted-cell value moves across the grid

Each time the direction value changes, the highlighted-cell value moves to a neighboring cell, wrapping smoothly to the next row at the edge. A keyboard-first input form or an accessibility-first dashboard fits this pattern, since a mouse never has to touch the grid to reach any date in it.

Keyboard-arrow value toggle — each time a direction value changes, a highlighted-cell value moves across the grid
Open in a new tab

03 · Keyboard-arrow value toggle — each time a direction value changes, a highlighted-cell value moves across the grid — Measured area 2.131% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

FOCUS_SEQmoveRingTo()focusDay()
  grid.addEventListener('keydown', function (e) {
    var delta = 0;
    if (e.key === 'ArrowRight') delta = 1;
    else if (e.key === 'ArrowLeft') delta = -1;
    else if (e.key === 'ArrowDown') delta = 7;
    else if (e.key === 'ArrowUp') delta = -7;
    else return;
    e.preventDefault();
    human();
    focusDay(focusedDay + delta);
  });
  grid.addEventListener('click', function (e) {
    var cellEl = e.target.closest('.kb__cell');
    if (!cellEl || !cellEl.dataset.day) return;
    human();
    focusDay(parseInt(cellEl.dataset.day, 10));
  });

What the numbers say

Item 03 fills a thin 2.13% of the frame at 111.3 average intensity across 8 of 23 frames, since only the ring travels from cell to cell while the grid itself never repaints.

04Out-of-range fade value toggle — dates beyond an allowed-range value gain a rising fade value that marks them unpickable

Cells outside the allowed-range value gain a rising fade value and lose their press-response value, standing apart from the rest. A restricted booking-window picker or a coupon-use date limited to a validity period suits this pattern, where an out-of-range date should read as unpickable at a glance rather than only on click.

Out-of-range fade value toggle — dates beyond an allowed-range value gain a rising fade value that marks them unpickable
Open in a new tab

04 · Out-of-range fade value toggle — dates beyond an allowed-range value gain a rising fade value that marks them unpickable — Measured area 4.213% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

FADE_STEPFADE_FLOORrecompute()
  function recompute() {
    // calc layer: each out-of-range cell's opacity drops by its distance from the boundary
    for (var day = 1; day <= n; day++) {
      var c = cellByDay[day];
      var distOut = day < min ? (min - day) : (day > max ? (day - max) : 0);
      var opacity = distOut === 0 ? 1 : Math.max(FADE_FLOOR, 1 - distOut * FADE_STEP);
      c.style.opacity = String(opacity);
      c.classList.toggle('mf__cell--bound', day === min || day === max);
      c.classList.toggle('mf__cell--out-range', distOut > 0);
    }
  }

What the numbers say

Item 04 fills 4.21% of the frame at 83.8 average intensity while moving in 14 of 23 frames, the fade recalculation touching every day in the month each time the range's minimum or maximum changes rather than only the newly out-of-range cells.

05Today-marker value toggle — a ring around the cell holding today's value periodically grows while fading

A ring around the cell holding today's value grows and fades on a slow cycle while the badge itself pulses slightly in scale, staying distinct even when it overlaps a selected value. An attendance-check calendar or a schedule check near a due date benefits from this pattern, since today needs to stand out without ever being mistaken for the selected date.

Today-marker value toggle — a ring around the cell holding today's value periodically grows while fading
Open in a new tab

05 · Today-marker value toggle — a ring around the cell holding today's value periodically grows while fading — Measured area 3.242% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

pulseAt()DURZETA
  function pulseAt(t) {
    // calc layer: exponential decay x cosine oscillation — computes the settling-spring quantity directly
    if (t < 0 || t > DUR) return { ring: 0, ringAlpha: 0, badge: 1 };
    var x = t / DUR;
    var amp = Math.exp(-ZETA * x);
    var osc = Math.cos(FREQ * t);
    var ringScale = 1 + 1.9 * (1 - amp);
    var ringAlpha = Math.max(0, amp * .55);
    var badgeScale = 1 + .16 * amp * osc;
    return { ring: ringScale, ringAlpha: ringAlpha, badge: badgeScale };
  }

What the numbers say

Item 05 fills a modest 3.24% of the frame at 38.3 average intensity across 16 of 23 frames — the low area is expected for a badge-sized effect, and the frame count stays high because pulseAt() keeps recomputing the decay-times-cosine formula across the full 1,100ms cycle rather than because much of the frame actually repaints.

06Preset-chip value toggle — engaging a chip value sets the date value directly, skipping the grid

When one chip value turns on, the date value is set directly without touching the grid, leaving only that chip's background value filled in to mark the choice. A report's quick period pick or a search filter's recent-range shortcut fits this pattern, where naming a range in words is faster than clicking its two endpoints.

Preset-chip value toggle — engaging a chip value sets the date value directly, skipping the grid
Open in a new tab

06 · Preset-chip value toggle — engaging a chip value sets the date value directly, skipping the grid — Measured area 11.493% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

PRESETSSTAGGERapplyPreset()
  function applyPreset(idx) {
    // calc layer: delays the transition by each cell's position from the range start, to fill in sequence
    var p = PRESETS[idx];
    for (var day = 1; day <= n; day++) {
      var c = cellByDay[day];
      var inRange = day >= p.start && day <= p.end;
      if (inRange) {
        var pos = day - p.start;
        c.style.transitionDelay = (pos * STAGGER) + 'ms';
      } else {
        c.style.transitionDelay = '0ms';
      }
      c.classList.toggle('ps__cell--on', inRange);
    }
    chipEls.forEach(function (el, i2) { el.classList.toggle('ps__chip--active', i2 === idx); });
  }

What the numbers say

Item 06 fills 11.49% of the frame at 63.8 average intensity while moving in 18 of 23 frames, the per-cell transition delay from applyPreset() spreading the background fill's motion across nearly every sampled frame instead of finishing in one step.

07Sheet-swap value toggle — as the screen-width value narrows, a popover value changes into a bottom-sheet value that rises from below

Once the screen-width value narrows past a threshold, the same calendar appears as a bottom sheet rising from below instead of a popover, and the width value is changed by engaging a button. A mobile booking app's date pick or a filter panel on a narrow screen fits this pattern, since a centered popover can run off a small screen's edges in a way a bottom sheet never does.

Sheet-swap value toggle — as the screen-width value narrows, a popover value changes into a bottom-sheet value that rises from below
Open in a new tab

07 · Sheet-swap value toggle — as the screen-width value narrows, a popover value changes into a bottom-sheet value that rises from below — Measured area 17.928% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

THRESHOLDmeasureMode()viewport
  function measureMode() {
    // calc layer: measures the viewport element's real rendered width to pick popover or sheet
    var w = viewport.getBoundingClientRect().width;
    return w < THRESHOLD ? 'sheet' : 'popover';
  }

  function render() {
    var mode = measureMode();
    panel.classList.toggle('ms--open', open);
    panel.classList.toggle('ms__panel--sheet', mode === 'sheet');
    panel.classList.toggle('ms__panel--popover', mode === 'popover');
  }

  function human() {
    ms.classList.remove('is-demo');
    stopAutoplay();
  }

  field.addEventListener('click', function () {
    human();
    open = !open;
    render();
  });

What the numbers say

Item 07 fills 17.93% of the frame at 106.2 average intensity while moving in 15 of 23 frames, the sheet's slide up from below covering more area per frame than any other pattern in the set apart from item 01's popover reveal.

08Denial-shake value toggle — engaging a blocked value makes that cell's position value shake briefly side to side

Engaging a blocked value makes that cell's position value shake side to side briefly, while a notice banner's opacity value rises for a moment before fading back down. Preventing reselection of an already-booked date or flagging a closed submission day suits this pattern, where the shake reads as a direct answer to a specific click rather than a generic error state.

Denial-shake value toggle — engaging a blocked value makes that cell's position value shake briefly side to side
Open in a new tab

08 · Denial-shake value toggle — engaging a blocked value makes that cell's position value shake briefly side to side — Measured area 3.376% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

shakeAt()SHAKE_ZETABANNER_MS
  function shakeAt(t) {
    // calc layer: turns exponential decay x cosine oscillation into a pixel offset
    if (t < 0 || t > SHAKE_DUR) return 0;
    var x = t / SHAKE_DUR;
    var amp = Math.exp(-SHAKE_ZETA * x);
    return SHAKE_AMP * amp * Math.cos(SHAKE_FREQ * t);
  }

  function loop(ts) {
    if (shakeTarget) {
      var elapsed = ts - shakeStart;
      if (elapsed > SHAKE_DUR) {
        shakeTarget.style.transform = '';
        shakeTarget = null;
      } else {
        shakeTarget.style.transform = 'translateX(' + shakeAt(elapsed).toFixed(2) + 'px)';
      }
    }
    rafId = requestAnimationFrame(loop);
  }

What the numbers say

Item 08 fills 3.38% of the frame at 39.3 average intensity across 16 of 23 frames — a number close to item 05's for the same reason: shakeAt() only offsets one cell's translateX and a banner's opacity, so the spring-damped physics runs for the full cycle without needing much frame area to show it.

09Reduced-motion fallback toggle — when a reduced-motion value turns on, the popover, sheet, and shake values change instantly instead

When the reduced-motion value turns on, every movement and shake value from the previous eight patterns is dropped, leaving only an instant show/hide value. A user with motion-minimizing settings on or a form screen on a low-power device is exactly who this pattern is for, since it is the one item built purely to demonstrate what the other eight fall back to.

Reduced-motion fallback toggle — when a reduced-motion value turns on, the popover, sheet, and shake values change instantly instead
Open in a new tab

09 · Reduced-motion fallback toggle — when a reduced-motion value turns on, the popover, sheet, and shake values change instantly instead — Measured area 3.151% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

rm__pop--normalrm__pop--reducedprefers-reduced-motion
.rm__pop {
  position: absolute; top: 100%; left: 0; right: 0; margin-top: $sp-1;
  box-sizing: border-box; padding: $sp-1; border-radius: $r-sm;
  background: $stage-paper; box-shadow: $shadow-raised;
  opacity: 0; pointer-events: none;
}
.rm__pop--normal {
  transform: scale(.8);
  transform-origin: top center;
  transition: transform $dur-enter $ease-elastic, opacity $dur-quick $ease-out;
}
.rm__pop--normal.is-open { transform: scale(1); opacity: 1; pointer-events: auto; }
.rm__pop--reduced {
  transition: opacity $dur-quick $ease-out;
}
.rm__pop--reduced.is-open { opacity: 1; pointer-events: auto; }

What the numbers say

Item 09 fills 3.15% of the frame at 76.6 average intensity while moving in 15 of 23 frames, the side-by-side comparison changing only two small popovers rather than the full calendar grid either one contains.

Where values open and close the popover — focus, range, width basis

Item 01's field click and ANCHOR_GAP 8px open the calendar as a popover the moment the field is engaged, anchored by measureAnchor() to sit exactly ANCHOR_GAP pixels below the field's measured bottom edge; a document-level click outside the popover or an Escape keydown closes it again.

Item 04's FADE_STEP .12 and FADE_FLOOR .15 decide how sharply a date's opacity falls off once it steps outside the allowed min–max range — recompute() walks every day in the month and drops each out-of-range day's opacity by .12 per day of distance until it hits the .15 floor, which is the difference a considered date picker css makes over one that just switches a class with no in-between step.

Item 07's THRESHOLD 140px is the one value in the set that reshapes the whole picker rather than a detail inside it: measureMode() reads the viewport's rendered width, and once it drops under 140 the same calendar swaps from a popover anchored to the field into a bottom sheet that a separate toggle button raises and lowers.

Beyond this one post, 9 Date Range Picker UI Patterns — Copy-Paste works a different axis — two clicks fill a span between a start date and an end date across the same kind of month grid, instead of one field opening one popover for a single value the way these nine do. 9 css calendar design patterns that really move drops the field and the popover entirely and treats the whole calendar grid as the destination in its own right, where this post's grids are field-bound instances of that same month math. 9 time picker ui patterns, from a point to a span swaps the calendar for a clock face and a duration control, so its values move along hours and minutes rather than days and weeks. Comparing all three against this post is the fastest way to place a given date selector css on the right axis before writing a single line of it, and the zip below is what 2s5u3jbf unlocks.

Where it breaks — narrow screens, out-of-range input, reduced motion

None of the nine builds its field as a native <input type="date"> — every one is a button or a div driving a custom popover or sheet, which is exactly what lets a date input css build decide how the calendar opens instead of accepting whatever shape the browser's own picker happens to render.

The instant a date crosses the range boundary

Item 04's recompute() only runs when min or max changes through a grid click or the autoplay tick() — it is not wired to any external form field in this demo, so a real integration driving min/max from a separate input would need to call recompute() itself after every such change, or a date newly outside the range would keep its old, fully opaque appearance until the next click happens to trigger a redraw.

Narrow screens

All nine carry a @media (max-width: 340px) rule in their SCSS, but item 07 is the one pattern that can already change its layout shape well above that width: measureMode()'s THRESHOLD check swaps between a popover and a sheet class at 140px of viewport width, a breakpoint of its own that has nothing to do with the shared 340px rule the other eight rely on for padding and font-size.

Reduced motion

Every one of the nine checks matchMedia('(prefers-reduced-motion: reduce)') in JavaScript before starting its own auto-loop, and every one also ships a CSS @media (prefers-reduced-motion: reduce) block. Item 09 is the pair built specifically to show the difference side by side: rm__pop--normal transitions both transform and opacity, rm__pop--reduced transitions opacity alone, and the reduced-motion media block strips even the normal side down to that same opacity-only transition — so a reduced-motion visitor sees the identical instant-fade behavior on both halves of the comparison.

Three variants and accessibility

Today-ring, slow decay (ZETA 4.2 to 2.0) — lowering the damping constant keeps the ring pulsing outward and fading two or three extra times instead of settling in one cycle.

Shake-denial, slow decay (SHAKE_ZETA 6.5 to 3.0) — lowering the damping constant keeps the shake swinging side to side a few extra times before stopping, making the denial feel stronger.

Chip-fill stagger, slower spread (STAGGER 28 to 70) — stretching the delay makes the background fill visibly ripple cell by cell from the start day to the end day instead of coloring almost all at once.

Variant What changes What it doesn't touch
Today-ring, slow decay (ZETA 4.2 to 2.0) Item 05's ring pulse cycle count only DUR, FREQ, the badge's own scale amplitude
Shake-denial, slow decay (SHAKE_ZETA 6.5 to 3.0) Item 08's shake cycle count only BANNER_MS, SHAKE_AMP, SHAKE_FREQ
Chip-fill stagger, slower spread (STAGGER 28 to 70) Item 06's per-cell fill delay only PRESETS, the active-chip class logic

Across the nine, only items 01 and 03 reach for a non-native role: item 01's panel is role="dialog" with an aria-label, opened by a field carrying aria-haspopup and aria-expanded; item 03 is built on a role="grid" div with a roving tabIndex that hands focus to exactly one cell at a time, so arrow-key navigation matches what a native grid widget would announce for free. The decorative weekday-header row is aria-hidden in items 01, 02, 03, 04, 05, 06, and 08 (items 07 and 09 build no such row), and items 02 and 03 additionally label their controls with aria-label. None of the nine reach for a disabled attribute — even item 04's out-of-range dates stay focusable and simply fade in opacity — so keyboard focus order never depends on which value the range or the display mode currently holds.

FAQ

Why do people search for "date picker ui design" instead of typing a date straight into a text field?

Because a typed date invites format mismatches and typos across locales and browsers, and none of these nine even reach for a native <input type="date"> — every field here is a plain button that opens a calendar, so the picked value is always unambiguous regardless of how a visitor's own locale writes dates.

Does resizing the browser really change which of these nine becomes a bottom sheet?

Only item 07 does. measureMode() reads the viewport's rendered width every time render() runs, and once it drops under THRESHOLD's 140px the same markup switches from ms__panel--popover to ms__panel--sheet; the other eight keep one fixed presentation no matter how narrow the viewport gets.

Is item 05's today-ring animated with a CSS keyframe or hand-computed physics?

Hand-computed. pulseAt() multiplies an exponential decay, Math.exp(-ZETA * x), by a cosine oscillation, Math.cos(FREQ * t), on every animation frame to get the ring's scale and alpha plus the badge's own small scale pulse, so nothing about the motion comes from a CSS @keyframes block.

Sources: MDN — Using CSS transitions · MDN — Window: requestAnimationFrame() method · MDN — prefers-reduced-motion · W3C ARIA APG — Date Picker Dialog Pattern

Enter the archive password

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