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 baseline — aria-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';