Wheel List Selection menu
- POKÉDEX
- POKÉMON
- ITEM
- TRAINER
- SAVE
- OPTION
- EXIT
Selected ITEM
1import Emojis from "@/components/icons/pxl/dotto-emoji";2import { WheelList, WheelOption } from "@/components/ui/pxl/wheel-list";3
4export default function WheelListPreview() {5 return (6 <div className="h-48 w-full max-w-md">7 <WheelList8 defaultValue="ITEM"9 onChange={(evt) =>10 console.log(evt.settled ? `selected ${evt.value}` : "coasting")11 }12 onSelect={(value) => console.log(value)}13 >14 <WheelOption key="POKÉDEX" value="POKÉDEX">15 <Emojis.Robot shapeRendering="crisp-edges" className="size-4 mr-1" />16 POKÉDEX17 </WheelOption>18 <WheelOption key="POKÉMON" value="POKÉMON">19 <Emojis.GrinningCat shapeRendering="crisp-edges" className="size-4 mr-1" />20 POKÉMON21 </WheelOption>22 <WheelOption key="ITEM" value="ITEM">23 <Emojis.PileOfPoo shapeRendering="crisp-edges" className="size-4 mr-1" />24 ITEM25 </WheelOption>26 <WheelOption key="TRAINER" value="TRAINER">27 <Emojis.CowboyHatFace shapeRendering="crisp-edges" className="size-4 mr-1" />28 TRAINER29 </WheelOption>30 <WheelOption key="SAVE" value="SAVE">31 <Emojis.ThoughtBalloon shapeRendering="crisp-edges" className="size-4 mr-1" />32 SAVE33 </WheelOption>34 <WheelOption key="OPTION" value="OPTION">35 <Emojis.AngerSymbol shapeRendering="crisp-edges" className="size-4 mr-1" />36 OPTION37 </WheelOption>38 <WheelOption key="EXIT" value="EXIT">39 <Emojis.DashingAway shapeRendering="crisp-edges" className="size-4 mr-1" />40 EXIT41 </WheelOption>42 </WheelList>43 </div>44 );45}Installation
Section titled “Installation”pnpm dlx shadcn@latest add pxl-ui/registry/wheel-list
Install the following dependencies:
pnpm add motion
Copy and paste the following code into your project.
1"use client";2
3import { cva } from "class-variance-authority";4import {5 type MotionValue,6 motion,7 useReducedMotion,8 useScroll,9 useTransform,10} from "motion/react";11import {12 Children,13 type ComponentProps,14 type CSSProperties,15 isValidElement,16 type KeyboardEvent,17 type PropsWithChildren,18 type ReactNode,19 useCallback,20 useEffect,21 useId,22 useLayoutEffect,23 useMemo,24 useRef,25 useState,26} from "react";27
28import { cn } from "@/lib/utils";29
30// Inertial wheel list - the iOS picker drum, rebuilt on one principle: the31// SCROLL POSITION IS THE STATE. Nothing is scroll-jacked and no library fakes32// the physics: the scroller is a plain overflow-y list, so the browser (and a33// thumb on a phone) owns momentum, and `scroll-snap-type: y mandatory` +34// `scroll-snap-align: center` land every fling on an item. The selection is35// DERIVED from scrollTop - round(scrollTop / itemHeight) - never stored beside36// it, so the two can't disagree.37//38// Options are declared as children, `<WheelList.Option value="...">`, the same39// pattern as a native <select>. WheelList doesn't render those elements - it40// reads their `value`/`children` props out of the React tree once (memoized on41// the `children` prop) and drives the drum from that derived array. Content per42// option can be any ReactNode, not just a string.43//44// The drum look is paint, not layout: motion's `useScroll` tracks the scroller45// and every item derives `rotateX(±38° · t) scale(1.14 → 0.80)` and opacity from46// its distance to the viewport's centre via `useTransform` - motion values47// update outside the React render loop, GPU-composited, no per-frame setState.48// The edge fade is a `mask-image` gradient on the scroller, so items dissolve at49// the rim instead of clipping.50//51// Settling: `scrollend` fires when the snap lands, but not in every engine, so52// a 140ms quiet-timer fallback commits the same selection. Keyboard follows the53// listbox pattern - the scroller is the single tab stop, arrows/Home/End scroll54// to the neighbour (which updates selection because selection IS scroll) and55// aria-activedescendant tracks the centre item. Honours prefers-reduced-motion:56// transforms stay flat, programmatic scrolls jump.57//58// Geometry is measured, not prop-driven. The root wrapper fills whatever box59// its parent gives it (`w-full h-full`, with a min-height fallback) and is60// tracked with a ResizeObserver. Row height (`--wheel-item`) is that measured61// height divided by `visibleCount`, optionally clamped between62// `minItemHeight`/`maxItemHeight`.63//64// IMPORTANT CSS gotcha this file works around: vertical padding expressed as65// a percentage (`padding-top/bottom: N%`) always resolves against the66// containing block's WIDTH, never its height - even though it looks like a67// vertical value. That's why the list's centring padding uses an explicit68// pixel custom property (`--wheel-h`, set from the measured box height) and69// never `100%`. The whole "scrollTop = index * itemH exactly centers item70// `index`" identity depends on padding-top being exactly71// `(--wheel-h - itemH) / 2` in real pixels; feed it a width-relative72// percentage instead and `scrollToIndex` silently drifts from where the73// snap points actually are, since `metrics.centers` (measured from the real74// DOM) and `index * itemH` (what scrollToIndex computes) stop agreeing.75
76export type WheelEvent = {77 value: string;78 index: number;79 count: number;80 settled: boolean;81};82
83// Marker component. WheelList never mounts this - it only reads its props off84// the React element tree. Rendering it directly is a no-op by design.85function WheelOption(86 _props: { className?: string; value: string } & PropsWithChildren,87) {88 return null;89}90WheelOption.displayName = "WheelList.Option";91
92type ResolvedOption = {93 value: string;94 className?: string;95 content: ReactNode;96};97
98type Metrics = {99 itemH: number;100 centers: number[];101 half: number;102};103
104// Derives centres/half from the *same* box height + item height used for105// `--wheel-item` / `--wheel-h`, mirroring the real padding-centred layout.106// Used only until the real DOM measure lands, so the very first paint (and107// the initial scrollTop) never disagrees with the values that arrive a108// frame later.109function fallbackMetrics(count: number, boxH: number, itemH: number): Metrics {110 const padding = Math.max(0, (boxH - itemH) / 2);111 return {112 itemH,113 centers: Array.from(114 { length: count },115 (_, i) => padding + itemH * i + itemH / 2,116 ),117 half: boxH / 2,118 };119}120
121// Pulls the `<WheelList.Option>` children out of the React tree into a plain122// array. Anything that isn't a WheelOption is ignored (with a dev warning) so123// stray whitespace/fragments from JSX don't break indexing.124function resolveChildren(children: ReactNode): {125 lens: ReactNode,126 options: ResolvedOption[]127} {128 const options: ResolvedOption[] = [];129 let lens: ReactNode = (<WheelLens />);130 for (const child of Children.toArray(children)) {131 if (!isValidElement<ComponentProps<typeof WheelOption>>(child)) {132 if (process.env.NODE_ENV !== "production") {133 console.warn(134 "WheelList: ignoring child that isn't a <WheelList.Option>.",135 child,136 );137 }138 continue;139 }140
141 if ((child.type as any).displayName === "WheelList.Option") {142 options.push({143 className: child.props.className,144 value: child.props.value,145 content: child.props.children,146 });147 } else {148 if ((child.type as any).displayName === "WheelList.Lens") {149 lens = child;150 }151 }152 }153 return { lens, options };154}155
156const optionVariants = cva("", {157 variants: {158 align: {159 start: "justify-start",160 center: "justify-center",161 end: "justify-end",162 }163 },164 defaultVariants: {165 align: "center",166 },167});168
169// One drum row. `t` is the item's signed distance from the viewport centre in170// half-viewport units - the centre item is biggest (scale 1.14) and the rim171// dissolves, by continuous function rather than a styled selected class. All172// four styles are motion values derived from the scroll: no React re-render,173// no layout read, GPU-composited.174function DrumOption({175 align,176 id,177 children,178 className,179 selected,180 scrollY,181 center,182 half,183 drum,184 flat,185 onClick,186}: {187 align?: "start" | "center" | "end";188 id: string;189 children: ReactNode;190 className?: string;191 selected: boolean;192 scrollY: MotionValue<number>;193 center: number;194 half: number;195 drum: boolean;196 flat: boolean;197 onClick: () => void;198}) {199 const t = useTransform(scrollY, (v) =>200 Math.max(-1, Math.min(1, (center - (v + half)) / half)),201 );202 const rotateX = useTransform(t, (tv) => (flat || !drum ? 0 : -38 * tv));203 const scale = useTransform(t, (tv) =>204 flat || align !== "center" ? 1 : 1.14 - 0.34 * Math.abs(tv),205 );206 const translateX = useTransform(t, (tv) => {207 if (flat || align === "center") {208 return 0;209 }210
211 const val = 6 - 2 * Math.abs(tv);212
213 if (align === "end") {214 return val * -1;215 }216
217 return val;218 });219 const opacity = useTransform(t, (tv) => (flat ? 1 : 1 - 0.55 * Math.abs(tv)));220
221 return (222 <motion.li223 id={id}224 role="option"225 aria-selected={selected}226 className={cn(227 "h-(--wheel-item) flex items-center gap-2.5 snap-center tabular-nums cursor-pointer select-none text-xs aria-selected:animate-pulse",228 optionVariants({229 align,230 }),231 className,232 )}233 style={{ rotateX, scale, translateX, opacity }}234 onClick={onClick}235 >236 {children}237 </motion.li>238 );239}240
241function WheelLens({ className, ...props }: ComponentProps<"span">) {242 return (243 <span244 data-slot="selection-lens"245 className={cn(246 "absolute left-0 right-0 top-1/2 h-(--wheel-item) -translate-y-1/2 pixel-border pixel-size-md bg-muted pointer-events-none",247 className248 )}249 aria-hidden="true"250 {...props}251 />252 )253}254WheelLens.displayName = "WheelList.Lens";255
256function WheelList({257 align = "center",258 children,259 label = "Pick a value",260 defaultValue = undefined,261 drum = true,262 onChange,263 onSelect,264 visibleCount = 5,265 minItemHeight,266 maxItemHeight,267}: {268 align?: "start" | "center" | "end";269 children: ReactNode;270 label?: string;271 defaultValue?: string;272 drum?: boolean;273 onChange?: (state: WheelEvent) => void;274 onSelect?: (value: string) => void;275 /**276 * How many rows should be visible in the drum at once. Row height277 * (`--wheel-item`) is derived from the measured container height divided278 * by this number, so keep it odd if you want a single row centred exactly279 * on the lens.280 */281 visibleCount?: number;282 /** Floor for the derived row height, in px. No floor if omitted. */283 minItemHeight?: number;284 /** Ceiling for the derived row height, in px. No ceiling if omitted. */285 maxItemHeight?: number;286}) {287 const containerRef = useRef<HTMLDivElement>(null);288 const scrollerRef = useRef<HTMLDivElement>(null);289 const settleTimer = useRef(0);290 const didInitialScrollRef = useRef(false);291 const idBase = useId();292 const reduced = useReducedMotion();293
294 const { lens, options } = useMemo(() => resolveChildren(children), [children]);295 const defaultIndex = defaultValue296 ? options.findIndex((opt) => opt.value === defaultValue)297 : 0;298
299 // Container box, read via ResizeObserver - this is what replaces the old300 // width/height/itemHeight props.301 const [box, setBox] = useState<{ w: number; h: number }>({ w: 0, h: 0 });302 const fallbackItemH = 40;303 const fallbackBoxH = fallbackItemH * visibleCount;304 const boxH = box.h > 0 ? box.h : fallbackBoxH;305 const rawItemH = box.h > 0 ? box.h / visibleCount : fallbackItemH;306 const derivedItemH = Math.min(307 maxItemHeight ?? Number.POSITIVE_INFINITY,308 Math.max(minItemHeight ?? 0, rawItemH),309 );310
311 const [metrics, setMetrics] = useState<Metrics>(() =>312 fallbackMetrics(options.length, boxH, derivedItemH),313 );314 const [index, setIndex] = useState<number>(defaultIndex);315 const [settled, setSettled] = useState(true);316 const indexRef = useRef(defaultIndex);317 const metricsRef = useRef(metrics);318 metricsRef.current = metrics;319 const optionsRef = useRef(options);320 optionsRef.current = options;321
322 const { scrollY } = useScroll({ container: scrollerRef });323
324 const clampIndex = useCallback(325 (i: number) => Math.min(Math.max(i, 0), optionsRef.current.length - 1),326 [],327 );328
329 function handleScroll() {330 // Selection derives from the scroll on every frame; it commits when the331 // snap lands (scrollend where the engine has it, the quiet-timer elsewhere).332 const scroller = scrollerRef.current;333 if (scroller) {334 const next = clampIndex(335 Math.round(scroller.scrollTop / metricsRef.current.itemH),336 );337 if (next !== indexRef.current) {338 indexRef.current = next;339 setIndex(next);340 }341 }342 setSettled(false);343 window.clearTimeout(settleTimer.current);344 settleTimer.current = window.setTimeout(() => setSettled(true), 140);345 }346
347 function handleScrollEnd() {348 window.clearTimeout(settleTimer.current);349 setSettled(true);350 }351
352 // Scrolls to `i` using the real, currently-measured item height. This353 // identity (`i * itemH` = scrollTop that centers item i) only holds354 // because the list's padding-top is exactly `(boxH - itemH) / 2` in real355 // pixels - see --wheel-h below.356 function scrollToIndex(i: number, smooth = true) {357 const scroller = scrollerRef.current;358 if (!scroller) return;359 scroller.scrollTo({360 top: clampIndex(i) * metricsRef.current.itemH,361 behavior: smooth && !reduced ? "smooth" : "auto",362 });363 }364
365 function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {366 if (event.key === "Enter") {367 onSelect?.(options[index].value);368 return;369 }370
371 const steps: Record<string, number> = {372 ArrowUp: -1,373 ArrowDown: 1,374 PageUp: -5,375 PageDown: 5,376 };377 let target: number;378 if (event.key in steps) target = indexRef.current + steps[event.key];379 else if (event.key === "Home") target = 0;380 else if (event.key === "End") target = options.length - 1;381 else return;382 event.preventDefault();383 scrollToIndex(target);384 }385
386 function handleClick(idx: number) {387 if (index === idx) {388 onSelect?.(options[idx].value);389 } else {390 scrollToIndex(idx);391 }392 }393
394 // Measure the wrapper against its parent's box - this is what geometry now395 // derives from instead of width/height/itemHeight props.396 useLayoutEffect(() => {397 const container = containerRef.current;398 if (!container) return undefined;399 const measureBox = () => {400 const rect = container.getBoundingClientRect();401 setBox((prev) =>402 prev.w === rect.width && prev.h === rect.height403 ? prev404 : { w: rect.width, h: rect.height },405 );406 };407 measureBox();408 if (typeof ResizeObserver === "undefined") return undefined;409 const boxObserver = new ResizeObserver(measureBox);410 boxObserver.observe(container);411 return () => boxObserver.disconnect();412 }, []);413
414 // Measure real row metrics (and, the very first time this succeeds, land on415 // defaultIndex). Landing lives *inside* this effect so it always uses the416 // itemH that was just measured, never a fallback guess.417 // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>418 useLayoutEffect(() => {419 const scroller = scrollerRef.current;420 if (!scroller) return undefined;421 const measure = () => {422 const opts = scroller.querySelectorAll<HTMLElement>('[role="option"]');423 if (!opts.length) return;424 const itemH = opts[0].offsetHeight;425 setMetrics({426 itemH,427 centers: Array.from(opts, (el) => el.offsetTop + el.offsetHeight / 2),428 half: scroller.clientHeight / 2,429 });430 if (!didInitialScrollRef.current) {431 didInitialScrollRef.current = true;432 scroller.scrollTop = clampIndex(defaultIndex) * itemH;433 }434 };435 measure();436 const observer =437 typeof ResizeObserver !== "undefined"438 ? new ResizeObserver(measure)439 : null;440 observer?.observe(scroller);441 return () => observer?.disconnect();442 }, [options, derivedItemH]);443
444 useEffect(() => () => window.clearTimeout(settleTimer.current), []);445
446 useEffect(() => {447 if (!options.length) return;448 onChange?.({449 value: options[index]?.value ?? options[0].value,450 index,451 count: options.length,452 settled,453 });454 }, [options, index, settled, onChange]);455
456 const optionId = (i: number) => `${idBase}-opt-${i}`;457 const current = options[index];458
459 return (460 <div461 ref={containerRef}462 className="relative size-full"463 style={464 {465 "--wheel-item": `${derivedItemH}px`,466 // Explicit pixel height, NOT a percentage: vertical padding percentages467 // resolve against the containing block's *width*, not its height, so468 // "100%" here would silently break the centring math. See file header.469 "--wheel-h": `${boxH}px`,470 } as CSSProperties471 }472 >473 <div className="relative h-full">474 {lens}475 <div476 className="relative h-full overflow-x-hidden overflow-y-auto overscroll-contain [scroll-snap-type:y_mandatory] perspective-[44rem] scrollbar-none [&::-webkit-scrollbar]:hidden mask-[linear-gradient(to_bottom,transparent_0,#000_30%,#000_70%,transparent_100%)] [-webkit-mask-image:linear-gradient(to_bottom,transparent_0,#000_30%,#000_70%,transparent_100%)] focus-visible:outline-none"477 ref={scrollerRef}478 role="listbox"479 aria-label={label}480 aria-activedescendant={current ? optionId(index) : undefined}481 tabIndex={0}482 data-drum={drum ? "true" : "false"}483 onScroll={handleScroll}484 onScrollEnd={handleScrollEnd}485 onKeyDown={handleKeyDown}486 >487 <ul className="py-[calc((var(--wheel-h)-var(--wheel-item))/2)]">488 {options.map((option, i) => (489 <DrumOption490 className={option.className}491 align={align}492 key={option.value}493 id={optionId(i)}494 selected={i === index}495 scrollY={scrollY}496 center={497 metrics.centers[i] ??498 fallbackMetrics(options.length, boxH, derivedItemH).centers[i]499 }500 half={metrics.half || boxH / 2}501 drum={drum}502 flat={Boolean(reduced)}503 onClick={() => handleClick(i)}504 >505 {option.content}506 </DrumOption>507 ))}508 </ul>509 </div>510 </div>511
512 <p className="sr-only" role="status" aria-live="polite">513 {settled ? `Selected ${current?.value ?? ""}` : "Scrolling"}514 </p>515 </div>516 );517}518
519const WheelListWithComponents = Object.assign(WheelList, {520 Lens: WheelLens,521 Option: WheelOption522});523
524export {525 WheelLens,526 WheelListWithComponents as WheelList,527 WheelOption528};Update the import paths to match your project setup.
import { WheelList, WheelOption,} from "@/components/ui/pxl/wheel-list"const items = [ { label: "Light", value: "light" }, { label: "Dark", value: "dark" }, { label: "System", value: "system" },];
<WheelList> {items.map((item) => ( <WheelOption key={item.value} value={item.value}> {item.label} </WheelOption> ))}</WheelList>Composition
Section titled “Composition”Use the following composition to build a WheelList:
WheelList└── WheelOptionFramed
Section titled “Framed”Wrap the component inside a Card to display framed menus.
Menu
Ash Ketchum
- POKÉDEX
- POKÉMON
- ITEM
- TRAINER
- SAVE
- OPTION
- EXIT
Selected ITEM
1import {2 Card,3 CardContent,4 CardDescription,5 CardHeader,6 CardTitle,7} from "@/components/ui/pxl/card";8import { WheelList, WheelOption } from "@/components/ui/pxl/wheel-list";9import { WidgetArea } from "@/components/ui/pxl/widget-area";10
11const ITEMS: string[] = [12 "POKÉDEX",13 "POKÉMON",14 "ITEM",15 "TRAINER",16 "SAVE",17 "OPTION",18 "EXIT",19];20
21export default function InCardPreview() {22 return (23 <WidgetArea size="sm">24 <Card size="lg" className="size-full">25 <CardHeader>26 <CardTitle>Menu</CardTitle>27 <CardDescription>Ash Ketchum</CardDescription>28 </CardHeader>29 <CardContent>30 <WheelList31 align="start"32 visibleCount={3}33 defaultValue="ITEM"34 onChange={(evt) =>35 console.log(evt.settled ? `selected ${evt.value}` : "coasting")36 }37 onSelect={(value) => console.log(value)}38 >39 {ITEMS.map((i) => (40 <WheelOption key={i} value={i}>41 {i}42 </WheelOption>43 ))}44 </WheelList>45 </CardContent>46 </Card>47 </WidgetArea>48 );49}Language Selection
Section titled “Language Selection”Use the CountryFlag component for visual language selection.
- Español
- Português
- Français
- Italiano
- Deutsch
- English
- Русский
- हिन्दी
- বাংলা
- العربية
- 日本語
- 한국어
Selected
1import { CountryFlag } from "@/components/ui/pxl/country-flag";2import { WheelList, WheelOption } from "@/components/ui/pxl/wheel-list";3import { WidgetArea } from "@/components/ui/pxl/widget-area";4
5const items = [6 { label: "Español", iso31661: "ES", value: "es" },7 { label: "Português", iso31661: "PT", value: "pt" },8 { label: "Français", iso31661: "FR", value: "fr" },9 { label: "Italiano", iso31661: "IT", value: "it" },10 { label: "Deutsch", iso31661: "DE", value: "de" },11 { label: "English", iso31661: "GB", value: "gb" },12 { label: "Русский", iso31661: "RU", value: "ru" },13 { label: "हिन्दी", iso31661: "IN", value: "hi" },14 { label: "বাংলা", iso31661: "BD", value: "bn" },15 { label: "العربية", iso31661: "SA", value: "ar" },16 { label: "日本語", iso31661: "JP", value: "jp" },17 { label: "한국어", iso31661: "KR", value: "kr" },18];19
20export default function LanguageSelect() {21 return (22 <WidgetArea size="sm">23 <WheelList align="start" visibleCount={5} defaultValue="ES">24 {items.map((item) => (25 <WheelOption key={item.value} value={item.value}>26 <CountryFlag code={item.iso31661} />27 {item.label}28 </WheelOption>29 ))}30 </WheelList>31 </WidgetArea>32 );33}