v3.0.0
PinwheelPicker

One to four scrolling wheels under a shared selection band — the date, time and option picker of a BottomSheet.

-- -- -- 3.0.2

Overview

PinwheelPicker picks one value per wheel by scrolling — a date, a time, a time window, a date of birth, or one of a short ordered set of options. Each wheel shows three rows: the selected value on the shared selection band and one neighbour above and below. The picker is a content block of a BottomSheet: the sheet owns the title, the confirm button and the dismiss.

  • 1
    Date
  • 2
    Time
  • 3
    Date + Time
  • 4
    Day + Month + Year
  • 5
    Time + Interval
  • 6
    PUDO
1
2
3
4
5
6

States

Every value is Selected, Unselected or Expired. State follows the value, not its position: Expired marks a value the user cannot pick — a past date or time — and it may sit anywhere in the list. Exactly one value per wheel is Selected. There is no Disabled picker and no Skeleton.

Unit states — live

Try it — scroll, click a value, or Tab in and use the arrow keys (Up = the next value)
1
2
3
  • 1
    Selected The wheel's current value, on the band — --text-and-icon-primary.
  • 2
    Unselected An available value off the band — --text-and-icon-secondary.
  • 3
    Expired An unavailable value shown for context — --text-and-icon-disabled. It never becomes Selected: a scroll that ends on it settles on the nearest available value, and arrow keys and assistive technology skip it. The sheet here opens at 1:40 AM, so the hours 12 (12 AM) and 1 are past. Expired also covers a value another wheel excludes — an end time not after the start.
  • Focused Figma Focused on PinWheel. Keyboard or accessibility focus on a wheel draws a var(--sp-s2) inside ring in --text-and-icon-primary around that wheel's part of the band, radius var(--sp-s96). Pointer and touch input do not show it — Tab into the live picker to see it.
  • Night Flag In Night, --text-and-icon-disabled is lighter than --text-and-icon-secondary, so Expired reads brighter than Unselected — the reverse of Day. The tokens stay as Figma binds them; colour as the only Expired cue is accepted (owner, 2026-11-12), and the Night pair is the DS-wide Disabled / Secondary item.

Anatomy

A row of up to four columns — wheels and optional separators — over one selection band. The Time + Interval preset uses all four: hour · until · hour · period.

Parts

1
2
3
4
5
  • 1
    Wheel A vertical scroller, var(--sp-s180) tall — three rows — and at least var(--sp-s48) wide. All wheels share the width equally. The focusable spinbutton; Figma PinWheel, axis Align.
  • 2
    Selection Band One band behind every wheel: --surface-on-white, radius var(--sp-s96) from the SP 150% scale. The full width of the component minus var(--sp-s8) a side, and as tall as the selected row. Figma SelectionBand.
  • 3
    Unit One value: at least var(--sp-s60) tall, var(--sp-s8) side padding. Figma PinWheelUnit, axis State.
  • 4
    Label Main Body, up to two lines, an ellipsis past that. Centred by default; a wheel can align its text to the left edge instead (Align=left) — see Layout.
  • 5
    Separator A static word on the band's row — --text-and-icon-primary, Main Body, var(--sp-s8) side padding, hugs its text. It does not scroll and takes no focus. Figma PinWheelSeparator, placed by swapping a wheel slot 02–04.

Layout

Each part of the picker, one at a time: its paddings, width and height. The values come from the rendered build at 100%.

PinwheelPicker

  • Paddings var(--sp-s8) — top and bottom.var(--sp-s24) — left and right.var(--sp-s0) — between columns: the wheels touch.
  • Size Width — the host's: 360, 393 and 440 in the presets. The picker has no width of its own; the wheels inside it stop at 393 — see Container.Height — 196: 8 + var(--sp-s180) + 8. It grows only when the selected row does.

Container

A 640-wide host: the wheels stop at 393 and centre, the band keeps the full width.

  • 1
    Wheels Container Holds the wheels and separators. Fills the root's content box up to 393 wide and centres in it; its minimum is one wheel, var(--sp-s48). 393 is the owner's value and the one number in the component that is not a token (--pinwheel-picker-container-max) — odd, so no SP step expresses it. Figma: the container frame.
  • 2
    The Band Is Not Capped The selection band belongs to the root, not the container: it stretches across the whole available width with var(--sp-s8) left and right — 624 here, past the 393 of wheels.

Selection Band

  • Paddings var(--sp-s8) — the inset from the component's left and right edges.No padding inside — the band is a plain surface behind the wheels.
  • Size Width — all the available width minus var(--sp-s8) a side. The band is not limited by the 393 wheels container — see Container.Height — the selected row: var(--sp-s60) while the value fits; it grows with a two-line value or a larger text scale. Figma draws a fixed 60.var(--sp-s96) @ SP 150% — the radius; larger than half the band at every scale, so the ends stay round.

Wheel

  • Paddings var(--sp-s0) — none: the units fill the wheel edge to edge.var(--sp-s60) — top and bottom inside the scroller, so the first and last value can reach the band. Scroll room, not a visual padding.
  • Size Minimum — var(--sp-s48).Maximum — no fixed value: the container's width, less the separators, shared equally by the wheels. 104 here: (360 − 24 − 24) / 3; at the 393 cap with three wheels, 131; with one wheel, the whole container.Height — var(--sp-s180): three rows.

Unit

  • Paddings var(--sp-s8) — left and right; the label starts here when the wheel is Align=left.var(--sp-s0) — top and bottom: the label centres in the row.
  • Size Width — the wheel's.Height — at least var(--sp-s60); a two-line value at a larger text scale grows it.

Separator

  • Paddings var(--sp-s8) — left and right.var(--sp-s0) — top and bottom: it sits on the band's row.
  • Size Width — hugs its word: the text plus 8 + 8.Height — one line of Main Body, var(--sp-s20).

Text Alignment · Text Scaling · RTL

Align=center — the default: text centred in each wheel
Align=left — text aligned to the left edge; long labels wrap from it
RTL — the columns mirror, start-aligned labels move to the right
  • Centred By Default The text inside a wheel is centred (Align=center) — numbers and short words sit under the middle of the band.
  • Left Alignment A wheel can align its text to the left edge instead (Align=left): each label starts at the unit's var(--sp-s8) side padding and a two-line value wraps from that edge. For long, word-like values (PUDO: "Domestic departure"). Set per wheel, so one picker can mix both.
  • Text Scaling Main Body scales in full mode. The var(--sp-s60) row minimum is on the 100% scale, so at 150% a two-line value outgrows it — the row and the band grow together.
  • RTL Opt-in; Figma RTL: off | on on the inner PinwheelPicker set. Under dir="rtl" the column order mirrors and left alignment becomes start alignment — the right edge. Digits keep their own direction; the order of day, month and year is the host's locale formatting.

Animation

Figma documents the motion in the "Behaviour & motion" note on the page. Scrolling and snapping are the platform's own; the component adds colour, corrections and the band.

  • 1
    Drag, Fling, Snap Platform scroll physics with inertia and the system overscroll (motion-rules.md § 0.4); at rest the nearest available value centres on the band — iOS UIPickerView, an Android snap helper, CSS scroll-snap-align: center. Expired values carry no snap point.
  • 2
    Colour Cross-Fade --pattern-color — a value entering the band takes Primary, the one leaving takes its own colour, 200 ms.
  • 3
    Corrections And Host Changes The platform's animated scroll — a wheel that rests on an Expired value, or whose value the host changes (Tomorrow → Today re-expires the past hours), scrolls to the nearest available value. Platforms time it to Position Medium (400 ms) where their scroll API takes a duration; it is not a translate pattern the component applies. On the web the browser's smooth scroll has its own duration and curve — a known difference.
  • 4
    The Band Takes The Row's Height No animation — when a two-line value lands on the band, the band takes its height at once. Height is a layout property and is not animated (motion-rules.md: width / height reflow).
  • 5
    One Change Per Settle The value changes when the wheel comes to rest, not for every row that passes the band. Haptic (Proposed): one selection tick per row crossing the band — iOS UISelectionFeedbackGenerator, Android CLOCK_TICK.
  • 6
    Reduced Motion The tokens zero themselves in motion.css; programmatic scrolls jump instead of glide. Finger scrolling is the user's own motion and stays as it is.
The sheet opens at 13:40 — hours before 14 are Expired today. Pick Tomorrow and they come back; pick Today again and the hour wheel moves off the past

Usage

  • 1
    Dates, Times, Short Ordered Lists Date, time, both, a time window or a date of birth inside a BottomSheet; one of a short ordered set of options where scanning beats reading a list — PUDO terminals and halls. Two steps are two sheets: a date sheet whose Next opens a time sheet.
  • 2
    The Sheet Owns The Rest The picker has no title and no action. The sheet's NavBar or SectionHeader names what is picked, its ButtonGroup confirms.
  • 3
    When Not To Use Options that all fit on screen — SegmentedControl, Chips or RadioButton. Long or searchable lists (countries, addresses) — a list of Cells. Free text or exact numbers — TextField. Mostly unavailable values — filter them out rather than showing a wheel of Expired rows.
✓ An ordered range in a sheet — past slots stay visible as Expired, for context
✕ Two options hidden in a wheel — they all fit on screen: SegmentedControl or RadioButton

In A BottomSheet

The picker always sits in a BottomSheet, between the sheet's header and its actions, full width — the sheet's own content padding is zero, the picker carries its var(--sp-s24) sides. The sheet chrome here is a stand-in: BottomSheet, NavBar, SectionHeader and ButtonGroup are not DS web components yet.

  • 1
    Header Above A NavBar (var(--sp-s48), centred Heading 4 title, close and optional back) for dates and times; a SectionHeader (Heading 2) when the title names a place, as in PUDO.
  • 2
    Actions Below A ButtonGroup: var(--sp-s8) padding, var(--sp-s4) between buttons, Button L at full width — the Primary confirm (Done, Next, Confirm pickup point) and, when needed, a Secondary next to it (Skip, Pickup directions).
  • 3
    Two Sheets For Date And Time Step by step: the date sheet's Next opens the time sheet; its back arrow returns, and the subtitle carries the picked date.
Step 1 — the date. NavBar title, one wheel, Next opens the time sheet
Step 2 — the time. Back returns to the date, the subtitle repeats it; Skip leaves the time open
PUDO — a SectionHeader names the place, two left-aligned wheels, one action
PUDO with a secondary action above the confirm

Accessibility

Each wheel is one adjustable element. Full spec: pinwheel-picker-a11y.md.

Semantics

Tab between wheels; Up / Down step, Page Up / Down jump five, Home / End go to the first / last
1
2
3
  • 1
    A Wheel Is A Spinbutton role="spinbutton": aria-label names what the wheel sets ("Period"), aria-valuetext is the selected label ("AM"). The group carries the sheet title. iOS: .adjustable, swipe up / down. Android: scroll forward / backward actions with the value in stateDescription. Units are aria-hidden — the wheel speaks for them.
  • 2
    The Separator Is Not An Element "until" is aria-hidden; its meaning moves into the neighbours' labels — "Start time", "End time". A wheel labelled only by its value ("2:00") does not say what was picked.
  • 3
    Expired Is Unreachable Arrow keys, iOS increment / decrement and Android scroll actions skip Expired values, so assistive technology never offers a value the user cannot pick. They still render, as context.
  • Implementation Traps A focused scroller scrolls by pixels on Up / Down and ignores the Expired skip — the key handler calls preventDefault() and moves by values. And scroll fires per pixel — the change fires once, when the wheel comes to rest, and only when the value differs.
  • Keyboard One tab stop per wheel — the scroller inside is tabindex="-1", or Chrome makes it a second, unnamed stop. Up / Down step to the next / previous available value (Up increases, per the APG and iOS increment), Page Up / Page Down jump five, Home / End go to the first / last available value. Modified keys (Alt, Ctrl, Cmd) pass through.
  • Focus Ring Figma Focused on PinWheel. :focus-visible on a wheel draws a var(--sp-s2) inside outline in --text-and-icon-primary at radius var(--sp-s96) around that wheel's part of the band; pointer focus does not show it.
  • Host Changes Are Announced When the host moves a wheel the user is not on (Today re-expires the past hours), focus stays where it is and nothing speaks — it is announced: a polite live region (web role="status"; Android accessibilityLiveRegion / Compose liveRegion = Polite on the wheel — announceForAccessibility is deprecated in Android 16), UIAccessibility.post(.announcement) on iOS ("Hour changed to 14"). The Animation demo does this.
  • Reduced Motion Under prefers-reduced-motion: reduce corrections and host-set values jump (behavior: "auto") instead of glide; iOS Reduce Motion and Android animations-off do the same. The value and its announcement are unchanged.
  • Edge States First / last value of a non-looping wheel: TalkBack drops the action in that direction, VoiceOver plays its boundary sound. A wheel with one available value keeps its actions but moves nowhere. Forced colours: Expired falls to GrayText and the band gets a Highlight border, so Expired never looks pickable. RTL mirrors the columns; reading order stays first wheel first.
  • Contrast Selected on the band ≈ 15.7:1 Day / 13.2:1 Night (--text-and-icon-primary on --surface-on-white); Unselected on the sheet ≈ 5.3:1 / 7.9:1 (--text-and-icon-secondary on --background-primary); Expired is exempt as inactive. The band itself is ≈ 1.1:1 on the sheet, so selection never depends on it: the selected value is also the middle row and the only one in Primary. Forced colours: Expired GrayText, the band a Highlight border, the focus ring CanvasText. Flag: in Night, Expired (≈ 9.8:1) reads brighter than Unselected (≈ 7.9:1) — a DS-wide token pair, raised at the token level.