Examples
Date Wheel

Date Wheel

An iOS-style date picker sheet: three vertical carousels turn like wheels while a month calendar follows along.

Live demo. Drag it, tap it.

How it works

  1. Three orientation="vertical" carousels with itemSize 36; itemAnimation sets rows 18° apart on a cylinder with translateY = R·sin θ and rotateX = −θ.
  2. snapMode="nearest" with snapMomentum 0.25 handles flicks, and each wheel's progress updates the header, calendar and dimmed days while it turns.
  3. onSnapToItem rolls February 30 back to the 28th with scrollTo({ index }); calendar taps and arrows drive the wheels with scrollTo, next() and prev().

Uses

Full source

import { SceneScreen, useScene } from "@/features/scenes/kit";
import { LinearGradient } from "expo-linear-gradient";
import * as React from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import Animated, {
	interpolate,
	interpolateColor,
	type SharedValue,
	useAnimatedReaction,
	useAnimatedStyle,
	useDerivedValue,
	useSharedValue,
} from "react-native-reanimated";
import {
	Carousel,
	type CarouselItemAnimation,
	type CarouselRef,
} from "react-native-reanimated-carousel";
import Svg, { Path } from "react-native-svg";
import { scheduleOnRN } from "react-native-worklets";
 
const MONTHS =
	"January February March April May June July August September October November December".split(
		" ",
	);
const TODAY = new Date();
const YEARS = Array.from(
	{ length: 21 },
	(_, k) => TODAY.getFullYear() - 10 + k,
); // today ± 10
const DAYS = Array.from({ length: 31 }, (_, k) => k + 1);
const WEEKDAYS = ["S", "M", "T", "W", "T", "F", "S"];
 
type Selection = { m: number; d: number; y: number };
const START: Selection = {
	m: TODAY.getMonth(),
	d: TODAY.getDate(),
	y: TODAY.getFullYear(),
};
 
const BLUE = "#007AFF";
const ITEM = 36; // row height
const WHEEL_H = 216; // six rows, like UIDatePicker
const DEG = 18; // rows sit 18° apart on the cylinder
const RADIUS = ITEM / ((DEG * Math.PI) / 180);
 
const mod = (n: number, m: number) => {
	"worklet";
	return ((n % m) + m) % m;
};
const daysIn = (year: number, month: number) => {
	"worklet";
	const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
	return month === 1 ? (leap ? 29 : 28) : 31 - ((month % 7) % 2);
};
const LINE = {
	fill: "none",
	strokeLinecap: "round",
	strokeLinejoin: "round",
} as const;
 
// Rows sit on a cylinder: R·sin θ places them, rotateX keeps them facing the axis.
const wheel: CarouselItemAnimation = (value) => {
	"worklet";
	const theta = Math.max(-90, Math.min(90, value * DEG));
	return {
		transform: [
			{ perspective: 500 },
			{
				translateY:
					(WHEEL_H - ITEM) / 2 + RADIUS * Math.sin((theta * Math.PI) / 180),
			},
			{ rotateX: `${-theta}deg` },
		],
		opacity:
			Math.abs(theta) >= 90
				? 0
				: interpolate(Math.abs(value), [0, 1, 4], [1, 0.75, 0.2], "clamp"),
	};
};
 
export default function Scene() {
	return (
		<SceneScreen
			background="#F2F2F7"
			statusBar="dark"
			fonts={[
				"Bricolage-Bold",
				"Instrument-Regular",
				"Instrument-Medium",
				"Instrument-SemiBold",
			]}
		>
			<NewEvent />
		</SceneScreen>
	);
}
 
function NewEvent() {
	const { width, height, insets } = useScene();
	const s = Math.min(width / 402, 1.05);
 
	const monthRef = React.useRef<CarouselRef>(null);
	const dayRef = React.useRef<CarouselRef>(null);
	const yearRef = React.useRef<CarouselRef>(null);
	const monthP = useSharedValue(START.m);
	const dayP = useSharedValue(START.d - 1);
	const yearP = useSharedValue(10);
	const [sel, setSel] = React.useState(START);
 
	// Live while the wheels turn: what the band shows right now, and how long that month is.
	const yearIndex = useDerivedValue(() =>
		Math.max(0, Math.min(YEARS.length - 1, Math.round(yearP.value))),
	);
	const monthDays = useDerivedValue(() =>
		daysIn(YEARS[yearIndex.value], mod(Math.round(monthP.value), 12)),
	);
	useAnimatedReaction(
		() => ({
			m: mod(Math.round(monthP.value), 12),
			d: mod(Math.round(dayP.value), 31) + 1,
			y: YEARS[yearIndex.value],
		}),
		(next, prev) => {
			if (!prev || next.m !== prev.m || next.d !== prev.d || next.y !== prev.y)
				scheduleOnRN(setSel, next);
		},
	);
 
	// February 30 does not exist: once the wheels settle, roll the day back to the month's last day.
	const clampDay = () => {
		const m = monthRef.current?.getCurrentIndex() ?? 0;
		const y = YEARS[yearRef.current?.getCurrentIndex() ?? 0];
		const d = (dayRef.current?.getCurrentIndex() ?? 0) + 1;
		if (d > daysIn(y, m)) dayRef.current?.scrollTo({ index: daysIn(y, m) - 1 });
	};
 
	const goToday = () => {
		monthRef.current?.scrollTo({ index: START.m });
		dayRef.current?.scrollTo({ index: START.d - 1 });
		yearRef.current?.scrollTo({ index: 10 });
	};
 
	const stepMonth = (step: "next" | "prev") => {
		monthRef.current?.[step]();
		// Paging past December (or back past January) turns the year wheel too.
		if (sel.m === (step === "next" ? 11 : 0)) yearRef.current?.[step]();
	};
 
	const day = Math.min(sel.d, daysIn(sel.y, sel.m));
	const date = new Date(sel.y, sel.m, day);
 
	// Fit the calendar rows between header and sheet; show the details list only if it fits too.
	const sheetH = 12 + 52 + WHEEL_H + 24 + insets.bottom;
	const free = height - insets.top - 44 - 104 - 16 - 82 - 16 - sheetH;
	const rowH = Math.max(28, Math.min(40, Math.floor(free / 6)));
	const showDetails = free - 6 * rowH >= 16 + 96;
 
	return (
		<View style={[styles.fill, { paddingTop: insets.top }]}>
			<View style={styles.nav}>
				<Text style={[styles.navButton, { fontSize: 17 * s }]}>Cancel</Text>
				<Text style={[styles.navTitle, { fontSize: 17 * s }]}>New Event</Text>
				<Text
					style={[
						styles.navButton,
						styles.bold,
						{ fontSize: 17 * s, textAlign: "right" },
					]}
				>
					Add
				</Text>
			</View>
 
			<View style={styles.header}>
				<Text style={[styles.kicker, { fontSize: 13 * s }]}>STARTS</Text>
				<Text
					style={[styles.bigDate, { fontSize: 32 * s, lineHeight: 40 * s }]}
				>
					{date.toLocaleDateString("en-US", {
						month: "long",
						day: "numeric",
						year: "numeric",
					})}
				</Text>
				<Text style={[styles.weekday, { fontSize: 17 * s }]}>
					{date.toLocaleDateString("en-US", { weekday: "long" })}
					<Text style={{ color: BLUE }}> · {relative(date)}</Text>
				</Text>
			</View>
 
			<MonthCard
				sel={{ ...sel, d: day }}
				rowH={rowH}
				s={s}
				onStep={stepMonth}
				onPick={(d) => dayRef.current?.scrollTo({ index: d - 1 })}
			/>
 
			{showDetails ? (
				<View style={[styles.group, styles.details]}>
					<DetailRow label="Repeat" value="Never" />
					<DetailRow label="Alert" value="1 day before" last />
				</View>
			) : null}
 
			{/* The picker sheet sits over the page, like a sheet presented at a small detent. */}
			<View style={[styles.sheet, { paddingBottom: insets.bottom + 12 }]}>
				<View style={[styles.grabber, { backgroundColor: "#C7C7CC" }]} />
				<View style={styles.toolbar}>
					<Pressable onPress={goToday} hitSlop={8}>
						<Text style={[styles.toolButton, { fontSize: 17 * s }]}>Today</Text>
					</Pressable>
					<Text style={[styles.toolTitle, { fontSize: 17 * s }]}>
						Select date
					</Text>
					<DoneButton s={s} />
				</View>
				<View style={styles.picker}>
					<View style={styles.band} />
					<Wheel
						wheelRef={monthRef}
						labels={MONTHS}
						width={150 * s}
						defaultIndex={START.m}
						progress={monthP}
						onSnap={clampDay}
						loop
					/>
					<Wheel
						wheelRef={dayRef}
						labels={DAYS}
						width={80 * s}
						defaultIndex={START.d - 1}
						progress={dayP}
						onSnap={clampDay}
						limit={monthDays}
						loop
					/>
					<Wheel
						wheelRef={yearRef}
						labels={YEARS}
						width={100 * s}
						defaultIndex={10}
						progress={yearP}
						onSnap={clampDay}
					/>
					<LinearGradient
						colors={["#fff", "#fff0"]}
						style={[styles.fade, { top: 0 }]}
					/>
					<LinearGradient
						colors={["#fff0", "#fff"]}
						style={[styles.fade, { bottom: 0 }]}
					/>
				</View>
			</View>
		</View>
	);
}
 
function relative(date: Date) {
	const start = new Date(
		TODAY.getFullYear(),
		TODAY.getMonth(),
		TODAY.getDate(),
	);
	const days = Math.round((date.getTime() - start.getTime()) / 86400000);
	if (days === 0) return "Today";
	if (days === 1) return "Tomorrow";
	if (days === -1) return "Yesterday";
	const [n, unit] =
		Math.abs(days) < 31
			? [Math.abs(days), "day"]
			: Math.abs(days) < 365
				? [Math.round(Math.abs(days) / 30.44), "month"]
				: [Math.round(Math.abs(days) / 365.25), "year"];
	const span = `${n} ${unit}${n === 1 ? "" : "s"}`;
	return days > 0 ? `In ${span}` : `${span} ago`;
}
 
type WheelProps = {
	wheelRef: React.RefObject<CarouselRef | null>;
	labels: (string | number)[];
	width: number;
	defaultIndex: number;
	progress: SharedValue<number>;
	onSnap: () => void;
	limit?: SharedValue<number>;
	loop?: boolean;
};
 
function Wheel({
	wheelRef,
	labels,
	width,
	limit,
	onSnap,
	...rest
}: WheelProps) {
	return (
		<Carousel
			{...rest}
			ref={wheelRef}
			orientation="vertical"
			data={labels}
			itemSize={ITEM}
			style={{ width, height: WHEEL_H }}
			snapMode="nearest"
			snapMomentum={0.25}
			itemAnimation={wheel}
			onSnapToItem={onSnap}
			renderItem={({ item, index, relativeProgress }) => (
				<WheelRow
					label={String(item)}
					index={index}
					relativeProgress={relativeProgress}
					limit={limit}
				/>
			)}
		/>
	);
}
 
type RowProps = {
	label: string;
	index: number;
	relativeProgress: SharedValue<number>;
	limit?: SharedValue<number>;
};
 
const WheelRow = React.memo(function WheelRow({
	label,
	index,
	relativeProgress,
	limit,
}: RowProps) {
	const style = useAnimatedStyle(() => ({
		// Black inside the band, grey outside; days the month does not have are dimmed.
		color:
			limit && index >= limit.value
				? "#C7C7CC"
				: interpolateColor(
						Math.abs(relativeProgress.value),
						[0.3, 0.7],
						["#000000", "#8E8E93"],
					),
	}));
	return (
		<View style={styles.row}>
			<Animated.Text style={[styles.rowText, style]}>{label}</Animated.Text>
		</View>
	);
});
 
type MonthCardProps = {
	sel: Selection;
	rowH: number;
	s: number;
	onStep: (step: "next" | "prev") => void;
	onPick: (day: number) => void;
};
 
function MonthCard({ sel, rowH, s, onStep, onPick }: MonthCardProps) {
	const lead = new Date(sel.y, sel.m, 1).getDay();
	const count = daysIn(sel.y, sel.m);
	const prevCount = daysIn(sel.m === 0 ? sel.y - 1 : sel.y, (sel.m + 11) % 12);
	const isThisMonth =
		sel.y === TODAY.getFullYear() && sel.m === TODAY.getMonth();
	const weeks = Array.from({ length: 6 }, (_, w) =>
		Array.from({ length: 7 }, (_, k) => w * 7 + k - lead + 1),
	);
	return (
		<View style={[styles.group, styles.card]}>
			<View style={styles.cardHead}>
				<Text style={[styles.cardTitle, { fontSize: 17 * s }]}>
					{MONTHS[sel.m]} {sel.y}
				</Text>
				<View style={styles.arrows}>
					<Chevron flip onPress={() => onStep("prev")} />
					<Chevron onPress={() => onStep("next")} />
				</View>
			</View>
			<View style={styles.week}>
				{WEEKDAYS.map((w, k) => (
					<Text key={k} style={[styles.weekLabel, { fontSize: 13 * s }]}>
						{w}
					</Text>
				))}
			</View>
			{weeks.map((week, w) => (
				<View key={w} style={[styles.week, { height: rowH }]}>
					{week.map((d) => {
						const inMonth = d >= 1 && d <= count;
						const picked = d === sel.d;
						const today = isThisMonth && d === TODAY.getDate();
						// Days of the previous and next month fill the grid in grey.
						const label = d < 1 ? prevCount + d : inMonth ? d : d - count;
						return (
							<Pressable
								key={d}
								disabled={!inMonth}
								onPress={() => onPick(d)}
								style={styles.cell}
							>
								<View
									style={[
										styles.dayDot,
										{ width: rowH - 2, height: rowH - 2, borderRadius: rowH },
										picked && {
											backgroundColor: today ? BLUE : "rgba(0,122,255,0.12)",
										},
									]}
								>
									<Text
										style={[
											styles.dayText,
											{ fontSize: 17 * s },
											!inMonth && styles.dayOther,
											today && { color: BLUE },
											picked && styles.dayPicked,
											picked && today && { color: "#fff" },
										]}
									>
										{label}
									</Text>
								</View>
							</Pressable>
						);
					})}
				</View>
			))}
		</View>
	);
}
 
function DetailRow({
	label,
	value,
	last,
}: {
	label: string;
	value: string;
	last?: boolean;
}) {
	return (
		<View style={[styles.detailRow, !last && styles.separator]}>
			<Text style={styles.detailLabel}>{label}</Text>
			<Text style={styles.detailValue}>{value}</Text>
			<Svg width={8} height={13} viewBox="0 0 8 13">
				<Path d="M1.5 1.5l5 5-5 5" stroke="#C4C4C7" strokeWidth={2} {...LINE} />
			</Svg>
		</View>
	);
}
 
function Chevron({ flip, onPress }: { flip?: boolean; onPress: () => void }) {
	return (
		<Pressable onPress={onPress} hitSlop={8} style={styles.chevron}>
			<Svg
				width={12}
				height={20}
				viewBox="0 0 12 20"
				style={flip && styles.flip}
			>
				<Path d="M2 2l8 8-8 8" stroke={BLUE} strokeWidth={2.6} {...LINE} />
			</Svg>
		</Pressable>
	);
}
 
function DoneButton({ s }: { s: number }) {
	const [saved, setSaved] = React.useState(false);
	React.useEffect(() => {
		if (!saved) return;
		const t = setTimeout(() => setSaved(false), 1200);
		return () => clearTimeout(t);
	}, [saved]);
	return (
		<Pressable onPress={() => setSaved(true)} hitSlop={8}>
			<Text style={[styles.toolButton, styles.bold, { fontSize: 17 * s }]}>
				{saved ? "Saved" : "Done"}
			</Text>
		</Pressable>
	);
}
 
const styles = StyleSheet.create({
	fill: { flex: 1 },
	nav: {
		height: 44,
		flexDirection: "row",
		alignItems: "center",
		paddingHorizontal: 16,
	},
	navButton: { width: 72, fontFamily: "Instrument-Regular", color: BLUE },
	navTitle: {
		flex: 1,
		textAlign: "center",
		fontFamily: "Instrument-SemiBold",
		color: "#000",
	},
	bold: { fontFamily: "Instrument-SemiBold" },
	header: { height: 104, paddingHorizontal: 20, justifyContent: "center" },
	kicker: {
		fontFamily: "Instrument-Medium",
		color: "#8E8E93",
		letterSpacing: 0.4,
	},
	bigDate: { fontFamily: "Bricolage-Bold", color: "#000", marginTop: 4 },
	weekday: { fontFamily: "Instrument-Medium", color: "#3C3C43" },
	group: { borderRadius: 16, backgroundColor: "#fff" },
	card: {
		margin: 16,
		marginBottom: 0,
		padding: 12,
		paddingTop: 0,
		paddingBottom: 10,
	},
	cardHead: {
		height: 48,
		flexDirection: "row",
		alignItems: "center",
		paddingLeft: 4,
	},
	cardTitle: { flex: 1, fontFamily: "Instrument-SemiBold", color: "#000" },
	arrows: { flexDirection: "row", gap: 20, paddingRight: 8 },
	chevron: { padding: 2 },
	flip: { transform: [{ scaleX: -1 }] },
	week: { flexDirection: "row", height: 24, alignItems: "center" },
	weekLabel: {
		flex: 1,
		textAlign: "center",
		fontFamily: "Instrument-SemiBold",
		color: "#3C3C434D",
	},
	cell: { flex: 1, alignItems: "center", justifyContent: "center" },
	dayDot: { alignItems: "center", justifyContent: "center" },
	dayText: {
		fontVariant: ["tabular-nums"],
		color: "#000",
		includeFontPadding: false,
		textAlignVertical: "center",
	},
	dayPicked: { color: BLUE, fontWeight: "600" },
	dayOther: { color: "#C7C7CC" },
	details: { margin: 16, marginBottom: 0, paddingLeft: 16 },
	detailRow: {
		height: 48,
		flexDirection: "row",
		alignItems: "center",
		gap: 8,
		paddingRight: 16,
	},
	separator: {
		borderBottomWidth: StyleSheet.hairlineWidth,
		borderBottomColor: "#C6C6C8",
	},
	detailLabel: {
		flex: 1,
		fontFamily: "Instrument-Regular",
		fontSize: 17,
		color: "#000",
	},
	detailValue: {
		fontFamily: "Instrument-Regular",
		fontSize: 17,
		color: "#8E8E93",
	},
	sheet: {
		position: "absolute",
		left: 0,
		right: 0,
		bottom: 0,
		borderTopLeftRadius: 20,
		borderTopRightRadius: 20,
		backgroundColor: "#fff",
		boxShadow: "0px -6px 30px rgba(0,0,0,0.08)",
	},
	grabber: {
		alignSelf: "center",
		width: 36,
		height: 5,
		borderRadius: 3,
		marginTop: 6,
	},
	toolbar: {
		height: 52,
		flexDirection: "row",
		alignItems: "center",
		justifyContent: "space-between",
		paddingHorizontal: 20,
		borderBottomWidth: StyleSheet.hairlineWidth,
		borderBottomColor: "#C6C6C8",
	},
	toolButton: { fontFamily: "Instrument-Medium", color: BLUE },
	toolTitle: { fontFamily: "Instrument-SemiBold", color: "#000" },
	picker: {
		height: WHEEL_H,
		marginTop: 12,
		flexDirection: "row",
		justifyContent: "center",
	},
	band: {
		position: "absolute",
		left: 16,
		right: 16,
		top: (WHEEL_H - ITEM) / 2,
		height: ITEM,
		borderRadius: 9,
		backgroundColor: "#EEEEF2",
	},
	fade: {
		position: "absolute",
		left: 0,
		right: 0,
		height: 44,
		pointerEvents: "none",
	},
	row: { height: ITEM, alignItems: "center", justifyContent: "center" },
	rowText: {
		fontSize: 21,
		fontVariant: ["tabular-nums"],
		includeFontPadding: false,
		textAlignVertical: "center",
	},
});