# OptionWheel

> Organism — orn-ui

> ⚠️ Requires <UIProvider>: This component (like every orn-ui component) must render inside a <UIProvider> ancestor, or it throws at runtime. See https://orn-ui-docs.vercel.app/getting-started.md

> Runs on Expo SDK 54, 55, 56 and 57, and on bare React Native >=0.81 with react >=19.1. No native modules — works in Expo Go, no prebuild.

## Installation

Install just this component (copies the source into your project, no npm dependency)
```bash
npx orn-ui add option-wheel
```

Or install the whole package and import it
```tsx
pnpm add orn-ui
import { OptionWheel } from 'orn-ui/option-wheel';
```

Depends on: core (theme + icons), transition

## Variants

### default — five rows, the centre one is the pick

```tsx
<SizeWheel label="Size" options={SIZES} />
```

### disabled options — the wheel slides off them on its own

```tsx
<SizeWheel label="In stock" options={STOCK} />
```

### unit and visibleCount={3} — a compact wheel

```tsx
<MinuteWheel />
```

### perspective={false} — size and fade, no tilt

```tsx
<FlatWheel />
```

### disabled — the whole wheel is frozen

```tsx
<LockedWheel />
```

### inside a Card, named for screen readers

```tsx
<InCard />
```

### variant='spotlight' — no band, the sharp row is the pick

```tsx
<PortfolioWheel />
```

## Full demo source

```tsx
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

| 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. |
