orn-ui
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-wheel

Or install the whole package and import it

pnpm add orn-ui
import { OptionWheel } from 'orn-ui/option-wheel';
Depends on:core (theme + icons)transition

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.

NameTypeDefaultDescription
optionsOptionWheelOption<T>[]The range, in order. Each option takes a label, its value, and can be disabled.
selectedValuestring | numberThe option in the centre window.
onSelect(value: T) => voidReports the option the wheel settled on.
label?stringTitle above the wheel.
accessibilityLabel?stringRequired when there's no `label`: the wheel as a whole needs a name.
visibleCount?number5Rows on screen. Forced to the next odd number so one of them is the centre.
itemHeight?number44Height of each row.
unit?stringFixed text to the right of the centre row ('kg', 'min').
perspective?booleantrueTilts 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?stringtheme.colors.textColour 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?numberRadius, 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'leftWhich side the wheel's centre is on.
disabled?booleanfalseStops the wheel from being turned.
style?StyleProp<ViewStyle>Style for the container.