Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Picker

A momentum wheel picker with one or more columns.

A momentum wheel picker — the classic spinning drum (iOS UIPickerView / ion-picker). Flick a column to spin it with velocity, momentum and exponential deceleration; it snaps to the nearest row and rubber-bands at the ends. Tap a row to select it, scroll with a trackpad, or drive it programmatically. Compose one or more picker-columns for single- or multi-column pickers. The component is standalone and inline — embed it anywhere; hosting it in a Bottom Sheet is just composition.

Installation

npx touchcn add picker

Usage

<tcn-picker>
  <tcn-picker-column label="Hour" [options]="hours" [(value)]="hour" />
  <tcn-picker-column label="Minute" [options]="minutes" [(value)]="minute" />
</tcn-picker>
import { TcnPicker, TcnPickerColumn } from '@/components/ui/picker';

<TcnPicker>
  <TcnPickerColumn label="Hour" options={hours} value={hour} onValueChange={(v) => setHour(Number(v))} />
  <TcnPickerColumn label="Minute" options={minutes} value={minute} onValueChange={(v) => setMinute(Number(v))} />
</TcnPicker>
<script setup lang="ts">
import { TcnPicker, TcnPickerColumn } from '@/components/ui/picker';
</script>

<template>
  <TcnPicker>
    <TcnPickerColumn label="Hour" :options="hours" v-model="hour" />
    <TcnPickerColumn label="Minute" :options="minutes" v-model="minute" />
  </TcnPicker>
</template>

Columns are data-driven: pass options as strings/numbers or { label, value } objects, and bind the selected value. All physics — pointer tracking with velocity sampling, momentum, snap, rubber-band overscroll, tap-to-select, wheel/trackpad and keyboard — lives per column in the engine (TcnPickerColumnDirective / usePickerColumn); the copied components own only markup. The Datepicker’s iOS datetime time drum is built on this same engine.

Accessibility

Each column is a focusable role="listbox" of role="option" rows, with the selected row carrying aria-selected. Keyboard support (when a column is focused): / move by one, PageUp/PageDown by three, Home/End jump to the ends. This is an honest baselinearia-activedescendant and richer screen-reader narration are not yet implemented, so verify against your own SR requirements.

Props

Picker Column

Prop Type Default Description
options Array<string | number | { label, value }> [] The rows to spin through.
value / onValueChange string | number / (value) => void The selected value (two-way on Angular).
label string Accessible label for the column.
itemHeight number 34 Row height in px; must match the theme.
disabled boolean false Disable the gesture (programmatic value still snaps).
Source · Angular
export * from './tcn-picker';
import { Component, computed, effect, inject, input, model, untracked } from '@angular/core';
import { TcnPickerColumnDirective } from '@touchcn/angular/picker';

/** A single row in a picker column. A bare string/number is shorthand for `{ label, value }`. */
export interface TcnPickerOption {
  label: string;
  value: string | number;
}

export type TcnPickerColumnValue = string | number | null;

function normalizeOptions(options: ReadonlyArray<string | number | TcnPickerOption>): TcnPickerOption[] {
  return options.map((option) =>
    typeof option === 'object' ? option : { label: String(option), value: option },
  );
}

/**
 * Momentum wheel picker — a classic multi-column drum. The container renders the
 * center selection band (hairline rules + subtle fill) and a fade mask toward
 * the edges; project one or more `tcn-picker-column`s inside. All physics —
 * pointer tracking, momentum, snap, rubber-band overscroll, tap-to-select,
 * wheel/trackpad and keyboard — lives per column in the engine
 * `TcnPickerColumnDirective`; these components own only markup.
 *
 * Presentation is standalone/inline and embeddable anywhere (a bottom sheet host
 * is just composition). The iOS look is the drum with dimmed edges via the mask;
 * MD3 has no wheel idiom, so the same wheel renders with MD tokens (Material
 * apps usually prefer Select/time inputs — the wheel exists for parity and
 * iOS-style flows).
 */
@Component({
  selector: 'tcn-picker',
  template: `
    <div class="tcn-picker-band" aria-hidden="true"></div>
    <div class="tcn-picker-columns"><ng-content /></div>
  `,
  host: { class: 'tcn-picker block' },
})
export class TcnPicker {}

/**
 * One data-driven column of a `tcn-picker`. Bind `options` (strings or
 * `{ label, value }`) and two-way `value`; the engine directive on the host
 * drives selection. Fixed row height must match the theme (`itemHeight`, 34px).
 */
@Component({
  selector: 'tcn-picker-column',
  hostDirectives: [{ directive: TcnPickerColumnDirective, inputs: ['itemHeight', 'disabled'] }],
  template: `
    <div class="tcn-picker-options">
      @for (option of normalized(); track option.value; let i = $index) {
        <div
          class="tcn-picker-option"
          role="option"
          [attr.data-index]="i"
          [attr.aria-selected]="i === engine.selectedIndex()"
          [attr.data-selected]="i === engine.selectedIndex() || null"
        >
          {{ option.label }}
        </div>
      }
    </div>
  `,
  host: {
    class: 'tcn-picker-column',
    role: 'listbox',
    tabindex: '0',
    '[attr.aria-label]': 'label()',
  },
})
export class TcnPickerColumn {
  readonly options = input<ReadonlyArray<string | number | TcnPickerOption>>([]);
  readonly value = model<TcnPickerColumnValue>(null);
  readonly label = input('');

  protected readonly engine = inject(TcnPickerColumnDirective);
  protected readonly normalized = computed(() => normalizeOptions(this.options()));

  constructor() {
    // value -> selected index (reacts to external/programmatic value changes).
    // The comparison and write are untracked so an engine-driven index change
    // (drag/tap/wheel/keyboard) does not re-fire this effect and revert it.
    effect(() => {
      const options = this.normalized();
      const value = this.value();
      const index = options.findIndex((option) => option.value === value);
      untracked(() => {
        if (index >= 0 && index !== this.engine.selectedIndex()) {
          this.engine.selectedIndex.set(index);
        }
      });
    });
    // selected index -> value (drag/tap/wheel/keyboard settle). Reacts only to
    // the engine's selected index; reads/writes of `value` stay untracked.
    effect(() => {
      const index = this.engine.selectedIndex();
      untracked(() => {
        const option = this.normalized()[index];
        if (option && option.value !== this.value()) {
          this.value.set(option.value);
        }
      });
    });
  }
}
Source · React
export * from './tcn-picker';
import type { ReactNode } from 'react';
import { cn } from '@touchcn/core';
import { usePickerColumn } from '@touchcn/react';

/** A single row in a picker column. A bare string/number is shorthand for `{ label, value }`. */
export interface TcnPickerOption {
  label: string;
  value: string | number;
}

export type TcnPickerColumnValue = string | number | null;

function normalizeOptions(options: ReadonlyArray<string | number | TcnPickerOption>): TcnPickerOption[] {
  return options.map((option) => (typeof option === 'object' ? option : { label: String(option), value: option }));
}

export interface TcnPickerProps {
  children?: ReactNode;
  className?: string;
}

/**
 * Momentum wheel picker — a classic multi-column drum, mirroring the Angular
 * component. The container renders the center selection band and an edge fade
 * mask; place one or more `TcnPickerColumn`s inside. All physics — pointer
 * tracking, momentum, snap, rubber-band overscroll, tap-to-select, wheel and
 * keyboard — lives per column in the engine `usePickerColumn` hook. Emits the
 * same `tcn-*` classes and `data-*` attributes as the Angular component.
 */
export function TcnPicker({ children, className }: TcnPickerProps) {
  return (
    <div className={cn('tcn-picker block', className)}>
      <div className="tcn-picker-band" aria-hidden="true" />
      <div className="tcn-picker-columns">{children}</div>
    </div>
  );
}

export interface TcnPickerColumnProps {
  /** Options as strings/numbers or `{ label, value }` objects. */
  options: ReadonlyArray<string | number | TcnPickerOption>;
  /** The selected value (controlled). */
  value: TcnPickerColumnValue;
  /** Called when the column settles on a new value. */
  onValueChange: (value: string | number) => void;
  /** Accessible label for the column. */
  label?: string;
  /** Fixed row height in pixels; must match the theme (34px). */
  itemHeight?: number;
  /** Disables the gesture (still snaps to a programmatic `value`). */
  disabled?: boolean;
  className?: string;
}

/**
 * One data-driven column of a `TcnPicker`. Pass `options` and a controlled
 * `value` + `onValueChange`; the engine hook drives selection with full wheel
 * physics.
 */
export function TcnPickerColumn({
  options,
  value,
  onValueChange,
  label,
  itemHeight,
  disabled,
  className,
}: TcnPickerColumnProps) {
  const normalized = normalizeOptions(options);
  const selectedIndex = Math.max(
    0,
    normalized.findIndex((option) => option.value === value),
  );
  const { columnRef } = usePickerColumn({
    selectedIndex,
    itemHeight,
    disabled,
    onSelectedIndexChange: (index) => {
      const option = normalized[index];
      if (option) {
        onValueChange(option.value);
      }
    },
  });

  return (
    <div ref={columnRef} role="listbox" aria-label={label} tabIndex={0} className={cn('tcn-picker-column', className)}>
      <div className="tcn-picker-options">
        {normalized.map((option, index) => (
          <div
            key={option.value}
            className="tcn-picker-option"
            role="option"
            data-index={index}
            aria-selected={index === selectedIndex}
            data-selected={index === selectedIndex || undefined}
          >
            {option.label}
          </div>
        ))}
      </div>
    </div>
  );
}
Source · Vue
<script setup lang="ts">
/**
 * Momentum wheel picker — a classic multi-column drum, mirroring the Angular /
 * React component. The container renders the center selection band and an edge
 * fade mask; place one or more `TcnPickerColumn`s inside. All physics — pointer
 * tracking, momentum, snap, rubber-band overscroll, tap-to-select, wheel and
 * keyboard — lives per column in the engine `usePickerColumn` composable. Emits
 * the same `tcn-*` classes and `data-*` attributes across frameworks.
 */
</script>

<template>
  <div class="tcn-picker block">
    <div class="tcn-picker-band" aria-hidden="true" />
    <div class="tcn-picker-columns"><slot /></div>
  </div>
</template>
<script lang="ts">
/** A single row in a picker column. A bare string/number is shorthand for `{ label, value }`. */
export interface TcnPickerOption {
  label: string;
  value: string | number;
}

export type TcnPickerColumnValue = string | number | null;

function normalizeOptions(
  options: ReadonlyArray<string | number | TcnPickerOption>,
): TcnPickerOption[] {
  return options.map((option) =>
    typeof option === 'object' ? option : { label: String(option), value: option },
  );
}
</script>

<script setup lang="ts">
import { computed } from 'vue';
import { usePickerColumn } from '@touchcn/vue';

/**
 * One data-driven column of a `TcnPicker`. Pass `options` and bind a controlled
 * value with `v-model`; the engine composable drives selection with full wheel
 * physics.
 */
const props = defineProps<{
  /** Options as strings/numbers or `{ label, value }` objects. */
  options: ReadonlyArray<string | number | TcnPickerOption>;
  /** Accessible label for the column. */
  label?: string;
  /** Fixed row height in pixels; must match the theme (34px). */
  itemHeight?: number;
  /** Disables the gesture (still snaps to a programmatic value). */
  disabled?: boolean;
}>();

const model = defineModel<TcnPickerColumnValue>({ required: true });

const normalized = computed(() => normalizeOptions(props.options));
const selectedIndex = computed(() =>
  Math.max(
    0,
    normalized.value.findIndex((option) => option.value === model.value),
  ),
);

const { setColumn } = usePickerColumn({
  selectedIndex: () => selectedIndex.value,
  itemHeight: props.itemHeight,
  disabled: () => props.disabled ?? false,
  onSelectedIndexChange: (index) => {
    const option = normalized.value[index];
    if (option) {
      model.value = option.value;
    }
  },
});
</script>

<template>
  <div :ref="setColumn" role="listbox" :aria-label="label" :tabindex="0" class="tcn-picker-column">
    <div class="tcn-picker-options">
      <div
        v-for="(option, index) in normalized"
        :key="option.value"
        class="tcn-picker-option"
        role="option"
        :data-index="index"
        :aria-selected="index === selectedIndex"
        :data-selected="index === selectedIndex || undefined"
      >
        {{ option.label }}
      </div>
    </div>
  </div>
</template>
export { default as TcnPicker } from './TcnPicker.vue';
export { default as TcnPickerColumn } from './TcnPickerColumn.vue';
export type { TcnPickerOption, TcnPickerColumnValue } from './TcnPickerColumn.vue';

Last updated on July 24, 2026

Was this page helpful?