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

Edit on GitHub
"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    motion

Copy 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?

On this page