ComponentsInputs
Date Wheel Picker
iOS-style date wheel picker for React. 3D rotation effect with drag gestures and keyboard support. Mobile-friendly date selection component.
Last updated on
"use client";
import { useState } from "react";
import { DateWheelPicker } from "@/components/ui/date-wheel-picker";
export function DateWheelPickerDemo() {
const [date, setDate] = useState<Date>(new Date());
return (
<div className="relative flex h-[300px] w-full flex-col items-center justify-center gap-4">
<DateWheelPicker value={date} onChange={setDate} />
<p className="text-muted-foreground text-sm">
Selected: {date.toLocaleDateString()}
</p>
</div>
);
}Installation
CLI
npx shadcn@latest add "https://jolyui.dev/r/date-wheel-picker"Manual
Install the following dependencies:
npm install motionCopy and paste the following code into your project.
"use client";
import {
animate,
type MotionValue,
motion,
type PanInfo,
useMotionValue,
useTransform,
} from "framer-motion";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface DateWheelPickerProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
value?: Date;
onChange: (date: Date) => void;
minYear?: number;
maxYear?: number;
size?: "sm" | "md" | "lg";
disabled?: boolean;
locale?: string;
}
const ITEM_HEIGHT = 40;
const VISIBLE_ITEMS = 5;
const PERSPECTIVE_ORIGIN = ITEM_HEIGHT * 2;
function getMonthNames(locale?: string): string[] {
const formatter = new Intl.DateTimeFormat(locale, { month: "long" });
return Array.from({ length: 12 }, (_, i) =>
formatter.format(new Date(2000, i, 1)),
);
}
const sizeConfig = {
sm: {
height: ITEM_HEIGHT * VISIBLE_ITEMS * 0.8,
itemHeight: ITEM_HEIGHT * 0.8,
fontSize: "text-sm",
gap: "gap-2",
},
md: {
height: ITEM_HEIGHT * VISIBLE_ITEMS,
itemHeight: ITEM_HEIGHT,
fontSize: "text-base",
gap: "gap-4",
},
lg: {
height: ITEM_HEIGHT * VISIBLE_ITEMS * 1.2,
itemHeight: ITEM_HEIGHT * 1.2,
fontSize: "text-lg",
gap: "gap-6",
},
};
interface WheelItemProps {
item: string | number;
index: number;
y: MotionValue<number>;
itemHeight: number;
visibleItems: number;
centerOffset: number;
isSelected: boolean;
disabled?: boolean;
onClick: () => void;
}
function WheelItem({
item,
index,
y,
itemHeight,
visibleItems,
centerOffset,
isSelected,
disabled,
onClick,
}: WheelItemProps) {
const itemY = useTransform(y, (latest) => {
const offset = index * itemHeight + latest + centerOffset;
return offset;
});
const rotateX = useTransform(
itemY,
[0, centerOffset, itemHeight * visibleItems],
[45, 0, -45],
);
const scale = useTransform(
itemY,
[0, centerOffset, itemHeight * visibleItems],
[0.8, 1, 0.8],
);
const opacity = useTransform(
itemY,
[
0,
centerOffset * 0.5,
centerOffset,
centerOffset * 1.5,
itemHeight * visibleItems,
],
[0.3, 0.6, 1, 0.6, 0.3],
);
return (
<motion.div
className="flex select-none items-center justify-center"
style={{
height: itemHeight,
rotateX,
scale,
opacity,
transformStyle: "preserve-3d",
transformOrigin: `center center -${PERSPECTIVE_ORIGIN}px`,
}}
onClick={() => !disabled && onClick()}
>
<span
className={cn(
"font-medium transition-colors",
isSelected ? "text-foreground" : "text-muted-foreground",
)}
>
{item}
</span>
</motion.div>
);
}
interface WheelColumnProps {
items: (string | number)[];
value: number;
onChange: (index: number) => void;
itemHeight: number;
visibleItems: number;
disabled?: boolean;
className?: string;
ariaLabel: string;
}
function WheelColumn({
items,
value,
onChange,
itemHeight,
visibleItems,
disabled,
className,
ariaLabel,
}: WheelColumnProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const y = useMotionValue(-value * itemHeight);
const centerOffset = Math.floor(visibleItems / 2) * itemHeight;
const valueRef = React.useRef(value);
const onChangeRef = React.useRef(onChange);
const itemsLengthRef = React.useRef(items.length);
React.useEffect(() => {
valueRef.current = value;
onChangeRef.current = onChange;
itemsLengthRef.current = items.length;
});
React.useEffect(() => {
animate(y, -value * itemHeight, {
type: "spring",
stiffness: 300,
damping: 30,
});
}, [value, itemHeight, y]);
const handleDragEnd = (
_: MouseEvent | TouchEvent | PointerEvent,
info: PanInfo,
) => {
if (disabled) return;
const currentY = y.get();
const velocity = info.velocity.y;
const projectedY = currentY + velocity * 0.2;
let newIndex = Math.round(-projectedY / itemHeight);
newIndex = Math.max(0, Math.min(items.length - 1, newIndex));
onChange(newIndex);
};
React.useEffect(() => {
const container = containerRef.current;
if (!container || disabled) return;
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
e.stopPropagation();
const direction = e.deltaY > 0 ? 1 : -1;
const currentValue = valueRef.current;
const maxIndex = itemsLengthRef.current - 1;
const newIndex = Math.max(
0,
Math.min(maxIndex, currentValue + direction),
);
if (newIndex !== currentValue) {
onChangeRef.current(newIndex);
}
};
container.addEventListener("wheel", handleWheel, { passive: false });
return () => {
container.removeEventListener("wheel", handleWheel);
};
}, [disabled]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
const maxIndex = items.length - 1;
let newIndex = value;
switch (e.key) {
case "ArrowUp":
e.preventDefault();
newIndex = Math.max(0, value - 1);
break;
case "ArrowDown":
e.preventDefault();
newIndex = Math.min(maxIndex, value + 1);
break;
case "Home":
e.preventDefault();
newIndex = 0;
break;
case "End":
e.preventDefault();
newIndex = maxIndex;
break;
case "PageUp":
e.preventDefault();
newIndex = Math.max(0, value - 5);
break;
case "PageDown":
e.preventDefault();
newIndex = Math.min(maxIndex, value + 5);
break;
default:
return;
}
if (newIndex !== value) {
onChange(newIndex);
}
};
const dragConstraints = React.useMemo(
() => ({
top: -(items.length - 1) * itemHeight,
bottom: 0,
}),
[items.length, itemHeight],
);
return (
<div
ref={containerRef}
className={cn(
"relative overflow-hidden",
disabled && "pointer-events-none opacity-50",
className,
)}
style={{ height: itemHeight * visibleItems }}
tabIndex={disabled ? -1 : 0}
onKeyDown={handleKeyDown}
role="spinbutton"
aria-label={ariaLabel}
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={items.length - 1}
aria-valuetext={String(items[value])}
aria-disabled={disabled}
>
<div
className="pointer-events-none absolute inset-x-0 top-0 z-10"
style={{
height: centerOffset,
background:
"linear-gradient(to bottom, var(--background) 0%, transparent 100%)",
}}
aria-hidden="true"
/>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-10"
style={{
height: centerOffset,
background:
"linear-gradient(to top, var(--background) 0%, transparent 100%)",
}}
aria-hidden="true"
/>
<div
className="pointer-events-none absolute inset-x-0 z-5 border-border border-y bg-muted/30"
style={{
top: centerOffset,
height: itemHeight,
}}
aria-hidden="true"
/>
<motion.div
className="cursor-grab active:cursor-grabbing"
style={{
y,
paddingTop: centerOffset,
paddingBottom: centerOffset,
}}
drag="y"
dragConstraints={dragConstraints}
dragElastic={0.1}
onDragEnd={handleDragEnd}
>
{items.map((item, index) => (
<WheelItem
key={`${item}-${index}`}
item={item}
index={index}
y={y}
itemHeight={itemHeight}
visibleItems={visibleItems}
centerOffset={centerOffset}
isSelected={index === value}
disabled={disabled}
onClick={() => onChange(index)}
/>
))}
</motion.div>
</div>
);
}
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
const DateWheelPicker = React.forwardRef<HTMLDivElement, DateWheelPickerProps>(
(
{
value,
onChange,
minYear = 1920,
maxYear = new Date().getFullYear(),
size = "md",
disabled = false,
locale,
className,
...props
},
ref,
) => {
const config = sizeConfig[size];
const months = React.useMemo(() => getMonthNames(locale), [locale]);
const years = React.useMemo(() => {
const arr: number[] = [];
for (let y = maxYear; y >= minYear; y--) {
arr.push(y);
}
return arr;
}, [minYear, maxYear]);
const [dateState, setDateState] = React.useState(() => {
const currentDate = value || new Date();
return {
day: currentDate.getDate(),
month: currentDate.getMonth(),
year: currentDate.getFullYear(),
};
});
const isInternalChange = React.useRef(false);
const days = React.useMemo(() => {
const daysInMonth = getDaysInMonth(dateState.year, dateState.month);
return Array.from({ length: daysInMonth }, (_, i) => i + 1);
}, [dateState.month, dateState.year]);
const handleDayChange = React.useCallback((dayIndex: number) => {
isInternalChange.current = true;
setDateState((prev) => ({ ...prev, day: dayIndex + 1 }));
}, []);
const handleMonthChange = React.useCallback((monthIndex: number) => {
isInternalChange.current = true;
setDateState((prev) => {
const daysInNewMonth = getDaysInMonth(prev.year, monthIndex);
const adjustedDay = Math.min(prev.day, daysInNewMonth);
return { ...prev, month: monthIndex, day: adjustedDay };
});
}, []);
const handleYearChange = React.useCallback(
(yearIndex: number) => {
isInternalChange.current = true;
setDateState((prev) => {
const newYear = years[yearIndex] ?? prev.year;
const daysInNewMonth = getDaysInMonth(newYear, prev.month);
const adjustedDay = Math.min(prev.day, daysInNewMonth);
return { ...prev, year: newYear, day: adjustedDay };
});
},
[years],
);
React.useEffect(() => {
if (isInternalChange.current) {
const newDate = new Date(
dateState.year,
dateState.month,
dateState.day,
);
onChange(newDate);
isInternalChange.current = false;
}
}, [dateState, onChange]);
React.useEffect(() => {
if (value && !isInternalChange.current) {
const valueDay = value.getDate();
const valueMonth = value.getMonth();
const valueYear = value.getFullYear();
if (
valueDay !== dateState.day ||
valueMonth !== dateState.month ||
valueYear !== dateState.year
) {
setDateState({
day: valueDay,
month: valueMonth,
year: valueYear,
});
}
}
}, [value, dateState.day, dateState.month, dateState.year]);
const yearIndex = years.indexOf(dateState.year);
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center",
config.gap,
config.fontSize,
disabled && "pointer-events-none opacity-50",
className,
)}
style={{ perspective: "1000px" }}
role="group"
aria-label="Date picker"
{...props}
>
<WheelColumn
items={days}
value={dateState.day - 1}
onChange={handleDayChange}
itemHeight={config.itemHeight}
visibleItems={VISIBLE_ITEMS}
disabled={disabled}
className="w-16"
ariaLabel="Select day"
/>
<WheelColumn
items={months}
value={dateState.month}
onChange={handleMonthChange}
itemHeight={config.itemHeight}
visibleItems={VISIBLE_ITEMS}
disabled={disabled}
className="w-28"
ariaLabel="Select month"
/>
<WheelColumn
items={years}
value={yearIndex >= 0 ? yearIndex : 0}
onChange={handleYearChange}
itemHeight={config.itemHeight}
visibleItems={VISIBLE_ITEMS}
disabled={disabled}
className="w-20"
ariaLabel="Select year"
/>
</div>
);
},
);
DateWheelPicker.displayName = "DateWheelPicker";
export { DateWheelPicker };Features
- 3D Wheel Effect: Smooth perspective-based rotation for a realistic wheel appearance
- Drag Gestures: Drag to scroll through options with momentum
- Scroll Support: Mouse wheel scrolling for quick navigation
- Keyboard Navigation: Full keyboard support (Arrow keys, Home, End, Page Up/Down)
- Locale Support: Automatic month name localization
- Multiple Sizes: Small, medium, and large size variants
- Accessible: Proper ARIA attributes and keyboard handling
Sizes
The picker supports three sizes: sm, md (default), and lg.
"use client";
import { useState } from "react";
import { DateWheelPicker } from "@/components/ui/date-wheel-picker";
export function DateWheelPickerSizesDemo() {
const [date, setDate] = useState<Date>(new Date());
return (
<div className="relative flex w-full flex-col items-center justify-center gap-8 py-8">
<div className="flex flex-col items-center gap-2">
<span className="font-medium text-muted-foreground text-sm">Small</span>
<DateWheelPicker value={date} onChange={setDate} size="sm" />
</div>
<div className="flex flex-col items-center gap-2">
<span className="font-medium text-muted-foreground text-sm">
Medium (default)
</span>
<DateWheelPicker value={date} onChange={setDate} size="md" />
</div>
<div className="flex flex-col items-center gap-2">
<span className="font-medium text-muted-foreground text-sm">Large</span>
<DateWheelPicker value={date} onChange={setDate} size="lg" />
</div>
</div>
);
}Locale Support
Pass a locale string to display month names in different languages.
"use client";
import { useState } from "react";
import { DateWheelPicker } from "@/components/ui/date-wheel-picker";
export function DateWheelPickerLocaleDemo() {
const [date, setDate] = useState<Date>(new Date());
return (
<div className="relative flex w-full flex-col items-center justify-center gap-8 py-8">
<div className="flex flex-col items-center gap-2">
<span className="font-medium text-muted-foreground text-sm">
English (US)
</span>
<DateWheelPicker value={date} onChange={setDate} locale="en-US" />
</div>
<div className="flex flex-col items-center gap-2">
<span className="font-medium text-muted-foreground text-sm">
French
</span>
<DateWheelPicker value={date} onChange={setDate} locale="fr-FR" />
</div>
<div className="flex flex-col items-center gap-2">
<span className="font-medium text-muted-foreground text-sm">
German
</span>
<DateWheelPicker value={date} onChange={setDate} locale="de-DE" />
</div>
</div>
);
}API Reference
Prop
Type
How is this guide?