Inertial Wheel List
moumenlab/inertial-wheel-listFreeComponent
An iOS picker drum built on native scroll-snap, with selection derived from scrollTop.
Live preview
live · rendered by the registry
Rendered by moumenlab on their own page — this is the component running, not a screenshot of it.Install it
npx shadcn@latest add https://lab.moumen.dev/r/inertial-wheel-list.jsonSource
1"use client";23import {4 useCallback,5 useEffect,6 useId,7 useLayoutEffect,8 useRef,9 useState,10 type CSSProperties,11 type KeyboardEvent,12} from "react";13import { motion, useReducedMotion, useScroll, useTransform, type MotionValue } from "motion/react";1415// Inertial wheel list - the iOS picker drum, rebuilt on one principle: the16// SCROLL POSITION IS THE STATE. Nothing is scroll-jacked and no library fakes17// the physics: the scroller is a plain overflow-y list, so the browser (and a18// thumb on a phone) owns momentum, and `scroll-snap-type: y mandatory` +19// `scroll-snap-align: center` land every fling on an item. The selection is20// DERIVED from scrollTop - round(scrollTop / itemHeight) - never stored beside21// it, so the two can't disagree.22//23// The drum look is paint, not layout: motion's `useScroll` tracks the scroller24// and every item derives `rotateX(±38° · t) scale(1.14 → 0.80)` and opacity from25// its distance to the viewport's centre via `useTransform` - motion values26// update outside the React render loop, GPU-composited, no per-frame setState.27// The edge fade is a `mask-image` gradient on the scroller, so items dissolve at28// the rim instead of clipping.29//30// Settling: `scrollend` fires when the snap lands, but not in every engine, so31// a 140ms quiet-timer fallback commits the same selection. Keyboard follows the32// listbox pattern - the scroller is the single tab stop, arrows/Home/End scroll33// to the neighbour (which updates selection because selection IS scroll) and34// aria-activedescendant tracks the centre item. Honours prefers-reduced-motion:35// transforms stay flat, programmatic scrolls jump.36//37// Geometry lives in three CSS custom properties on the root (--wheel-w/-h/-item)38// so the same wheel is fluid on a phone. Fully Tailwind; animation via motion/react.3940const WHEEL_VARS = {41 "--wheel-w": "14rem",42 "--wheel-h": "12.5rem",43 "--wheel-item": "2.5rem",44} as CSSProperties;4546export interface WheelState {47 value: string;48 index: number;49 count: number;50 settled: boolean;51}5253interface Metrics {54 itemH: number;55 centers: number[];56 half: number;57}5859// Fallback geometry (16px root): item 40px, viewport 200px - used until the60// first real measure lands.61const fallbackMetrics = (count: number): Metrics => ({62 itemH: 40,63 centers: Array.from({ length: count }, (_, i) => 80 + 40 * i + 20),64 half: 100,65});6667export default function WheelList({68 items,69 label = "Pick a value",70 initialIndex = 0,71 drum = true,72// … truncated
What it pulls in
npm packages
Other registry items
Files it writes
More from moumenlab
All 14 items| Component | Registry | Kind | Access | Installs | Command |
|---|---|---|---|---|---|
| Caret-Anchored Mention Popovercaret-mention-popover | moumenlab | Components | Free | 1 dep | |
| Command Palette with Argument Chipscommand-palette | moumenlab | Components | Free | 1 dep | |
| Drag-to-Reorder Listdrag-to-reorder-list | moumenlab | Components | Free | 1 dep | |
| File Upload Staging Areafile-upload-staging | moumenlab | Components | Free | 1 dep | |
| Hover-Expand Icon Striphover-expand-icon-strip | moumenlab | Components | Free | 1 dep | |
| Morphing Checkout Flowmorphing-checkout | moumenlab | Components | Free | 1 dep | |
| OTP Segmented Inputotp-segmented-input | moumenlab | Components | Free | 1 dep | |
| Schedule Builderschedule-builder | moumenlab | Components | Free | 1 dep |
