Organism
OptionWheel
Requires <UIProvider> — Getting Started →
Installation
Install just this component (copies the source into your project, no npm dependency)
npx orn-ui add option-wheelOr install the whole package and import it
pnpm add orn-ui
import { OptionWheel } from 'orn-ui/option-wheel';Usage
- when to use — A long, ordered range where the neighbours matter: minutes, a weight, a size, a year. The option in the centre window is the one picked — no confirm step. For a short list of unrelated options use Select, and for a handful of them SegmentedControl.
- options / selectedValue / onSelect — Controlled and generic, the same pattern as Select: the value keeps its type, so a wheel of numbers reports a number.
- visibleCount / itemHeight — How much of the range is on screen. visibleCount is forced to the next odd number so one row is the centre, and itemHeight is what makes the wheel fit a card or fill a sheet.
- variant — 'window' marks the pick with a band behind the centre row — the iOS reading. 'spotlight' drops the band and fades the far rows instead, which suits a dark or image background where a band would look like a seam.
- curveRadius / curveFrom — Bows the rows onto a wheel whose centre is off to the side, so they bend inwards as they get further from the middle. It is the flourish, not the mechanism — leave it out and the wheel still works.
- accessibilityLabel — Required when there is no label: the wheel as a whole needs a name, because its rows are numbers and a screen reader reading "15, 20, 25" says nothing about what they are.
- textColor — The wheel does not paint its own background — whoever composes it does. This is how the text stays legible on top of whatever that turns out to be.
Demo clip not recorded yet — see MEDIA.md
Variants
default — five rows, the centre one is the pick
<SizeWheel label="Size" options={SIZES} />disabled options — the wheel slides off them on its own
<SizeWheel label="In stock" options={STOCK} />unit and visibleCount={3} — a compact wheel
<MinuteWheel />perspective={false} — size and fade, no tilt
<FlatWheel />disabled — the whole wheel is frozen
<LockedWheel />inside a Card, named for screen readers
<InCard />variant='spotlight' — no band, the sharp row is the pick
<PortfolioWheel />Full demo source
function PortfolioWheel() {
const [bucket, setBucket] = useState('stocks');
const colors = useColors();
const current = PORTFOLIO.find((option) => option.value === bucket);
return (
<View style={{ gap: 8 }}>
<View
style={{
backgroundColor: colors.surface,
borderRadius: 24,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
paddingVertical: 12,
}}
>
<OptionWheel
accessibilityLabel="Portfolio bucket"
options={PORTFOLIO}
selectedValue={bucket}
onSelect={setBucket}
variant="spotlight"
textColor={colors.text}
curveRadius={CURVE}
perspective={false}
visibleCount={ROWS}
itemHeight={ROW}
/>
</View>
<Body>Allocating to: {current?.label ?? '—'}</Body>
</View>
);
}
function SizeWheel({ label, options }: { label: string; options: OptionWheelOption<string>[] }) {
const [size, setSize] = useState('m');
const current = options.find((option) => option.value === size);
return (
<View style={{ gap: 8 }}>
<OptionWheel label={label} options={options} selectedValue={size} onSelect={setSize} />
<Body>Picked: {current?.label ?? '—'}</Body>
</View>
);
}
function MinuteWheel() {
const [minutes, setMinutes] = useState(15);
return (
<View style={{ gap: 8 }}>
<OptionWheel
label="Remind me in"
options={MINUTES}
selectedValue={minutes}
onSelect={setMinutes}
unit="min"
visibleCount={3}
/>
<Body>Reminder in {minutes} minutes</Body>
</View>
);
}
function FlatWheel() {
const [size, setSize] = useState('l');
const current = SIZES.find((option) => option.value === size);
return (
<View style={{ gap: 8 }}>
<OptionWheel
label="No tilt"
options={SIZES}
selectedValue={size}
onSelect={setSize}
perspective={false}
/>
<Body>Flat pick: {current?.label ?? '—'}</Body>
</View>
);
}
function LockedWheel() {
const [size, setSize] = useState('m');
const current = SIZES.find((option) => option.value === size);
return (
<View style={{ gap: 8 }}>
<OptionWheel
label="Locked"
options={SIZES}
selectedValue={size}
onSelect={setSize}
disabled
/>
<Body>Locked on: {current?.label ?? '—'}</Body>
</View>
);
}
function InCard() {
const [size, setSize] = useState('s');
const current = SIZES.find((option) => option.value === size);
return (
<Card>
<Subtitle>Bag size</Subtitle>
<Body style={{ marginTop: 4, marginBottom: 12 }}>Ships free over 2 kg</Body>
<OptionWheel
accessibilityLabel="Bag size"
options={SIZES}
selectedValue={size}
onSelect={setSize}
visibleCount={3}
/>
<Body style={{ marginTop: 8 }}>Bag: {current?.label ?? '—'}</Body>
</Card>
);
}
export function OptionWheelDemo() {
const variants: VariantDef[] = [
{
label: 'default — five rows, the centre one is the pick',
content: <SizeWheel label="Size" options={SIZES} />,
},
{
label: 'disabled options — the wheel slides off them on its own',
content: <SizeWheel label="In stock" options={STOCK} />,
},
{
label: 'unit and visibleCount={3} — a compact wheel',
content: <MinuteWheel />,
},
{
label: 'perspective={false} — size and fade, no tilt',
content: <FlatWheel />,
},
{
label: 'disabled — the whole wheel is frozen',
content: <LockedWheel />,
},
{
label: 'inside a Card, named for screen readers',
content: <InCard />,
},
{
label: "variant='spotlight' — no band, the sharp row is the pick",
content: <PortfolioWheel />,
},
];
return <VariantList variants={variants} />;Props
Also accepts testID, forwarded to the root node. It exists for end-to-end tests (Maestro drives the app by the accessibility tree) and has no effect on how the component looks or behaves.
| Name | Type | Default | Description |
|---|---|---|---|
options | OptionWheelOption<T>[] | — | The range, in order. Each option takes a label, its value, and can be disabled. |
selectedValue | string | number | — | The option in the centre window. |
onSelect | (value: T) => void | — | Reports the option the wheel settled on. |
label? | string | — | Title above the wheel. |
accessibilityLabel? | string | — | Required when there's no `label`: the wheel as a whole needs a name. |
visibleCount? | number | 5 | Rows on screen. Forced to the next odd number so one of them is the centre. |
itemHeight? | number | 44 | Height of each row. |
unit? | string | — | Fixed text to the right of the centre row ('kg', 'min'). |
perspective? | boolean | true | Tilts the far rows to suggest a cylinder. |
variant? | 'window' | 'spotlight' | window | 'window' marks the pick with a band behind the centre row. 'spotlight' drops the band and lets the far rows fall away and blur instead, which needs a quiet surface under it to read. |
textColor? | string | theme.colors.text | Colour of the option labels. The surface the wheel sits on is whoever composes it to paint — this is how the text keeps contrast against it. |
curveRadius? | number | — | Radius, in pixels, of the wheel the rows sit on. Its centre is off to the side, so the rows bow inwards as they get further from the middle and tilt with the tangent — the wheel reads as a rim seen edge-on instead of a flat list. Smaller radius, rounder arc. Undefined leaves the rows flat. |
curveFrom? | 'left' | 'right' | left | Which side the wheel's centre is on. |
disabled? | boolean | false | Stops the wheel from being turned. |
style? | StyleProp<ViewStyle> | — | Style for the container. |