こんにちは世界

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>