GODRICH

9 time picker ui patterns, from a point to a span

A time picker ui is the part of a screen where someone sets a time without typing it.

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

The order is not by popularity. It follows what a value is asked to hold. The first three answer "when": a dial (01), a drum (02), and a grid of bookable slots (03) all produce one point in time and nothing else. Then the value grows a second end. A span bar (04) carries a start and an end on one axis, and working-hours rows (05) repeat that span once per weekday, which is where a single picker turns into a schedule. The next two leave the clock behind: a repeat rule (06) is a time that has no single moment, and a preset like "in an hour" (07) is measured from now rather than from midnight. The last two answer "how long" instead of "when" — a countdown (08) is a duration with a ring drawn around it, and a three-city row (09) is one instant read through three offsets. If you want the neighboring shapes, the month grid handles the date half of the same form, and the picker patterns roundup covers what else a chooser can look like.

01Round dial you turn by the hour hand

Drag the hand around the face and the angle under your finger becomes an hour; let go and the same dial cuts to minute marks, so both steps finish inside one circle. Math.atan2 turns the pointer into a degree, the degree rounds to the nearest of the twelve ticks, and the chip in the middle reads the value back. Use it where a rough hour is enough, such as an alarm on a phone.

Math.atan2rotatearia-valuetext
function fromPointer(e) {
  var r = dial.getBoundingClientRect();
  var a = Math.atan2(e.clientX - r.left - r.width / 2, r.top + r.height / 2 - e.clientY) * 180 / Math.PI;
  a = (a + 360) % 360;
  if (s.hour) { s.h = Math.round(a / 30) || 12; } else { s.m = (Math.round(a / 30) * 5) % 60; }
  paint();
}
dial.addEventListener('pointerup', function () {
  if (!dial.dataset.drag) return;
  delete dial.dataset.drag;
  if (s.hour) { s.hour = false; paint(); }
});

02Drum roller with hours and minutes on separate tracks

Two columns scroll independently, the numbers coast, and only the value framed by the center window counts as chosen. That value is read back from scrollTop about 90ms after scrolling stops rather than on every scroll event, so one flick writes once instead of forty times. It belongs in a form row on a phone, where a booking or a reminder changes without opening a dialog.

scroll-snap-typescroll-snap-aligntranslateY
.dr__col {
  position: relative;
  height: 100%;
  overflow-y: auto;
  overscroll-behavior: contain;
  scroll-snap-type: y mandatory;
  scrollbar-width: none;
  touch-action: pan-y;
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 30%, #000 70%, transparent 100%);
  mask-image: linear-gradient(to bottom, transparent 0%, #000 30%, #000 70%, transparent 100%);
}
.dr__col::-webkit-scrollbar { display: none; }
.dr__col:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
.dr.is-demo .dr__col { scroll-snap-type: none; }

03Slot grid where only open times take a press

Half-hour slots sit in a grid and the taken ones are genuinely disabled, so a press does nothing and keyboard focus steps over them. The slot you choose flips aria-pressed, which recolors that button, and the confirmed badge appears in a single frame driven by a class on the wrapper, with the neighboring slots lifting 5px in sequence to fill the frames around that cut. This is the booking shape for a clinic or a salon, where what is still free has to be legible at a glance.

aria-pressedgrid-template-columnssteps(1, end)
.sg.is-demo .sg__slot[data-pick] {
  animation-name: sg-pick, sg-pop;
  animation-duration: $duration, $duration;
  animation-timing-function: steps(1, end), $easing;
  animation-iteration-count: infinite, infinite;
}
@keyframes sg-pick {
  0%   { background-color: $subject-cream; color: $color; }
  40%  { background-color: $color; color: $subject-cream; }
  86%  { background-color: $subject-cream; color: $color; }
  100% { background-color: $subject-cream; color: $color; }
}
@keyframes sg-pop {
  0%   { transform: scale(1); }
  38%  { transform: scale(1); }
  42%  { transform: scale(.93); }
  50%  { transform: scale(1.04); }
  58%  { transform: scale(1); }
  100% { transform: scale(1); }
}

04Span bar whose two handles push each other

Start and end share a single 24-hour axis cut into 96 quarter-hour steps, and neither handle can cross the other: bring one inside the one-hour minimum and the opposite handle is shoved along instead. Grab the band between them and the whole span slides with its length intact. Use it wherever a start and an end are decided together, like a meeting or a rental, and the length matters more than either endpoint.

aria-valuetextscaleXpointermove
function set(i, t) {
  t = Math.min(Math.max(t, 0), 96);
  if (i) { v[1] = Math.max(t, MIN); if (v[1] - v[0] < MIN) v[0] = v[1] - MIN; }
  else { v[0] = Math.min(t, 96 - MIN); if (v[1] - v[0] < MIN) v[1] = v[0] + MIN; }
  draw();
}
function shift(a) { var L = sv[1] - sv[0]; a = Math.min(Math.max(a, 0), 96 - L); v[0] = a; v[1] = a + L; draw(); }

05Working-hours bars stretched one row per weekday

Five weekday rows each carry one bar on a 48-step axis, so dragging an end snaps in thirty-minute steps and the figure printed beside the row recomputes on every pointer move. A clamp keeps each end inside the axis and at least one step away from its partner, which is why a row can never be closed by accident. This is the admin shape for opening hours and office hours, and it lives next to the rest of the form patterns.

setPointerCaptureclamptranslateX
function clamp(x, lo, hi) { return Math.min(Math.max(x, lo), hi); }
function fmt(t) { var h = t / 2 | 0; return (h < 10 ? '0' : '') + h + ':' + (t % 2 ? '30' : '00'); }
function draw(r) {
  var s = +r.dataset.s, e = +r.dataset.e, g = r.querySelectorAll('.wh__grip');
  r.style.setProperty('--s', s / 48); r.style.setProperty('--e', e / 48);
  g[0].setAttribute('aria-valuenow', s * 30); g[0].setAttribute('aria-valuetext', fmt(s));
  g[1].setAttribute('aria-valuenow', e * 30); g[1].setAttribute('aria-valuetext', fmt(e));
  r.querySelector('.wh__read [data-k="1"]').textContent = fmt(s) + '–' + fmt(e);
}
function set(r, i, t) {
  if (i) r.dataset.e = clamp(t, +r.dataset.s + 1, 48);
  else r.dataset.s = clamp(t, 0, +r.dataset.e - 1);
  draw(r);
}

06Repeat rule that becomes a sentence as you tap chips

Seven weekday chips are role="checkbox" buttons, and every toggle rewrites the sentence underneath into the rule you just built. Switch the segmented control from weekly to monthly and the same chips are reread as an ordinal week, which the script actually counts from a reference date instead of hard-coding "second". Put it where a rule has to be confirmed in words before anyone trusts it, like a recurring event or a standing reminder.

aria-checkedaria-livesteps(1, end)
function on(c) { return c.getAttribute('aria-checked') === 'true'; }
function ord(w) {
  var d = new Date(2026, 8, 12);
  d.setDate(d.getDate() + ((w - d.getDay() + 7) % 7));
  return OR[Math.ceil(d.getDate() / 7) - 1];
}
chips.forEach(function (c) {
  c.addEventListener('click', function () { wake(); c.setAttribute('aria-checked', String(!on(c))); render(); });
});

07Presets that show the resulting clock time

Each chip carries either minutes to add or an absolute minute of the day, and pressing one rolls the resulting clock time up from below, so the answer to "in an hour, but what time is that?" is on screen. The rolled-in value only becomes the committed one at transitionend, and the same string goes to a live region. Use it for snooze and send-later menus, where the distance is what people pick but the clock time is what they check.

aria-livetranslateYsteps(1, end)
function pick(btn) {
  root.classList.remove('is-demo');
  for (var k = 0; k < chips.length; k++) chips[k].setAttribute('aria-pressed', String(chips[k] === btn));
  var m = btn.hasAttribute('data-add') ? BASE + Number(btn.getAttribute('data-add')) : Number(btn.getAttribute('data-at'));
  var t = clock(m);
  live.textContent = t;
  if (t === cur.textContent) return;
  nxt.textContent = t;
  slot.classList.remove('is-roll');
  void slot.offsetWidth;
  slot.classList.add('is-roll');
}

08Countdown whose ring starts when you dial minutes

The minute and second fields are real spinbuttons, so arrow keys move them by one minute and five seconds, and pressing start drains the ring drawn around the readout once per second. The ring is a single conic-gradient whose sweep is one custom property, and the remaining fraction is written straight into it. Use it when the question is how long from now rather than at what time.

@propertyconic-gradientaria-valuenow
@property --ring-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 360deg;
}
.cd__ring {
  position: relative;
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 124px;
  height: 124px;
  border-radius: 50%;
  background: conic-gradient($color 0deg var(--ring-angle), rgba($subject-ink, .14) var(--ring-angle) 360deg);
}

09One time read at once by three cities

Move the time in the reference city with the rail, the two nudge buttons, or the arrow keys, and all three rows are redrawn in the same pass from one number, so they cannot drift apart. Any row whose minutes reach 1440 gets a next-day marker attached in the same frame as its new value. This is the scheduling row for calls with an overseas team, where the difference is the thing being decided.

steps(1, end)aria-livetranslateY
function apply() {
  root.classList.remove('is-demo');
  var say = [];
  for (var i = 0; i < rows.length; i++) {
    var m = base + OFF[i], t = m % 1440;
    var next = m >= 1440 ? '1' : '0';
    rows[i].querySelector('.tz__t').textContent = pad(Math.floor(t / 60)) + ':' + pad(t % 60);
    rows[i].setAttribute('data-next', next);
    var b = rows[i].querySelector('.tz__badge');
    say.push(rows[i].querySelector('.tz__name').textContent + ' ' + rows[i].querySelector('.tz__t').textContent
      + (next === '1' && b ? ' ' + b.textContent : ''));
  }
  rail.style.setProperty('--p', (base / MAX).toFixed(4));
  rail.setAttribute('aria-valuenow', String(base));
  rail.setAttribute('aria-valuetext', say[0]);
}

Where it breaks

Three of the nine cost far more than they looked like they would, and all three were found by running the preview loop rather than by reading the code.

The drum in 02 went first. Its columns are real scrollers with scroll-snap-type: y mandatory, while the autoplay preview moves the inner track with a transform. Mandatory snap answers every transform by re-snapping the scroller back to the nearest cell, so the two fought each other and the column sat still. Turning snap off only while the preview class is on is the whole fix, and the scroller a person actually touches keeps its snapping: .dr.is-demo .dr__col { scroll-snap-type: none; }.

The ring in 08 failed in a quieter way. A custom property that has not been registered is just a string to the engine, so the angle inside the conic-gradient jumps between values instead of sweeping. Registering it with @property and syntax: "<angle>" gives it a type, and then the second line matters: the block declares inherits: false, so the angle has to be set on the ring element itself. Write it on a wrapper and the ring keeps its initial-value: 360deg and never moves, which is exactly why the script calls setProperty on #cd-ring and not on the root.

The third is a measurement problem. Cutting a state change with steps(1, end) is the right call whenever a value must not be caught half-faded, but a cut only produces two differing frames, and the preview is graded on how many of its 24 frames changed. So every cut here has a transform-driven partner beside it: 03 runs sg-pick next to the sg-pop scale, 06 pairs rc-on4 with rc-pop4, and 09 pairs the badge fade with a translateY pop while the reference bar keeps stretching. The archive with all nine sources opens with 6meczddf, and the numbers below are what those pairs are worth. Even with them, 09 changes less than anything else in this post: a cumulative changed area of 1.799% over 12 of 24 moved frames, against 17.651% over 17 frames for the slot grid in 03. That is the honest shape of a time-zone table, and the fix for a poster frame that reads as empty is not more motion but a cut placed where the value arrives rather than where it leaves.

Accessibility

Every demo puts its loop animations behind @media (prefers-reduced-motion: reduce) and kills them with animation: none !important, keeping the live value visible instead of the rolling one — 02 pins .dr__ov[data-live] to full opacity, and 07 also drops the transition on the rolling slot. Nothing about interaction changes there, because the state in all nine lives in an attribute and the CSS reads it; the animation is only the preview. The roles follow the slider role contract, which is why every draggable value writes both a number and a spoken string.

# Mouse Keyboard Where the state is written
01 Drag the hand anywhere on the face Arrows ±1 hour or ±5 minutes, Home/End, Enter or Space swaps hour and minute aria-valuenow plus aria-valuetext on one role="slider"
02 Flick or wheel a column Arrows scroll one cell, Home/End jump to the ends aria-valuenow/aria-valuetext per column, read from scrollTop
03 Press an open slot Arrows walk the grid and skip disabled slots, Home/End aria-pressed on the chosen button, text in an aria-live="polite" line
04 Drag a handle, or drag the band to move the span Arrows ±15 minutes, Home/End; the band shifts both ends at once aria-valuenow in minutes plus aria-valuetext on both knobs
05 Drag either end of a weekday bar Arrows ±30 minutes, Home/End two role="slider" grips inside a role="group" labeled by the weekday
06 Click a chip or a mode Arrows move between chips, arrows on the control switch mode aria-checked on role="checkbox" chips and role="radio" modes, sentence in aria-live
07 Click a preset Arrows move to the next preset and pick it aria-pressed on the chip, resulting time in aria-live="polite"
08 Click the up and down buttons Arrows ±1 minute or ±5 seconds, Home/End for 0 and the maximum aria-valuenow plus aria-valuetext on two role="spinbutton" fields
09 Drag the rail or press the ±30 minute buttons Arrows ±30 minutes, Home/End for 00:00 and 23:30 aria-valuenow on the rail, all three rows in one aria-live line

FAQ

Why not just use <input type="time">?

For one clock time in a form, it is the right answer and it costs nothing. It stops being the answer as soon as the value changes shape: there is no native control for a span, a per-weekday schedule, a repeat rule, a relative offset, or a duration, which is what 04 through 08 are. The native popup also cannot be styled, so a booking screen that needs sold-out slots visible has to draw its own grid, as 03 does.

How do I keep the time zone straight with the server?

Keep the value as an integer, never as a formatted string. Every demo here stores minutes since midnight — 04 works in 96 quarter-hour steps, 05 in 48 half-hour steps, 09 in 30-minute steps up to 1410 — and builds the display text only at the moment of painting. On the wire, send an absolute instant in UTC together with the IANA zone name, and let the client format it. The three offsets in 09 are a fixed array because it is a demo; in a real app they come from Intl.DateTimeFormat.

How do I pick the snap step?

Match it to the smallest unit the business actually sells. 03 is built from half-hour slots because that is how a salon books, 04 snaps to 15 minutes but refuses any span shorter than an hour, and 08 steps minutes by one and seconds by five because a timer set to 4 minutes 37 seconds helps nobody. A step that is too fine makes a drag produce values no one wants to commit to, and every one of those values still has to be a legal answer on the server.

Enter the archive password

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