The iOS time-picker wheel on the web — 3D cylinder, momentum flicks, detent snapping, edge fog.
@uicapsule
"use client";
import { useState } from "react";
import type { FC } from "react";
import { motion, useMotionValue, useMotionValueEvent, useTransform } from "motion/react";
import type { MotionValue } from "motion/react";
const ITEM_HEIGHT = 38;
const VISIBLE_ITEMS = 7;
const WHEEL_HEIGHT = ITEM_HEIGHT * VISIBLE_ITEMS;
const CENTER_OFFSET = (WHEEL_HEIGHT - ITEM_HEIGHT) / 2;
const HOURS = Array.from({ length: 12 }, (_, index) => String(index + 1));
const MINUTES = Array.from({ length: 60 }, (_, index) => String(index).padStart(2, "0"));
const PERIODS = ["AM", "PM"];
type Align = "left" | "center" | "right";
const ALIGN_CLASS = {
center: "justify-center",
left: "justify-start pl-2",
right: "justify-end pr-2",
} satisfies Record<Align, string>;
interface WheelItemProps {
y: MotionValue<number>;
index: number;
label: string;
align: Align;
}
const WheelItem: FC<WheelItemProps> = ({ y, index, label, align }) => {
// Distance of this item from the selection line, in items (-3..3 visible).
const distance = useTransform(y, (value) => (value + index * ITEM_HEIGHT) / ITEM_HEIGHT);
const rotateX = useTransform(distance, [-3.5, 0, 3.5], [-62, 0, 62]);
const opacity = useTransform(distance, [-3.5, -2, 0, 2, 3.5], [0, 0.35, 1, 0.35, 0]);
const scale = useTransform(distance, [-3.5, 0, 3.5], [0.82, 1, 0.82]);
return (
<motion.div
style={{
height: ITEM_HEIGHT,
opacity,
rotateX,
scale,
transformPerspective: 780,
}}
className={`flex items-center text-[21px] text-white tabular-nums ${ALIGN_CLASS[align]}`}
>
{label}
</motion.div>
);
};
interface WheelProps {
items: string[];
initialIndex: number;
onChange: (index: number) => void;
width: string;
align: Align;
}
const Wheel: FC<WheelProps> = ({ items, initialIndex, onChange, width, align }) => {
const y = useMotionValue(-initialIndex * ITEM_HEIGHT);
useMotionValueEvent(y, "change", (value) => {
const index = Math.round(-value / ITEM_HEIGHT);
onChange(Math.min(items.length - 1, Math.max(0, index)));
});
return (
<div className={`relative ${width}`} style={{ height: WHEEL_HEIGHT }}>
<div className="absolute inset-0 overflow-hidden" style={{ perspective: 780 }}>
<motion.div
drag="y"
style={{ paddingTop: CENTER_OFFSET, y }}
dragConstraints={{
bottom: 0,
top: -(items.length - 1) * ITEM_HEIGHT,
}}
dragElastic={0.12}
dragTransition={{
bounceDamping: 28,
bounceStiffness: 320,
modifyTarget: (target) => Math.round(target / ITEM_HEIGHT) * ITEM_HEIGHT,
power: 0.55,
timeConstant: 220,
}}
className="cursor-grab active:cursor-grabbing"
>
{items.map((label, index) => (
<WheelItem key={label} y={y} index={index} label={label} align={align} />
))}
</motion.div>
</div>
</div>
);
};
export const DrumPicker = () => {
const [hourIndex, setHourIndex] = useState(8);
const [minuteIndex, setMinuteIndex] = useState(41);
const [periodIndex, setPeriodIndex] = useState(0);
const hour = HOURS[hourIndex] ?? "9";
const minute = MINUTES[minuteIndex] ?? "41";
const period = PERIODS[periodIndex] ?? "AM";
return (
<div className="w-[340px] rounded-[32px] bg-[#1d0b10] p-6 shadow-2xl shadow-black/60 ring-1 ring-white/10 select-none">
<div className="flex items-baseline justify-between">
<p className="text-[17px] font-semibold text-white">Alarm</p>
<p className="text-[13px] text-white/40 tabular-nums">
{hour}:{minute} {period}
</p>
</div>
<div className="relative mt-4">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 rounded-xl bg-rose-400/[0.1]"
style={{ height: ITEM_HEIGHT }}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 z-10 h-16 bg-gradient-to-b from-[#1d0b10] to-transparent"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-16 bg-gradient-to-t from-[#1d0b10] to-transparent"
/>
<div className="flex items-stretch justify-center gap-1 px-6">
<Wheel
items={HOURS}
initialIndex={8}
onChange={setHourIndex}
width="w-16"
align="right"
/>
<Wheel
items={MINUTES}
initialIndex={41}
onChange={setMinuteIndex}
width="w-16"
align="center"
/>
<Wheel
items={PERIODS}
initialIndex={0}
onChange={setPeriodIndex}
width="w-14"
align="left"
/>
</div>
</div>
<p className="mt-4 text-center text-[11px] text-white/30">Flick a wheel — it snaps to rest</p>
</div>
);
};














