ui-063

Wheel Date Picker

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

A wheel date picker: day, month and year drums in the locale's order that flick and coast, skip dates a month lacks and respect min and max.

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

Released

About this component

The date picker people already know from their phone, built for the web. Day, month and year sit on three drums drawn in real 3D, each row turned on the cylinder and fading 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. Day and month loop with no seam, the year stops at the ends of its range with a rubber-band give. The calendar is real: days a month does not have are faded and skipped, so landing on the 31st and then choosing February rolls the day drum back to the 28th, or the 29th in a leap year, instead of jumping.

The columns follow the visitor's locale: day, month, year for a British page, month first for an American one, year first in Japanese, with month names in the page's language. Earliest and latest dates fade everything outside them, and a date and time mode puts one drum of days (Today, Tue 10 Nov, ...) beside hours and minutes, as on a phone. A native date input carries the value into a form and is the whole picker when JavaScript is off.

Wheel Date Picker - UI component preview

What's included

10 items
  • Day, month and year 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 settles on a whole value
  • Day and month loop infinitely; the year rubber-bands at the ends of its range
  • Days a month lacks are faded and skipped: from the 31st, choosing February rolls the day back to the 28th or 29th
  • Column order, month names and day labels follow the locale: day-month-year, month-day-year or year-month-day
  • Earliest and latest dates, or "today", fade everything outside them and roll back from it
  • Date and time mode: one drum of days labelled Today, Tue 10 Nov and so on, beside hour, minute and AM/PM where the locale uses it
  • Readout above rolls only the characters that changed, in the locale's own date format
  • Each column is a role="spinbutton" with arrow keys, PageUp/PageDown, Home/End, typed digits and typed month initials
  • No page scroll while dragging or wheeling a column, including under Lenis; native date input for forms and no JavaScript

Perfect for

4 use cases
  • Date of birth fields in sign-up, checkout and age confirmation flows
  • Scheduling a send, a reminder or a delivery slot on mobile
  • Booking forms for international audiences, where the column order must match the visitor's locale
  • Any form where a typed date invites the wrong format

How it works

4 sections

The drum

Each column keeps one number: how far it has turned. A handful of recycled rows are placed around that number on every frame, each turned on the cylinder by its distance from the centre and pushed out to a radius that follows the row height, so the rows always meet edge to edge. Looping columns read their label round the list, so there is no copy of the list and no seam, and a hundred years of dates costs nothing until it is turned to.

A calendar that knows its limits

When a column comes to rest the picker reads all three and pulls the date inside the calendar and the earliest and latest dates, in order: year, then month, then day. Any column showing a value that was ruled out rolls the short way to the one that is now selected, so a 31st in February glides back to the end of the month rather than snapping. While the month is still turning, the days it lacks fade as soon as it passes the band.

Locale

The column order, the month names, the day and year labels, the date and time labels and the readout all come from the browser's own date formatting for one locale: the one named on the picker, or the page's language, or the visitor's. The clock follows the locale too, twelve-hour with an AM/PM column where that is the custom and twenty-four-hour elsewhere.

Keyboard and ARIA

Each column is a spinbutton with a spoken value such as "14", "March" or "1992". Arrow keys step one row and skip the dates that are ruled out, PageUp and PageDown take larger steps, Home and End jump to the first and last allowed value, digits type ahead and letters pick a month by its initial. Pointer and wheel changes are announced once through a polite live region when the wheels settle.

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

  • Day and month loop with no seam; the year rubber-bands at the ends of its range

  • Days a month lacks are faded and skipped: landing on the 31st and then choosing February rolls the day drum back to the 28th, or the 29th in a leap year

  • Column order, month names and labels from Intl.DateTimeFormat: day-month-year, month-day-year or year-month-day

  • data-min and data-max (or today) fade and skip everything outside them; the drum rolls back from a date it lands on out of range

  • A date and time mode: one drum of days ("Today", "Tue 10 Nov", ...) beside hour, minute and, where the locale uses one, AM/PM

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

  • A native <input type="date"> (or datetime-local) 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-date] on the page and builds the columns inside its [data-wdp-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 .wdp-* 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. The demo's mode switch also swaps the card's label and limits between a date of birth and a scheduled send, which is demo wiring too.

Options

Attribute Values Default What it does
data-value YYYY-MM-DD, or YYYY-MM-DDTHH:MM today The starting value. Kept current as the wheels move
data-mode date, datetime date Day, month and year columns, or one date column with hour and minute
data-locale a BCP 47 tag: en-GB, en-US, ja-JP, de-DE see Locale Column order, month names, labels and the 12/24-hour clock
data-min YYYY-MM-DD, YYYY-MM-DDTHH:MM or today none The earliest date (and time) that can be chosen
data-max as data-min none The latest
data-minute-step 1, 5, 15 5 Minute increments in datetime mode
data-format 12, 24 the locale's Forces the datetime clock regardless of locale

Every option except data-value is watched: set any of them at any time and the columns rebuild around the current value, pulled inside the new limits. Set data-value in the same tick as one of them to rebuild around a new value instead.

Locale

The picker uses one locale for everything it prints: the order of the columns, the month names, the day and year labels (14日 and 1992年 in Japanese, 14. in German), the date column in datetime mode, the readout, the AM/PM labels and whether the clock is 12 or 24 hour. It is chosen in this order:

  1. data-locale on the picker
  2. the page's lang (the nearest [lang] ancestor) when it names a region, such as en-GB
  3. the browser's language when it is the same language as the page (an lang="en" page read in an en-GB browser gets day-month-year)
  4. the page's lang, then en-US
Locale Columns Readout
en-GB day, month, year 14 March 1992
en-US month, day, year March 14, 1992
ja-JP year, month, day 1992年3月14日
de-DE day, month, year 14. März 1992

The spinbuttons' accessible names ("Day", "Month", "Year") come from the same locale through Intl.DisplayNames, so they match the language the values are spoken in. The value the picker emits is always ISO, whatever the locale.

Limits

  • The calendar. The day column always shows 1 to 31; days the current month lacks are faded and cannot be chosen. Land on one, by flick, wheel or month change, and the day drum rolls back to the last day of the month. Arrow keys step over them (the 28th of February steps up to the 1st in a non-leap year).
  • data-min and data-max. In date mode the year column runs from the min year to the max year and stops there with a rubber-band give. Inside the boundary years, the months and days outside the range fade the same way and roll back the same way. Without them the years run from 100 years ago to 20 ahead (YEARS_BACK, YEARS_AHEAD in the script).
  • today resolves when the picker builds, in the visitor's time zone. data-max="today" is the date-of-birth case.
  • Datetime mode. The date column runs from data-min to data-max, or a year back and two ahead without them (DAYS_BACK, DAYS_AHEAD). A time on either limit (data-min="2026-11-09T14:30") also fades the hours and minutes outside it on that day, and the time rolls forward to the limit if it lands before it.
  • The native input gets matching min and max attributes from the script. Write a literal min and max in the markup too if the no-JavaScript field should be limited; today cannot be written there, so the server should check it.

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, skipping values the limits rule out
PageUp / PageDown Larger step: 7 days, 3 months, 10 years, 3 hours, 15 minutes
Home / End First / last allowed value of the column
0-9 Type ahead: 1 then 4 picks the 14th; on the year, 1 9 9 2 walks to 1992
Letters On the month, the first month starting with the letters typed; the same letter again moves to the next (M: March, then May)
T Today, on the datetime date column
A / P AM / PM (or the locale's labels' initials), 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 ("14", "March", "1992"; "Today, Monday, 9 November 2026" on the datetime date column). 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 wheels settle ("14 March 1992"), so a long flick is not read out row by row.
  • Faded values are decoration, painted by CSS from a data-mark attribute, not text: they cannot be chosen, so they are not offered to assistive technology, and their low contrast is not read as low-contrast copy. The same applies to the rows more than one step from the centre.
  • 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, a ruled-out day jumps back rather than rolling, and the readout swaps characters without rolling. Every input still works. Changing the preference while the page is open rebuilds the picker and keeps the date.
  • No JavaScript: the native date input is shown instead of the wheels and submits the same ISO 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="wdp" 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 AM/PM and labels, off-centre rows
--ink-off Days and dates the limits rule out
--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, --band and --ink-off 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 --wdp-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; labels are formatted on demand and cached, so a long year or date range costs nothing until it is turned to. 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.

Limits. When a column comes to rest, the picker reads every column and clamps the result: year into range, then month into the range for that year, then day into the range for that month (the month's length and any min or max). Every column that is not still moving and shows something other than the clamped value rolls to it the short way round. While a year or month column turns, the columns that depend on it re-render as soon as its centre row changes, so the 30th and 31st fade the moment February passes the band.

The readout. The value is formatted with the locale's formatToParts, and each part gets a slot: one per digit for numbers, one for a word. When the value changes, only the slots whose text changed roll; the new glyph enters from the side the value moved towards and the old one leaves the other way. A part that changes width (9 to 10, March to May) rolls as one piece and splits back into digits when it lands.

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: --wdp-item on .wdp (36px). The cylinder radius follows it.
  • Visible height: --wdp-rows on .wdp (5.5 rows).
  • Column widths: the flex-grow rules on .wdp-col[data-wdp-col="..."] in assets/style.css (month widest, day narrowest).
  • 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).
  • Default ranges: YEARS_BACK, YEARS_AHEAD, DAYS_BACK, DAYS_AHEAD.
  • 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)
  • Intl.DateTimeFormat with formatToParts, in every current browser; Intl.DisplayNames and Intl.RelativeTimeFormat are used when present, with English fallbacks
  • No build step, no framework

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

£7