Navigation

v0.0.4
Forms

Wheel

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.

Wheel

<div class="wheel" role="group" aria-label="Reminder time">  <div class="wheel-column" role="listbox" aria-label="Hour" tabindex="0">    <div class="wheel-option" role="option" aria-selected="false">01</div>    <div class="wheel-option" role="option" aria-selected="false">02</div>    <div class="wheel-option" role="option" aria-selected="false">03</div>    <div class="wheel-option" role="option" aria-selected="false">04</div>    <div class="wheel-option" role="option" aria-selected="false">05</div>    <div class="wheel-option" role="option" aria-selected="false">06</div>    <div class="wheel-option" role="option" aria-selected="false">07</div>    <div class="wheel-option" role="option" aria-selected="false">08</div>    <div class="wheel-option" role="option" aria-selected="true">09</div>    <div class="wheel-option" role="option" aria-selected="false">10</div>    <div class="wheel-option" role="option" aria-selected="false">11</div>    <div class="wheel-option" role="option" aria-selected="false">12</div>  </div>  <span class="wheel-separator" aria-hidden="true">:</span>  <div class="wheel-column" role="listbox" aria-label="Minute" tabindex="0">    <div class="wheel-option" role="option" aria-selected="false">00</div>    <div class="wheel-option" role="option" aria-selected="false">05</div>    <div class="wheel-option" role="option" aria-selected="false">10</div>    <div class="wheel-option" role="option" aria-selected="false">15</div>    <div class="wheel-option" role="option" aria-selected="false">20</div>    <div class="wheel-option" role="option" aria-selected="false">25</div>    <div class="wheel-option" role="option" aria-selected="true">30</div>    <div class="wheel-option" role="option" aria-selected="false">35</div>    <div class="wheel-option" role="option" aria-selected="false">40</div>    <div class="wheel-option" role="option" aria-selected="false">45</div>    <div class="wheel-option" role="option" aria-selected="false">50</div>    <div class="wheel-option" role="option" aria-selected="false">55</div>  </div>  <div class="wheel-column" role="listbox" aria-label="Period" tabindex="0">    <div class="wheel-option" role="option" aria-selected="true">AM</div>    <div class="wheel-option" role="option" aria-selected="false">PM</div>  </div></div><script type="module">  import { attach } from '@master/ui/behaviors'  attach()</script>
Scroll a column, drag it on a touch screen, click an option, or focus a column and use the arrow keys.

#What the platform does

  • 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 behavior

The stylesheet cannot tell your form which value is under the mark. The wheel behavior 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.

#One column

A wheel may hold a single column. wheel-sm shows three rows instead of five, for a wheel inside a popover or a row of settings.

One column

<div class="wheel" role="group" aria-label="Repeat every">  <div class="wheel-column" role="listbox" aria-label="Interval" tabindex="0">    <div class="wheel-option" role="option" aria-selected="false">Hour</div>    <div class="wheel-option" role="option" aria-selected="false">Day</div>    <div class="wheel-option" role="option" aria-selected="true">Week</div>    <div class="wheel-option" role="option" aria-selected="false">Fortnight</div>    <div class="wheel-option" role="option" aria-selected="false">Month</div>    <div class="wheel-option" role="option" aria-selected="false">Quarter</div>    <div class="wheel-option" role="option" aria-selected="false">Year</div>  </div></div><div class="wheel wheel-sm" role="group" aria-label="Font size">  <div class="wheel-column" role="listbox" aria-label="Size" tabindex="0">    <div class="wheel-option" role="option" aria-selected="false">12</div>    <div class="wheel-option" role="option" aria-selected="false">13</div>    <div class="wheel-option" role="option" aria-selected="true">14</div>    <div class="wheel-option" role="option" aria-selected="false">15</div>    <div class="wheel-option" role="option" aria-selected="false">16</div>    <div class="wheel-option" role="option" aria-selected="false">18</div>    <div class="wheel-option" role="option" aria-selected="false">20</div>    <div class="wheel-option" role="option" aria-selected="false">24</div>  </div>  <span class="wheel-separator" aria-hidden="true">px</span></div><script type="module">  import { attach } from '@master/ui/behaviors'  attach()</script>

#The mark

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.

#When to use something else

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.

#Keyboard

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.

KeysAction
ArrowUp, ArrowDownScroll the focused column until the option before or after rests under the mark, skipping disabled options
PageUp, PageDownScroll the focused column by a page: as many options as it shows
Home, EndScroll the focused column to its first or its last option

#Accessibility

  • 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.

#React, Svelte and Vue

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'export interface WheelColumn {  /** The name of the column, for assistive technology. */  label: string  options: (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}export interface WheelProps extends Omit<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`. */export function Wheel({ columns, value, defaultValue, onValueChange, className, ...root }: WheelProps) {  const ref = useBehavior(wheel, undefined, { value, defaultValue, onValueChange })  const [first] = useState(value ?? defaultValue ?? [])  return (    <div className={className ? `wheel ${className}` : 'wheel'} role="group" ref={ref} {...root}>      {columns.map((column, at) => (        <Fragment key={column.label}>          <div className="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 } : option              return <div className="wheel-option" role="option" key={own} data-value={own} aria-selected={own === first[at]} aria-disabled={disabled || undefined}>{label}</div>            })}          </div>          {column.separator !== undefined && <span className="wheel-separator" aria-hidden="true">{column.separator}</span>}        </Fragment>      ))}    </div>  )}
ui/Wheel.svelte
<script lang="ts" module>  import type { HTMLAttributes } from 'svelte/elements'  export interface WheelColumn {    /** The name of the column, for assistive technology. */    label: string    options: (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  }  export interface WheelProps extends Omit<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><script lang="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) },  })  const plain = (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`.--><div class={['wheel', className]} role="group" {@attach attach} {...root}>  {#each columns as column, at (column.label)}    <div class="wheel-column" role="listbox" aria-label={column.label} tabindex={0}>      {#each column.options.map(plain) as option (option.value)}        <div class="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}<span class="wheel-separator" aria-hidden="true">{column.separator}</span>{/if}  {/each}</div>
ui/Wheel.vue
<script lang="ts">export interface WheelColumn {  /** The name of the column, for assistive technology. */  label: string  options: (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><script setup lang="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)const row = (option: WheelColumn['options'][number]) => typeof option === 'string' ? { value: option, label: option, disabled: false } : option</script><template>  <div class="wheel" role="group" ref="root" v-bind="$attrs">    <template v-for="(column, at) in columns" :key="column.label">      <div class="wheel-column" role="listbox" :aria-label="column.label" tabindex="0">        <div v-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>      <span v-if="column.separator !== undefined" class="wheel-separator" aria-hidden="true">{{ column.separator }}</span>    </template>  </div></template>

#Classes

ClassDescription
wheelThe well and the mark across its middle, which its columns share
wheel-smThree 32px rows; the default is five 36px rows. With wheel
wheel-columnA column that scrolls and snaps every option, including the first and last, to its center, on a role="listbox" with a label and tabindex="0"
wheel-optionOne value in a column, on a role="option"
wheel-separatorFixed text between columns that rests on the mark: the colon of a time, a unit after a number

#Custom properties

PropertyDefaultDescription
--wheel-row2.25remHeight of a row and of the mark; 2.75rem on touch screens
--wheel-rows5Rows in view; keep it odd
--wheel-radiusvar(--radius-control-lg)Corner of the well

#Source

@master/ui/components/wheel.css
/* * 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: 0 var(--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/1 var(--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: 1px solid CanvasText;      outline-offset: -1px;      &::before {        outline: 1px solid 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: 1 1 auto;    min-width: 3.25rem;    margin: 0;    padding: calc((var(--wheel-rows) - 1) / 2 * var(--wheel-row)) 0 0;    overflow: hidden auto;    overscroll-behavior: contain;    list-style: none;    scroll-snap-type: y mandatory;    scrollbar-width: none;    mask-image: linear-gradient(to bottom, transparent, black 38%, black 62%, 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: 2px solid var(--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: x 0deg;    scale: 1;  }  62% {    opacity: .42;    rotate: x 18deg;    scale: .96;  }  100% {    opacity: .2;    rotate: x 52deg;    scale: .86;  }}@keyframes wheel-fade {  0%, 100% {    opacity: .2;  }  38%, 62% {    opacity: .42;  }  50% {    opacity: 1;  }}