Components

Inertial Wheel List

The iOS picker drum without scroll-jacking: it is a plain scrollable list, so the browser owns the momentum - a thumb-fling on a phone included - and scroll-snap: y mandatory lands every fling on an item. The selection is derived from the scroll (round(scrollTop / itemHeight)), never stored beside it, so the two cannot disagree. Each scroll frame, one rAF pass writes rotateX, scale and opacity straight onto the items by their distance from the centre - the middle item is biggest and the rim dissolves through a mask-image fade rather than clipping. It is also a proper listbox: one tab stop, arrows and Home/End scroll to the neighbour, and the live selection is announced.

  • 12:00 AM
  • 12:15 AM
  • 12:30 AM
  • 12:45 AM
  • 1:00 AM
  • 1:15 AM
  • 1:30 AM
  • 1:45 AM
  • 2:00 AM
  • 2:15 AM
  • 2:30 AM
  • 2:45 AM
  • 3:00 AM
  • 3:15 AM
  • 3:30 AM
  • 3:45 AM
  • 4:00 AM
  • 4:15 AM
  • 4:30 AM
  • 4:45 AM
  • 5:00 AM
  • 5:15 AM
  • 5:30 AM
  • 5:45 AM
  • 6:00 AM
  • 6:15 AM
  • 6:30 AM
  • 6:45 AM
  • 7:00 AM
  • 7:15 AM
  • 7:30 AM
  • 7:45 AM
  • 8:00 AM
  • 8:15 AM
  • 8:30 AM
  • 8:45 AM
  • 9:00 AM
  • 9:15 AM
  • 9:30 AM
  • 9:45 AM
  • 10:00 AM
  • 10:15 AM
  • 10:30 AM
  • 10:45 AM
  • 11:00 AM
  • 11:15 AM
  • 11:30 AM
  • 11:45 AM
  • 12:00 PM
  • 12:15 PM
  • 12:30 PM
  • 12:45 PM
  • 1:00 PM
  • 1:15 PM
  • 1:30 PM
  • 1:45 PM
  • 2:00 PM
  • 2:15 PM
  • 2:30 PM
  • 2:45 PM
  • 3:00 PM
  • 3:15 PM
  • 3:30 PM
  • 3:45 PM
  • 4:00 PM
  • 4:15 PM
  • 4:30 PM
  • 4:45 PM
  • 5:00 PM
  • 5:15 PM
  • 5:30 PM
  • 5:45 PM
  • 6:00 PM
  • 6:15 PM
  • 6:30 PM
  • 6:45 PM
  • 7:00 PM
  • 7:15 PM
  • 7:30 PM
  • 7:45 PM
  • 8:00 PM
  • 8:15 PM
  • 8:30 PM
  • 8:45 PM
  • 9:00 PM
  • 9:15 PM
  • 9:30 PM
  • 9:45 PM
  • 10:00 PM
  • 10:15 PM
  • 10:30 PM
  • 10:45 PM
  • 11:00 PM
  • 11:15 PM
  • 11:30 PM
  • 11:45 PM

Selected 9:00 AM

Selected: 9:00 AM · 37 / 96 · settled

Install

npx moumenlab add inertial-wheel-list

For AI

Open .md

Usage

"use client";

import WheelList from "./inertial-wheel-list";

// Every quarter hour of the day; the wheel starts on 9:00 AM (index 36).
export const TIMES: string[] = [];
for (let m = 0; m < 24 * 60; m += 15) {
  const h = Math.floor(m / 60);
  TIMES.push(`${h % 12 === 0 ? 12 : h % 12}:${String(m % 60).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`);
}

export default function WheelListExample() {
  return (
    <div className="flex flex-wrap items-start justify-center gap-6">
      {/* Default: the 3D drum. Selection derives from the scroll position and
          commits when the snap settles. */}
      <WheelList
        items={TIMES}
        label="Pick a start time"
        initialIndex={36}
        onStateChange={(state) => console.log(state.settled ? `selected ${state.value}` : "coasting")}
      />

      {/* drum={false}: a flat wheel - keeps the scale + fade, drops the rotateX. */}
      <WheelList items={TIMES} label="Pick a start time" initialIndex={36} drum={false} />
    </div>
  );
}

Story

  1. The iOS alarm drum

    The target was the wheel you spin to set an alarm on iOS: biggest and sharpest at the centre, dissolving at the rim, and it keeps moving after you let go.

  2. The scroll position is the state

    Nothing here is scroll-jacked and no library fakes the physics. It is a plain overflow-y list, so the browser and your thumb own the momentum, and snap mandatory plus snap-align center land every fling on an item. The selection is then read back out of scrollTop, never stored beside it, so the two can't disagree.

  3. The drum is paint, and the settle is defensive

    Each row takes its rotation, scale and opacity from how far it is from the centre, through motion values that update outside the React render; the rim is a mask, so items dissolve instead of clipping. And because scrollend doesn't fire in every engine, a 140ms quiet timer commits the same value.

References

  • scroll-snap-type (MDN): the one declaration doing the hardest part: mandatory means the scroller must come to rest on a snap point, so a fling can never stop between two items.