A value picked by turning a column until it rests under a mark.
On this page
A wheel picks a value by turning a column until the value rests under a mark: an hour, a minute, a unit. It is the picker of a phone, made of what the platform already has: each column is a scroll container that snaps one option to its center. The wheel is a well, and one small sheet lies across its middle to say which row is the value.
Turning is scrolling. A wheel, a finger and a trackpad work as they do in any scroll container.
Coming to rest is scroll snapping: scroll-snap-type: y mandatory on the column and scroll-snap-align: center on each option. A column never stops between two values. Space at both ends lets the first and last options reach the mark.
The look of a wheel is a scroll-driven animation. Each option is its own view timeline over the column: it is full ink at the middle of its pass, and quieter and turned slightly away the further it has gone. No script reads a scroll position to draw this.
Where the browser has no view timelines, all options are drawn flat and the one marked aria-selected="true" takes the strong ink.
The stylesheet cannot tell your form which value is under the mark. The wheelbehavior reads it from how far the column has scrolled. It takes the root, the element with the wheel class, has no options, and returns the function that detaches it.
app.js
import{ wheel }from'@master/ui/behaviors/wheel.js'const root = document.querySelector('.wheel')wheel(root)root.addEventListener('change',(event)=>{// event.target is the column that came to rest. console.log(event.target.getAttribute('aria-label'), event.detail.value)})
Coming to rest. When a column has settled, the behavior moves aria-selected="true" to the option under the mark.
The event. The column then dispatches a bubbling change, when the option is a new one. Its detail.value is the option's data-value, or its text, and detail.option is the option.
Starting. When it attaches, and when a column is laid out again, as in a popover that opens, it brings each column's chosen option under the mark.
A click on an option brings that option under the mark.
The keys. A focused column has the keys of a listbox, one option at a time.
Disabled options. A column that came to rest on an option with aria-disabled="true" turns on to the nearest one that is not.
To submit the value with a form, copy detail.value into a hidden input in the change listener. Without the behavior a column still scrolls and snaps, but it starts at its first option and aria-selected stays where the markup put it.
The sheet across the middle belongs to the wheel, not to a column, so the hours and the minutes of one time share it. It sits 4px inside the well and takes the concentric corner. wheel-separator is fixed text that rests on it between two columns: the colon of a time, a unit after a number.
Rows are 36px, and 44px on a touch screen. Set --wheel-rows to an odd number to show more of the column.
A wheel suits a short, ordered set that a reader knows by heart, on a device they touch. With a keyboard and a pointer, a select or a stepper is quicker, and for a date the calendar shows the week a reader is choosing in. A native <input type="time"> already opens the platform's own wheel on a phone; reach for this one when the picker has to live in the page.
With the behavior a key moves a focused column by whole options and skips disabled ones. Without it the same keys scroll the column as they scroll any scroll container, and snapping finishes the move.
Keys
Action
ArrowUp, ArrowDown
Scroll the focused column until the option before or after rests under the mark, skipping disabled options
PageUp, PageDown
Scroll the focused column by a page: as many options as it shows
Home, End
Scroll the focused column to its first or its last option
Each column is a role="listbox" with an aria-label and tabindex="0"; each option is a role="option". The behavior keeps aria-selected on the option under the mark.
The turn and the fade follow the reader's own scrolling. With reduced motion the options keep the fade and lose the turn.
The wheel component renders the columns from data, attaches the behavior and passes the value of each column in and out. Copy it into the project with npx master-ui add wheel; React, Svelte and Vue says how the components are built and used.
ui/wheel.tsx
'use client'import{ Fragment, useState,type ComponentProps }from'react'import{ wheel }from'@master/ui/behaviors/wheel.js'import{ useBehavior }from'./use-behavior'exportinterfaceWheelColumn{/** The name of the column, for assistive technology. */label:stringoptions:(string|{value:string,label:string,disabled?:boolean})[]/** Fixed text after the column that rests on the mark: the colon of a time, a unit. */separator?:string}exportinterfaceWheelPropsextendsOmit<ComponentProps<'div'>,'defaultValue'|'role'>{columns:WheelColumn[]/** The value of each column, in the order of the columns. */value?:string[]defaultValue?:string[]onValueChange?:(value:string[])=>void}/** Columns that turn. Name the group with `aria-label` or `aria-labelledby`. */exportfunctionWheel({ columns, value, defaultValue, onValueChange, className,...root }:WheelProps){const ref =useBehavior(wheel,undefined,{ value, defaultValue, onValueChange })const[first]=useState(value ?? defaultValue ??[])return(<divclassName={className ?`wheel ${className}`:'wheel'}role="group"ref={ref}{...root}>{columns.map((column, at)=>(<Fragmentkey={column.label}><divclassName="wheel-column"role="listbox"aria-label={column.label}tabIndex={0}>{column.options.map((option)=>{const{value: own, label, disabled =false}=typeof option ==='string'?{value: option,label: option }: optionreturn<divclassName="wheel-option"role="option"key={own}data-value={own}aria-selected={own === first[at]}aria-disabled={disabled ||undefined}>{label}</div>})}</div>{column.separator!==undefined&&<spanclassName="wheel-separator"aria-hidden="true">{column.separator}</span>}</Fragment>))}</div>)}
ui/Wheel.svelte
<scriptlang="ts"module>importtype{ HTMLAttributes }from'svelte/elements'exportinterfaceWheelColumn{/** The name of the column, for assistive technology. */label:stringoptions:(string|{value:string,label:string,disabled?:boolean})[]/** Fixed text after the column that rests on the mark: the colon of a time, a unit. */separator?:string}exportinterfaceWheelPropsextendsOmit<HTMLAttributes<HTMLDivElement>,'role'|'children'>{columns:WheelColumn[]/** The value of each column, in the order of the columns. Bind it, or leave it out. */value?:string[]onValueChange?:(value:string[])=>void}</script><scriptlang="ts">import{ untrack }from'svelte'import{ wheel }from'@master/ui/behaviors/wheel.js'import{ behavior }from'./behavior.svelte.js'let{ columns, value =$bindable(), onValueChange,class: className,...root }:WheelProps=$props()const first =untrack(()=> value ??[])const attach =behavior(wheel,undefined,{value:()=> value,change:(next)=>{ value = next;onValueChange?.(next)},})constplain=(option:WheelColumn['options'][number])=>typeof option ==='string'?{value: option,label: option,disabled:false}: option</script><!--@component Columns that turn. Name the group with `aria-label` or `aria-labelledby`.--><divclass={['wheel', className]}role="group"{@attach attach}{...root}>{#each columns as column, at (column.label)}<divclass="wheel-column"role="listbox"aria-label={column.label}tabindex={0}>{#each column.options.map(plain)as option (option.value)}<divclass="wheel-option"role="option"data-value={option.value}aria-selected={option.value=== first[at]}aria-disabled={option.disabled||undefined}>{option.label}</div>{/each}</div>{#if column.separator!==undefined}<spanclass="wheel-separator"aria-hidden="true">{column.separator}</span>{/if}{/each}</div>
ui/Wheel.vue
<scriptlang="ts">exportinterfaceWheelColumn{/** The name of the column, for assistive technology. */label:stringoptions:(string|{value:string,label:string,disabled?:boolean})[]/** Fixed text after the column that rests on the mark: the colon of a time, a unit. */separator?:string}</script><scriptsetuplang="ts">import{ useTemplateRef }from'vue'import{ wheel }from'@master/ui/behaviors/wheel.js'import{ useBehavior }from'./use-behavior'defineOptions({inheritAttrs:false})defineProps<{columns:WheelColumn[]}>()/** The value of each column, in the order of the columns: `v-model`. */const model =defineModel<string[]>()/* Columns that turn. Name the group with `aria-label` or `aria-labelledby`. */const first = model.value??[]const root =useTemplateRef<HTMLDivElement>('root')useBehavior(root, wheel,undefined, model)constrow=(option:WheelColumn['options'][number])=>typeof option ==='string'?{value: option,label: option,disabled:false}: option</script><template><divclass="wheel"role="group"ref="root"v-bind="$attrs"><templatev-for="(column, at)in columns":key="column.label"><divclass="wheel-column"role="listbox":aria-label="column.label"tabindex="0"><divv-for="option in column.options.map(row)":key="option.value"class="wheel-option"role="option":data-value="option.value":aria-selected="option.value=== first[at]":aria-disabled="option.disabled||undefined">{{ option.label}}</div></div><spanv-if="column.separator!==undefined"class="wheel-separator"aria-hidden="true">{{ column.separator}}</span></template></div></template>
/* * Wheel: a value picked by turning a column until it rests under a mark: an hour, a minute, a unit. * The wheel is a well. Each column in it is a scroll container that snaps one option to its center, and one * small sheet lies across the middle of the well, under the options, to say which row is the value; several * columns share it, as the hours and the minutes of one time do. A column fades into the well at both ends. * Scrolling, snapping and the arrow keys are the platform's. Where the browser has view timelines, each * option reads its own place in the column: it is full ink under the mark, and quieter and slightly turned * away the further it has gone. Elsewhere, and for assistive technology, the chosen option is the one a * script marks aria-selected, which it reads from how far the column has scrolled. */@layer components {.wheel{--wheel-row:2.25rem;--wheel-rows:5;--wheel-radius:var(--radius-control-lg);--wheel-inset:4px;position:relative;display:inline-flex;align-items:stretch;justify-content:center;max-width:100%;height:calc(var(--wheel-row)*var(--wheel-rows));padding:0var(--wheel-inset);border-radius:calc(var(--wheel-radius)*var(--corner-scale));corner-shape:var(--corner-shape);background:var(--color-surface-inset);color:var(--color-text-strong);box-shadow:var(--shadow-pressed);font:var(--font-weight-control).9375rem/1var(--font-family-sans);font-feature-settings:inherit;font-variant-numeric:tabular-nums;vertical-align:middle;isolation:isolate;/* The mark: a sheet as tall as a row, 4px inside the well, with the concentric corner. */&::before{content:'';position:absolute;inset:calc(50%-var(--wheel-row)/2)var(--wheel-inset)auto;z-index:-1;height:var(--wheel-row);border-radius:calc(var(--wheel-radius)*var(--corner-scale)-var(--wheel-inset));corner-shape:var(--corner-shape);@apply--surface-sheet;box-shadow:var(--shadow-raised);pointer-events:none;}&.wheel-sm{--wheel-row:2rem;--wheel-rows:3;--wheel-radius:var(--radius-control);font-size:.875rem;}@media(pointer:coarse){--wheel-row:2.75rem;}@media(forced-colors:active){outline:1pxsolid CanvasText;outline-offset:-1px;&::before{outline:1pxsolid Highlight;outline-offset:-1px;}}}/* * A column: a listbox that scrolls. Start padding and an end spacer leave room for the first and last * options to rest under the mark. The spacer participates in scrollable overflow even when a browser * excludes trailing padding from a perspective container's scroll extent. */.wheel-column{display:block;flex:11auto;min-width:3.25rem;margin:0;padding:calc((var(--wheel-rows)-1)/2*var(--wheel-row))00;overflow:hiddenauto;overscroll-behavior:contain;list-style:none;scroll-snap-type:ymandatory;scrollbar-width:none;mask-image:linear-gradient(tobottom,transparent,black38%,black62%,transparent);perspective:20rem;&::after{content:'';display:block;height:calc((var(--wheel-rows)-1)/2*var(--wheel-row));}@media(forced-colors:active){mask-image:none;}&:focus-visible{outline:2pxsolidvar(--color-focus);outline-offset:-2px;border-radius:calc(var(--wheel-radius)*var(--corner-scale)-var(--wheel-inset));corner-shape:var(--corner-shape);}}.wheel-option{display:flex;align-items:center;justify-content:center;height:var(--wheel-row);margin:0;padding:0.875rem;color:var(--color-text-muted);white-space:nowrap;scroll-snap-align:center;scroll-snap-stop:normal;user-select:none;-webkit-user-select:none;&[aria-selected="true"]{color:var(--color-text-strong);}&[aria-disabled="true"]{color:var(--color-text-disabled);}/* * Each option is its own view timeline over the column. At the middle of its pass it is under the mark; * a row away it has already let go of the ink. */@supports(animation-timeline:view()){&:not([aria-disabled="true"]){color:var(--color-text-strong);animation-name: wheel-turn;animation-timing-function:linear;animation-fill-mode:both;animation-timeline:view(block);animation-range:cover;}@media(prefers-reduced-motion:reduce){&:not([aria-disabled="true"]){animation-name: wheel-fade;}}/* In forced colors a faded value could not be read: every option is plain text, the chosen one marked by the sheet. */@media(forced-colors:active){&:not([aria-disabled="true"]){animation-name:none;}}}}/* Fixed text between columns: the colon of a time, a unit after a number. It rests on the mark. */.wheel-separator{display:flex;flex:none;align-items:center;align-self:center;height:var(--wheel-row);color:var(--color-text-muted);user-select:none;-webkit-user-select:none;}}@keyframes wheel-turn {0%{opacity:.2;rotate:x-52deg;scale:.86;}38%{opacity:.42;rotate:x-18deg;scale:.96;}50%{opacity:1;rotate:x0deg;scale:1;}62%{opacity:.42;rotate:x18deg;scale:.96;}100%{opacity:.2;rotate:x52deg;scale:.86;}}@keyframes wheel-fade {0%, 100%{opacity:.2;}38%, 62%{opacity:.42;}50%{opacity:1;}}