Wheel Picker
IOS styled wheel picker.
Demo
Item 5
Item 1
Item 2
<script lang="ts"> import * as Null from "#lib/index.js"; </script> <Null.WheelPicker visibleItems={3} />
WheelPicker.svelte
<script lang="ts"> let { options = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"], value = $bindable<string | number>("March"), visibleItems = 5, onChange, }: { options?: (string | number)[]; value?: string | number; visibleItems?: 3 | 5; onChange?: (value: string | number) => void; } = $props(); const ITEM_HEIGHT = 40; let scrollPosition = $state(0); let dragging = $state(false); let dragStartY = 0; let dragStartPosition = 0; let wheelTimer: ReturnType<typeof setTimeout> | undefined; const selectedIndex = $derived( options.length ? ((Math.round(scrollPosition) % options.length) + options.length) % options.length : 0, ); const baseIndex = $derived(Math.round(scrollPosition)); const fraction = $derived(scrollPosition - baseIndex); const slots = $derived( Array.from( { length: options.length ? visibleItems : 0 }, (_, index) => index - Math.floor(visibleItems / 2), ), ); $effect(() => { if (dragging) return; const index = options.findIndex((option) => option === value); if (index >= 0 && index !== selectedIndex) { scrollPosition = index; } }); function wrapIndex(index: number) { return ((index % options.length) + options.length) % options.length; } function syncValue() { if (!options.length) return; const index = wrapIndex(Math.round(scrollPosition)); const nextValue = options[index]; if (nextValue === undefined || nextValue === value) return; value = nextValue; onChange?.(nextValue); } function handleWheel(event: WheelEvent) { if (!options.length || event.deltaY === 0) return; event.preventDefault(); const delta = Math.max(-100, Math.min(100, event.deltaY)); scrollPosition += delta / ITEM_HEIGHT; syncValue(); clearTimeout(wheelTimer); wheelTimer = setTimeout(() => { if (!dragging) { scrollPosition = Math.round(scrollPosition); syncValue(); } }, 110); } function handlePointerDown(event: PointerEvent) { if (event.button !== 0 || !options.length) return; clearTimeout(wheelTimer); dragging = true; dragStartY = event.clientY; dragStartPosition = scrollPosition; if (event.currentTarget instanceof HTMLElement) { event.currentTarget.setPointerCapture(event.pointerId); } } function handlePointerMove(event: PointerEvent) { if (!dragging) return; scrollPosition = dragStartPosition - (event.clientY - dragStartY) / ITEM_HEIGHT; syncValue(); } function finishDrag() { if (!dragging) return; dragging = false; scrollPosition = Math.round(scrollPosition); syncValue(); } function handleKeydown(event: KeyboardEvent) { if (!options.length) return; if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); scrollPosition += event.key === "ArrowDown" ? 1 : -1; syncValue(); } else if (event.key === "Home") { event.preventDefault(); scrollPosition = 0; syncValue(); } else if (event.key === "End") { event.preventDefault(); scrollPosition = options.length - 1; syncValue(); } } </script> <svelte:window onpointerup={finishDrag} onpointercancel={finishDrag} /> <div class="wheel-picker" class:dragging style={`--visible-items: ${visibleItems};`} role="listbox" aria-label="Choose an option" aria-activedescendant={options.length ? `wheel-option-${selectedIndex}` : undefined} tabindex="0" onwheel={handleWheel} onpointerdown={handlePointerDown} onpointermove={handlePointerMove} onkeydown={handleKeydown} > <div class="selection-band"></div> {#each slots as offset (offset)} {@const relative = offset - fraction} {@const distance = Math.abs(relative)} {@const index = options.length ? wrapIndex(baseIndex + offset) : 0} {@const option = options[index]} {@const scale = Math.max(0.52, 1 - distance * 0.19)} {@const rotation = relative * 22} {@const opacity = Math.max(0.12, 1 - distance * 0.5)} {@const translateZ = -distance * 22} <div id={`wheel-option-${index}`} class="option" class:selected={offset === 0 && Math.abs(fraction) < 0.5} style={`--y: ${relative * ITEM_HEIGHT}px; --scale: ${scale}; --rotation: ${rotation}deg; --opacity: ${opacity}; --z: ${translateZ}px;`} role="option" aria-selected={index === selectedIndex} > {option} </div> {/each} </div> <style> .wheel-picker { --wheel-border-radius: 20px; --wheel-item-height: 40px; position: relative; width: 180px; height: calc(var(--wheel-item-height) * var(--visible-items)); overflow: hidden; perspective: 450px; perspective-origin: center center; transform-style: preserve-3d; touch-action: pan-x; user-select: none; outline: none; color: var(--foreground, currentColor); cursor: grab; contain: layout paint; } .wheel-picker.dragging { cursor: grabbing; } .selection-band { position: absolute; inset-inline: 0; top: calc(50% - var(--wheel-item-height) / 2); height: var(--wheel-item-height); border-radius: var(--wheel-border-radius); background: var(--foreground); pointer-events: none; } .option { position: absolute; inset-inline: 0; top: 50%; display: flex; align-items: center; justify-content: center; height: var(--wheel-item-height); padding-inline: 12px; color: inherit; font: inherit; text-align: center; white-space: nowrap; pointer-events: none; backface-visibility: hidden; transform-style: preserve-3d; opacity: var(--opacity); transform: translateY(calc(-50% + var(--y))) translateZ(var(--z)) rotateX(var(--rotation)) scale(var(--scale)); transition: transform 100ms ease-out, opacity 100ms ease-out; will-change: transform, opacity; } .option.selected { color: var(--background); font-weight: 600; } .dragging .option { transition: none; } @media (prefers-reduced-motion: reduce) { .option { transition: none; } } </style>