ui-062

Wheel Time Picker

£7
or the whole Vault, £99
Open in new tab

A wheel time picker: hour, minute and AM/PM drums that drag, flick or scroll, coast to a stop on a value and loop forever.

advanced
6 more details
3d-transformsinertiainfinite-loopsnapkeyboard-navigationodometer-counter

Released

About this component

The time picker people already know from their phone, built for the web. Hours, minutes and AM/PM sit on three drums drawn in real 3D: each row is turned on the cylinder and fades as it rolls away from the centre, so the selection band reads as the front of a wheel rather than a highlighted list row.

Read the full effect overview

Drag a column and it follows the finger; let go mid-flick and it leaves the hand at the speed it was thrown, coasts, and lands on a whole value. Hours and minutes loop with no seam, AM/PM stops at its ends with a rubber-band give. A mouse wheel steps one row per notch and a trackpad glides, and neither scrolls the page underneath, smooth scroller or not.

The readout above rolls only the digits that changed, in the direction their wheel turned. A 24-hour clock and 1, 5 or 15 minute steps are data attributes read live, and a native time input carries the value into a form and stands in for the whole picker when JavaScript is off.

Wheel Time Picker - UI component preview

What's included

10 items
  • Hour, minute and AM/PM columns as 3D drums: each row turned on the cylinder with perspective, fading towards the edges
  • Drag, flick, mouse wheel or trackpad; a release coasts at the speed it was thrown and snaps to a whole value
  • Hours and minutes loop infinitely; AM/PM rubber-bands at its ends
  • Tap a row above or below the band to roll it to the centre
  • Readout digits roll individually, in the direction the wheel turned
  • 12 or 24 hour clock and 1, 5 or 15 minute steps via data attributes, read live
  • Each column is a role="spinbutton" with arrow keys, PageUp/PageDown, Home/End, digit type-ahead and A/P
  • No page scroll while dragging or wheeling a column, including under Lenis, without data-lenis-prevent
  • Native time input for forms and the no-JavaScript fallback; change and save events with the HH:MM value
  • Reduced motion swaps the drums for a flat list that steps without glide

Perfect for

4 use cases
  • Scheduling a send time, a reminder or a recurring report
  • Booking and appointment flows on mobile, where a native-feeling picker matters
  • Alarm, timer and opening-hours settings in an app's preferences
  • Any form where a typed time field invites the wrong format

How it works

4 sections

The drum

Each column keeps one number: how many rows it has turned. Eleven recycled row elements are placed around that number on every frame, each rotated on the X axis by its distance from the centre and pushed out to the cylinder's radius, which is derived from the row height so the rows meet edge to edge. Looping columns read their label with a modulo of that number, so there is no copy of the list and no seam to jump.

Flick and settle

While dragging, the last tenth of a second of pointer movement is kept. On release that becomes a speed in rows per second, the column projects where it would coast to, rounds to a whole row and tweens there with an ease-out whose starting speed matches the throw. A held finger releases with no speed and simply settles. Wheel events are prevented and stopped at the column, which is what keeps the page and any smooth scroller still.

The rolling readout

The time above the wheels is split into one slot per character. When a column comes to rest, only the slots whose character changed roll: the new glyph enters from the side the wheel turned towards and the old one leaves the other way, both in the same clipped cell, ones before tens.

Keyboard and ARIA

Each column is a spinbutton with a min, max, current value and a spoken value text such as "7 o'clock" or "30 minutes". Arrow keys step one row with a short roll, PageUp and PageDown take larger steps, Home and End jump to the ends, digits type ahead and A or P set the period. Pointer and wheel changes are announced once through a polite live region when the wheel settles.

Difficulty Advanced
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

The Vault opens this source and the whole library.

Each product gives you the HTML, CSS and JavaScript source, the full documentation and an AI setup prompt, licensed for unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

One payment, over 350 products, and future additions to the library.

Documentation

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, plain CSS custom properties for colours

  • assets/script.js: readable, commented source with an onReady guard

  • Three columns as 3D cylinders: each row is rotated on the X axis with perspective and fades as it turns away from the centre

  • Drag, flick, mouse wheel and trackpad; a flick coasts at the speed it was thrown and lands on a whole value

  • Hours and minutes loop with no seam; AM/PM rubber-bands at its two ends

  • A tap on a row above or below the band rolls it to the centre

  • The readout rolls only the characters that changed, in the direction their wheel turned

  • 12 or 24 hour clock and 1, 5 or 15 minute steps, read live from data attributes

  • A native <input type="time"> carries the value into your form and is the whole picker when JavaScript is off

  • Roles: group around the columns, spinbutton per column, status for announcements

Quick Start

1. Add to your <head>:

In your downloadThe working code for this step, in index.html.

The one-line has-js script hides the native fallback field before the first paint, so the page never shows it for a frame. The component script adds the class too, so it is optional if you can live with that frame.

2. Add before the closing </body> tag:

In your downloadThe working code for this step, in index.html.

3. Copy the component markup from index.html into your page:

In your downloadThe working code for this step, in index.html.

The script looks for every [data-wheel-time] on the page and builds the columns inside its [data-wtp-wheels]. Only that container is required. The readout, the native input, the status line and the two buttons are each optional: leave one out and the picker simply does without it. The .wtp-* classes are styling only; the script reads the data- hooks.

The toolbar strip and the grey ground in index.html are demo furniture, not part of the component; leave them behind.

Options

Attribute Values Default What it does
data-value HH:MM, 24-hour 09:00 The starting time. Kept current as the wheels move
data-format 12, 24 12 12-hour with an AM/PM column, or 24-hour with two
data-minute-step 1, 5, 15 5 Minute increments. Changing it rounds the current minute to the nearest step

data-format and data-minute-step are watched: set either at any time and the columns rebuild around the current time.

Keyboard & Accessibility

Each column is in the tab order and behaves as a spinbutton:

Key What it does
ArrowUp / ArrowDown Next / previous value, with a short roll
PageUp / PageDown Larger step: 3 hours, or 15 minutes (30 at a 15-minute step)
Home / End First / last value of the column
0-9 Type ahead: 7 picks 7, 4 then 5 picks 45
A / P AM / PM, on the period column
Tab Moves column to column, then on to the buttons
  • Roles: the columns sit in a role="group" labelled by the field label; each column is role="spinbutton" with aria-valuemin, aria-valuemax, aria-valuenow and aria-valuetext ("7 o'clock", "30 minutes", "PM"). The rows themselves are aria-hidden.
  • Announcements: keyboard changes are spoken through the spinbutton's own value text. Pointer and wheel changes are announced once, through the role="status" line, after the wheel settles ("7:45 AM selected"), so a long flick is not read out row by row.
  • The visible readout is aria-hidden once the script runs, because the spinbuttons already carry the value.
  • Pointer: touch-action: none on the columns, so a vertical drag turns the wheel and never scrolls the page. Wheel and trackpad events over a column are prevented and stopped there, which also keeps Lenis or any other smooth scroller on window still. No data-lenis-prevent is needed.
  • prefers-reduced-motion: reduce: the drums become a flat list, a release snaps straight to the nearest value with no coasting, the wheel steps one row per gesture and the readout swaps digits without rolling. Every input still works. Changing the preference while the page is open rebuilds the picker and keeps the time.
  • No JavaScript: the native time input is shown instead of the wheels and submits the same HH:MM value.

Themes

Two themes on one set of custom properties, chosen by data-variant:

In your downloadThe working code for this step, in index.html.

The attribute also works on the component root (<div class="wtp" data-variant="dark" ...>) if your page sets its own theme elsewhere. The demo's toggle and the ?variant=dark URL parameter only set that attribute; nothing in the script reads it.

The tokens, set in both body[data-variant="light"] and body[data-variant="dark"] in assets/style.css:

Token Used for
--raised The card surface
--ink, --ink-2, --ink-3 Centre value, readout period and labels, off-centre rows
--band The selection band behind the centre row
--line, --line-strong Card and button hairlines
--accent, --accent-ink The Save button and the focus ring
--hover Button hover
--shadow, --radius, --radius-sm Card elevation and corners
--font Inter by default

To fit your brand, re-value --accent and, if your surfaces differ, --raised and --band in both blocks. Keep text colours solid (no rgba on --ink-*), so contrast stays measurable. --ground and --ground-2 only colour the demo page.

How It Works

The drum. Each column keeps one number, how many rows it has turned. Eleven recycled row elements are placed around it every frame, each with rotateX by its distance from the centre and translateZ out to the cylinder's radius, and the drum sits back by that radius so the centre row renders at its true size. The radius is derived from --wtp-item, so the rows always meet edge to edge. Looping columns read their label with a modulo, so there is no duplicated list and no seam. GSAP tweens only that number; it never owns a row's transform, so no CSS transform can fight it.

Flick and settle. During a drag the last 100ms of pointer movement is kept. On release it becomes a speed in rows per second; the column projects where it would coast, rounds to a whole row and gsap.tos there with power3.out, whose duration is chosen so its starting speed matches the throw. A finger held still before letting go has no speed and just settles. Wheel input eases towards an accumulating target and snaps to a row 120ms after the last event.

The readout. The time is split into one clipped slot per character. When a column settles, only the slots whose character changed roll: the new glyph enters from the side the wheel turned towards, the old one leaves the other way, minutes before hours.

Teardown. Everything runs inside gsap.matchMedia(). Reverting it (or a motion-preference change) removes every listener, kills the tweens and empties the wheels, leaving the readout as plain text and the native input holding the last value.

Customisation

  • Row height: --wtp-item on .wtp (36px). The cylinder radius follows it.
  • Visible height: --wtp-rows on .wtp (5.5 rows).
  • Curvature: ITEM_ANGLE at the top of assets/script.js (20 degrees between rows). Smaller is a flatter, larger drum.
  • Throw: FLING_TIME (how far a release coasts) and MAX_FLING_ROWS (the cap on one flick).
  • Wheel sensitivity: WHEEL_ROWS_PER_PX (about one row per 100px mouse notch).
  • Large steps for PageUp/PageDown: largeStep() in assets/script.js.
  • Colours live in assets/style.css as custom properties per theme.

Requirements

  • GSAP 3.12+ (core only, no plugins)
  • No build step, no framework

Also in your download: the full source, worked examples, the events and programmatic API, and the class reference.

£7