BetaForms

WheelPicker

A column of options you turn, and the one at the middle is the answer.

Overview

A column of options you turn, and the one at the middle is the answer.

First example

import { WheelPicker } from '@xaui/native/wheel-picker'
export function Example() {
return <WheelPicker />
}

Anatomy

<WheelPicker>
  <WheelPicker.Column value={hour} onValueChange={setHour}>
    <WheelPicker.Item value="09">09</WheelPicker.Item>
  </WheelPicker.Column>
</WheelPicker>
  • WheelPicker — the wheel. It resolves the recipe, computes the geometry once and draws the band. It has no value of its own.
  • WheelPicker.Column — one turning column, and the thing that has a value.
  • WheelPicker.Item — one row. A Text, not a control.

Usage

import { WheelPicker } from '@xaui/native/wheel-picker'

Root props

WheelPickerProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
variantWheelPickerVariant | undefinedNo
sizeWheelPickerSize | undefinedNoThe row's height and its type. The wheel's width is its parent's business.
radiusRadiusKey | undefinedNoThe band's corner.
colorstring | undefinedNoA raw tint (`'#7c3aed'`), never a token (R7). It lands on the band.
visibleCountnumber | undefinedNoHow many rows are visible at once — **forced odd**, because the whole control is built on there being a middle row. A raw number, so it lives outside the style cache: it is a count rather than a token, and the wheel's height falls out of it.
isDisabledboolean | undefinedNoDims every column and stops the scroll. A column cannot opt back in.
styleStyleProp<ViewStyle>No
childrenReactNodeNo

Slots

WheelPickerColumnProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
valuestring | undefinedNoThe chosen row's value. Controlled — leave it out and the column holds its own.
defaultValuestring | undefinedNoThe row it starts on. Unset, and with no `value`, that is the first one.
onValueChange((value: string) => void) | undefinedNoFired when the wheel comes to **rest** on a new row, never while it is turning: a column that reported every row it passed would fire nine times on one flick, and each of those is a value some caller would have written to a form.
isDisabledboolean | undefinedNoDims this column and stops its scroll.
childrenReactNodeNo

WheelPickerItemProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
valuestringYesWhat this row stands for. The column reports it; it is not the text.
childrenReactNodeNo
styleStyleProp<TextStyle>No

Variants, sizes and colour

Sizes

sizeRowType
sm3214/20
md3616/24
lg4418/28

size is the row's height, never the wheel's width. A wheel spans its parent and its columns divide that between them, which is RN's own behaviour — a caller who wants a narrow one writes a width on the wheel or a flex on a column.

md is iOS's picker row measured: 36 points, which is what puts five of them in the 180 the platform's own wheel is.

The row heights are off the spacing grid on purpose, like the Slider's rail: how tall a row has to be before a list of them reads as something you can aim at has nothing to do with the gaps between things.

Variants and colour

variantBandThe row on it
primaryaccentSoftaccentSoftForeground
secondarydefaultdefaultForeground
tertiarytwo hairlinesforeground
ghostforeground

Four emphasis levels and no intent: a wheel reports a choice, and a choice is neither a success nor a danger. What the variant names is the band.

tertiary is two hairlines rather than a box, because the band marks a row in a column of rows and a full border round it reads as a field the wheel is inside. ghost names neither a fill nor a border, and that is a design rather than an omission — the rows already say which one is chosen, and on a busy screen the band is the part that reads as chrome.

color is a raw value (R7) and lands on the band. secondary names defaultForeground rather than foreground for exactly that: resolveTint reads the role off the token's own name, a bare foreground is a neutral and maps to the tint itself, and a band painted the same colour as the row sitting on it is a row you cannot read.

Accessibility

  • The column carries the label, not the rows: accessibilityLabel="Heures" on a column is what a screen reader announces, and every row announcing itself would announce the whole drum.
  • Rows other than the chosen one are hidden from the accessibility tree for the same reason.
  • isDisabled stops the scroll rather than dimming a control that still moves.

Migration from legacy

This component declares no rule of its own. The migration guide covers variants, colours and slots.

Implementation notes

The column has the value, not the wheel

A time is two columns and a date is three, so a wheel with a single value would be a wheel that can only ever be one of them. The WheelDatePicker, WheelTimePicker and WheelDateTimePicker of P5.25 are all this component with a different set of columns and the arithmetic to fill them — which is why it is here first, and why the roadmap calls it "the spinning column the three below share".

<WheelPicker>
  <WheelPicker.Column
    value={hour}
    onValueChange={setHour}
    accessibilityLabel="Heures"
  >
    {HOURS.map(h => (
      <WheelPicker.Item key={h} value={h}>
        {h}
      </WheelPicker.Item>
    ))}
  </WheelPicker.Column>
  <WheelPicker.Column
    value={minute}
    onValueChange={setMinute}
    accessibilityLabel="Minutes"
  >
    {MINUTES.map(m => (
      <WheelPicker.Item key={m} value={m}>
        {m}
      </WheelPicker.Item>
    ))}
  </WheelPicker.Column>
</WheelPicker>

The scroll is the control

There is no press to select. The row at the middle is the choice, so a column snaps to a row and reports whichever one it stopped at — which is what makes this a wheel rather than a short list, and why the rows are Text nodes rather than pressables. A row you could tap would be a second way to choose that the band does not describe.

It reports at rest, never while turning. One flick passes nine rows, and every one of them is a value some caller would have written to a form. onScrollEndDrag covers a slow drag that stops without momentum and onMomentumScrollEnd covers the flick; both are needed, and neither fires for the other.

Coming back to rest on the row you started from fires nothing at all.

The band is the root's

One shape laid across every column, rather than one per column: two columns at different widths would show the seam between their two bands. It is pointerEvents: 'none', so it marks the middle without taking the touch that turns the wheel under it.

It is written by the root rather than by the caller, unlike the Tabs's indicator — that one is a choice about a decoration, and this is where the middle is.

The rows fade and lean away

Not decoration: it is the whole of what says this is a drum with more of it out of sight rather than a list that happens to have stopped. The row at the middle is upright, full strength and in the band's colour; two rows out it is at a quarter opacity, 82% of its size and turned 55°.

The turn is read from the column's scroll offset on the UI thread, through a shared value. A position crossing the bridge every frame would animate at the rate React re-renders rather than at the rate the finger moves — which on a fast flick is the difference between a drum and a slideshow.

`visibleCount` is forced odd

<WheelPicker visibleCount={7}>…</WheelPicker>

The whole control is built on there being a middle row. An even count has two rows equally near the centre, and the band would sit over the seam between them. A count is rounded up to the next odd number, because a caller who asked for four wanted more than three, not less.

It is a raw number rather than a token, like the ProgressCircle's radius, so it lives outside the style cache: the wheel's height is visibleCount rows and is applied after the recipe rather than inside it.

There is no `loop`

An endless drum is not a scroll view with a flag on it — it is a list with no end, faked by rewriting the data around the finger and jumping the offset back whenever it drifts too far. That trick belongs to the caller's data, where the caller knows how many months there are; here it would be a component quietly renumbering its own children.

Extending it

useWheelPicker() carries the resolved styles and the geometry — the row height above all, since the snap interval, the padding and the rest position are all computed from it. useWheelPickerColumn() carries the live offset, the resting index and each row's position. Both are exported (R10), and both throw by name outside their parent.