One to four scrolling wheels under a shared selection band — the date, time and option picker of a BottomSheet.
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
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
-
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
Focusedon 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
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, axisAlign. -
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, axisState. -
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
containerframe. -
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
-
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 | onon the inner PinwheelPicker set. Underdir="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 — iOSUIPickerView, an Android snap helper, CSSscroll-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, AndroidCLOCK_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.
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.
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.
Accessibility
Each wheel is one adjustable element. Full spec: pinwheel-picker-a11y.md.
Semantics
-
1
A Wheel Is A Spinbutton
role="spinbutton":aria-labelnames what the wheel sets ("Period"),aria-valuetextis the selected label ("AM"). The group carries the sheet title. iOS:.adjustable, swipe up / down. Android: scroll forward / backward actions with the value instateDescription. Units arearia-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. Andscrollfires 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
Focusedon PinWheel.:focus-visibleon 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"; AndroidaccessibilityLiveRegion/ ComposeliveRegion = Politeon the wheel —announceForAccessibilityis deprecated in Android 16),UIAccessibility.post(.announcement)on iOS ("Hour changed to 14"). The Animation demo does this. -
Reduced Motion Under
prefers-reduced-motion: reducecorrections 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
GrayTextand the band gets aHighlightborder, 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 aHighlightborder, the focus ringCanvasText. Flag: in Night, Expired (≈ 9.8:1) reads brighter than Unselected (≈ 7.9:1) — a DS-wide token pair, raised at the token level.