Month-view calendar built with dependency-free Date math: day grid, prev/next navigation, selected highlight, disabled-day bounds and marked-date dots
components/ui/calendar/calendar.tsx@lumioui/core@latest@lumioui/ui@latestreact-native-reanimated@^4.0.0expo-haptics@*Live preview — the real component, driven by the props below.
import { Calendar } from "@/components/ui/calendar/calendar";
export function Example() {
return (
<Calendar />
);
} import * as React from "react";
import { forwardRef, useMemo, useState } from "react";
import { Text, View, type ViewProps } from "react-native";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withSequence,
withSpring,
withTiming,
} from "react-native-reanimated";
import { cn, spring, timing, useHaptic, useReducedMotion, usePressAnimation, AnimatedPressable } from "@lumioui/core";
import { Icon } from "@lumioui/ui";
const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
/** Full names announced to screen readers — the two-letter glyphs aren't. */
const WEEKDAY_NAMES = [
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday",
];
const MONTHS = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
/** Weeks matrix starting Sunday; nulls pad out-of-month cells. */
function buildWeeks(year: number, month: number): (Date | null)[][] {
const firstDow = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells: (Date | null)[] = [];
for (let i = 0; i < firstDow; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(year, month, d));
while (cells.length % 7 !== 0) cells.push(null);
const rows: (Date | null)[][] = [];
for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7));
return rows;
}
function toKey(d: Date): string {
const m = `${d.getMonth() + 1}`.padStart(2, "0");
const day = `${d.getDate()}`.padStart(2, "0");
return `${d.getFullYear()}-${m}-${day}`;
}
export interface CalendarProps extends Omit<ViewProps, "className"> {
/** Controlled selected date (ISO "yyyy-MM-dd" or Date). */
selected?: string | Date | null;
/** Uncontrolled initial selection. Ignored when `selected` is provided. */
defaultSelected?: string | Date | null;
onSelect?: (date: Date) => void;
/** ISO date keys that get a dot marker. */
markedDates?: string[];
/** Earliest selectable date (ISO "yyyy-MM-dd", inclusive). */
minDate?: string;
/** Latest selectable date (ISO "yyyy-MM-dd", inclusive). */
maxDate?: string;
/** Month initially shown when `selected` is unset. */
initialMonth?: Date;
className?: string;
}
/**
* Calendar — PRO. Month-view day grid built with plain Date math
* (no date-fns), prev/next arrows, selected highlight and dot markers.
* Expo Go compatible.
*/
export const Calendar = forwardRef<View, CalendarProps>(function Calendar(
{ selected, defaultSelected, onSelect, markedDates = [], minDate, maxDate, initialMonth, className, ...props },
ref
) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const prevPress = usePressAnimation({ pressedScale: 0.9 });
const nextPress = usePressAnimation({ pressedScale: 0.9 });
// Controlled (`selected`) or uncontrolled (`defaultSelected` + internal
// state) — resolved during render, never synced in a passive effect.
const [internal, setInternal] = useState<string | Date | null | undefined>(defaultSelected ?? null);
const active = selected !== undefined ? selected : internal;
const selectedDate = useMemo(() => {
if (!active) return null;
return typeof active === "string" ? new Date(`${active}T00:00:00`) : active;
}, [active]);
const handleSelect = (day: Date) => {
haptics.selection();
if (selected === undefined) setInternal(day);
onSelect?.(day);
};
const [cursor, setCursor] = useState(() => {
const base = selectedDate ?? initialMonth ?? new Date();
return new Date(base.getFullYear(), base.getMonth(), 1);
});
const marked = useMemo(() => new Set(markedDates), [markedDates]);
const todayKey = toKey(new Date());
const selectedKey = selectedDate ? toKey(selectedDate) : null;
// The outgoing month keeps rendering on its own layer while the new one
// slides in — staging old + new is what makes the slide a real crossfade
// instead of a fade applied to already-swapped content.
const [prevCursor, setPrevCursor] = useState<Date | null>(null);
const weeks = useMemo(() => buildWeeks(cursor.getFullYear(), cursor.getMonth()), [cursor]);
const prevWeeks = useMemo(
() => (prevCursor ? buildWeeks(prevCursor.getFullYear(), prevCursor.getMonth()) : null),
[prevCursor]
);
// Directional slide on month change — the exit layer travels in the
// direction of the arrow and the new month enters from the opposite edge
// (beui "content swap": keep travel short, let opacity do the lifting).
const exitOpacity = useSharedValue(1);
const exitX = useSharedValue(0);
const enterOpacity = useSharedValue(1);
const enterX = useSharedValue(0);
const enterStyle = useAnimatedStyle(() => ({
opacity: enterOpacity.value,
transform: [{ translateX: enterX.value }],
}));
const exitStyle = useAnimatedStyle(() => ({
opacity: exitOpacity.value,
transform: [{ translateX: exitX.value }],
}));
const shiftMonth = (delta: number) => {
haptics.selection();
const next = new Date(cursor.getFullYear(), cursor.getMonth() + delta, 1);
if (reduce) {
setPrevCursor(null);
setCursor(next);
return;
}
setPrevCursor(cursor);
setCursor(next);
// Exit fades on timing.instant, entrance on timing.fast — exits stay
// faster than entrances while both remain token-driven.
exitOpacity.value = 1;
exitX.value = 0;
exitOpacity.value = withTiming(0, timing.instant);
exitX.value = withTiming(-14 * delta, timing.instant, (done) => {
if (done) runOnJS(setPrevCursor)(null);
});
enterOpacity.value = 0;
enterX.value = 14 * delta;
enterOpacity.value = withTiming(1, timing.fast);
enterX.value = withSpring(0, spring.layout);
};
// ISO keys sort lexicographically, so bounds compare as plain strings.
const isDisabled = (key: string) =>
(minDate !== undefined && key < minDate) || (maxDate !== undefined && key > maxDate);
const renderWeeks = (rows: (Date | null)[][]) =>
rows.map((week, wi) => (
<View key={wi} className="w-full flex-row">
{week.map((day, di) => {
if (!day) return <View key={di} className="h-11 flex-1" />;
const key = toKey(day);
const isSelected = key === selectedKey;
const isToday = key === todayKey;
const disabled = isDisabled(key);
return (
<DayPressable
key={di}
onPress={() => handleSelect(day)}
disabled={disabled}
label={day.toDateString()}
selected={isSelected}
>
<DayCell selected={isSelected} today={isToday} disabled={disabled} label={day.getDate()} />
<View
className={cn(
"absolute bottom-1 h-1 w-1 rounded-full",
marked.has(key) ? (isSelected ? "bg-primary-foreground" : "bg-primary") : "bg-transparent"
)}
/>
</DayPressable>
);
})}
</View>
));
return (
<View
ref={ref}
className={cn("rounded-2xl bg-card p-5", className)}
accessibilityRole="none"
accessibilityLabel="Calendar"
{...props}
>
<View className="w-full flex-row items-center justify-between">
<AnimatedPressable
onPress={() => shiftMonth(-1)}
onPressIn={prevPress.onPressIn}
onPressOut={prevPress.onPressOut}
style={prevPress.animatedStyle}
className="h-11 w-11 items-center justify-center rounded-full active:bg-accent"
accessibilityRole="button"
accessibilityLabel="Previous month"
>
<Icon name="chevron-left" size="sm" color="foreground" />
</AnimatedPressable>
<Text className="text-base font-semibold leading-tight text-card-foreground" accessibilityRole="header">
{`${MONTHS[cursor.getMonth()]} ${cursor.getFullYear()}`}
</Text>
<AnimatedPressable
onPress={() => shiftMonth(1)}
onPressIn={nextPress.onPressIn}
onPressOut={nextPress.onPressOut}
style={nextPress.animatedStyle}
className="h-11 w-11 items-center justify-center rounded-full active:bg-accent"
accessibilityRole="button"
accessibilityLabel="Next month"
>
<Icon name="chevron-right" size="sm" color="foreground" />
</AnimatedPressable>
</View>
{/* Pure grouping row — role "none" so the week strip isn't announced
as a widget; each glyph carries its full day name instead. */}
<View className="mt-2 w-full flex-row" accessibilityRole="none">
{WEEKDAYS.map((d, i) => (
<View key={d} className="flex-1 items-center py-1">
<Text
className="text-xs font-medium text-muted-foreground"
accessibilityRole="text"
accessibilityLabel={WEEKDAY_NAMES[i]}
>
{d}
</Text>
</View>
))}
</View>
<View className="w-full">
<Animated.View style={enterStyle}>{renderWeeks(weeks)}</Animated.View>
{prevWeeks ? (
// Exit layer: floats over the entering grid, never takes taps, and
// is hidden from screen readers so stale days aren't announced.
<Animated.View
style={[{ position: "absolute", top: 0, left: 0, right: 0 }, exitStyle]}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
{renderWeeks(prevWeeks)}
</Animated.View>
) : null}
</View>
</View>
);
});
Calendar.displayName = "Calendar";
/** Day cell wrapper — owns a usePressAnimation so every day squashes on
* touch (0.92 reads tactile at 36pt), even before selection commits. */
function DayPressable({
onPress,
disabled,
label,
selected,
children,
}: {
onPress: () => void;
disabled: boolean;
label: string;
selected: boolean;
children: React.ReactNode;
}) {
const press = usePressAnimation({ pressedScale: 0.92, pressedOpacity: 0.75 });
return (
<AnimatedPressable
onPress={onPress}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
disabled={disabled}
style={press.animatedStyle}
className="h-11 flex-1 items-center justify-center"
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ selected, disabled }}
// RNW drops accessibilityState — mirror for the web day grid.
aria-selected={selected}
aria-disabled={disabled || undefined}
>
{children}
</AnimatedPressable>
);
}
function DayCell({ selected, today, disabled, label }: { selected: boolean; today: boolean; disabled: boolean; label: number }) {
const reduce = useReducedMotion();
const scale = useSharedValue(1);
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
React.useEffect(() => {
if (reduce || !selected) {
scale.value = 1;
return;
}
// Selection pop: a quick dip on timing.instant, then spring.press home.
// Exits (none here) would stay faster; entrance stays under 300ms.
scale.value = withSequence(withTiming(0.85, timing.instant), withSpring(1, spring.press));
}, [selected, scale, reduce]);
return (
<Animated.View
style={style}
className={cn(
"h-9 w-9 items-center justify-center rounded-full",
selected ? "bg-primary" : today ? "border border-border" : "bg-transparent"
)}
>
<Text
className={cn(
"text-sm",
selected ? "font-semibold text-primary-foreground" : "text-card-foreground",
// Today is never border-only: weight backs up the ring treatment.
!selected && today && "font-semibold",
disabled && "opacity-30"
)}
>
{label}
</Text>
</Animated.View>
);
}
| selected? | string | Date | null | Controlled selected date (ISO "yyyy-MM-dd" or Date). | |
| defaultSelected? | string | Date | null | Uncontrolled initial selection. Ignored when `selected` is provided. | |
| onSelect? | (date: Date) => void | ||
| markedDates? | string[] | ISO date keys that get a dot marker. | |
| minDate? | string | Earliest selectable date (ISO "yyyy-MM-dd", inclusive). | |
| maxDate? | string | Latest selectable date (ISO "yyyy-MM-dd", inclusive). | |
| initialMonth? | Date | Month initially shown when `selected` is unset. | |
| className? | string |
Month-view calendar built with dependency-free Date math: day grid, prev/next navigation, selected highlight, disabled-day bounds and marked-date dots
import { Calendar } from "@/components/ui/calendar/calendar";
export function Example() {
return (
<Calendar />
);
} import * as React from "react";
import { forwardRef, useMemo, useState } from "react";
import { Text, View, type ViewProps } from "react-native";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withSequence,
withSpring,
withTiming,
} from "react-native-reanimated";
import { cn, spring, timing, useHaptic, useReducedMotion, usePressAnimation, AnimatedPressable } from "@lumioui/core";
import { Icon } from "@lumioui/ui";
const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
/** Full names announced to screen readers — the two-letter glyphs aren't. */
const WEEKDAY_NAMES = [
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday",
];
const MONTHS = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
/** Weeks matrix starting Sunday; nulls pad out-of-month cells. */
function buildWeeks(year: number, month: number): (Date | null)[][] {
const firstDow = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells: (Date | null)[] = [];
for (let i = 0; i < firstDow; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(year, month, d));
while (cells.length % 7 !== 0) cells.push(null);
const rows: (Date | null)[][] = [];
for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7));
return rows;
}
function toKey(d: Date): string {
const m = `${d.getMonth() + 1}`.padStart(2, "0");
const day = `${d.getDate()}`.padStart(2, "0");
return `${d.getFullYear()}-${m}-${day}`;
}
export interface CalendarProps extends Omit<ViewProps, "className"> {
/** Controlled selected date (ISO "yyyy-MM-dd" or Date). */
selected?: string | Date | null;
/** Uncontrolled initial selection. Ignored when `selected` is provided. */
defaultSelected?: string | Date | null;
onSelect?: (date: Date) => void;
/** ISO date keys that get a dot marker. */
markedDates?: string[];
/** Earliest selectable date (ISO "yyyy-MM-dd", inclusive). */
minDate?: string;
/** Latest selectable date (ISO "yyyy-MM-dd", inclusive). */
maxDate?: string;
/** Month initially shown when `selected` is unset. */
initialMonth?: Date;
className?: string;
}
/**
* Calendar — PRO. Month-view day grid built with plain Date math
* (no date-fns), prev/next arrows, selected highlight and dot markers.
* Expo Go compatible.
*/
export const Calendar = forwardRef<View, CalendarProps>(function Calendar(
{ selected, defaultSelected, onSelect, markedDates = [], minDate, maxDate, initialMonth, className, ...props },
ref
) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const prevPress = usePressAnimation({ pressedScale: 0.9 });
const nextPress = usePressAnimation({ pressedScale: 0.9 });
// Controlled (`selected`) or uncontrolled (`defaultSelected` + internal
// state) — resolved during render, never synced in a passive effect.
const [internal, setInternal] = useState<string | Date | null | undefined>(defaultSelected ?? null);
const active = selected !== undefined ? selected : internal;
const selectedDate = useMemo(() => {
if (!active) return null;
return typeof active === "string" ? new Date(`${active}T00:00:00`) : active;
}, [active]);
const handleSelect = (day: Date) => {
haptics.selection();
if (selected === undefined) setInternal(day);
onSelect?.(day);
};
const [cursor, setCursor] = useState(() => {
const base = selectedDate ?? initialMonth ?? new Date();
return new Date(base.getFullYear(), base.getMonth(), 1);
});
const marked = useMemo(() => new Set(markedDates), [markedDates]);
const todayKey = toKey(new Date());
const selectedKey = selectedDate ? toKey(selectedDate) : null;
// The outgoing month keeps rendering on its own layer while the new one
// slides in — staging old + new is what makes the slide a real crossfade
// instead of a fade applied to already-swapped content.
const [prevCursor, setPrevCursor] = useState<Date | null>(null);
const weeks = useMemo(() => buildWeeks(cursor.getFullYear(), cursor.getMonth()), [cursor]);
const prevWeeks = useMemo(
() => (prevCursor ? buildWeeks(prevCursor.getFullYear(), prevCursor.getMonth()) : null),
[prevCursor]
);
// Directional slide on month change — the exit layer travels in the
// direction of the arrow and the new month enters from the opposite edge
// (beui "content swap": keep travel short, let opacity do the lifting).
const exitOpacity = useSharedValue(1);
const exitX = useSharedValue(0);
const enterOpacity = useSharedValue(1);
const enterX = useSharedValue(0);
const enterStyle = useAnimatedStyle(() => ({
opacity: enterOpacity.value,
transform: [{ translateX: enterX.value }],
}));
const exitStyle = useAnimatedStyle(() => ({
opacity: exitOpacity.value,
transform: [{ translateX: exitX.value }],
}));
const shiftMonth = (delta: number) => {
haptics.selection();
const next = new Date(cursor.getFullYear(), cursor.getMonth() + delta, 1);
if (reduce) {
setPrevCursor(null);
setCursor(next);
return;
}
setPrevCursor(cursor);
setCursor(next);
// Exit fades on timing.instant, entrance on timing.fast — exits stay
// faster than entrances while both remain token-driven.
exitOpacity.value = 1;
exitX.value = 0;
exitOpacity.value = withTiming(0, timing.instant);
exitX.value = withTiming(-14 * delta, timing.instant, (done) => {
if (done) runOnJS(setPrevCursor)(null);
});
enterOpacity.value = 0;
enterX.value = 14 * delta;
enterOpacity.value = withTiming(1, timing.fast);
enterX.value = withSpring(0, spring.layout);
};
// ISO keys sort lexicographically, so bounds compare as plain strings.
const isDisabled = (key: string) =>
(minDate !== undefined && key < minDate) || (maxDate !== undefined && key > maxDate);
const renderWeeks = (rows: (Date | null)[][]) =>
rows.map((week, wi) => (
<View key={wi} className="w-full flex-row">
{week.map((day, di) => {
if (!day) return <View key={di} className="h-11 flex-1" />;
const key = toKey(day);
const isSelected = key === selectedKey;
const isToday = key === todayKey;
const disabled = isDisabled(key);
return (
<DayPressable
key={di}
onPress={() => handleSelect(day)}
disabled={disabled}
label={day.toDateString()}
selected={isSelected}
>
<DayCell selected={isSelected} today={isToday} disabled={disabled} label={day.getDate()} />
<View
className={cn(
"absolute bottom-1 h-1 w-1 rounded-full",
marked.has(key) ? (isSelected ? "bg-primary-foreground" : "bg-primary") : "bg-transparent"
)}
/>
</DayPressable>
);
})}
</View>
));
return (
<View
ref={ref}
className={cn("rounded-2xl bg-card p-5", className)}
accessibilityRole="none"
accessibilityLabel="Calendar"
{...props}
>
<View className="w-full flex-row items-center justify-between">
<AnimatedPressable
onPress={() => shiftMonth(-1)}
onPressIn={prevPress.onPressIn}
onPressOut={prevPress.onPressOut}
style={prevPress.animatedStyle}
className="h-11 w-11 items-center justify-center rounded-full active:bg-accent"
accessibilityRole="button"
accessibilityLabel="Previous month"
>
<Icon name="chevron-left" size="sm" color="foreground" />
</AnimatedPressable>
<Text className="text-base font-semibold leading-tight text-card-foreground" accessibilityRole="header">
{`${MONTHS[cursor.getMonth()]} ${cursor.getFullYear()}`}
</Text>
<AnimatedPressable
onPress={() => shiftMonth(1)}
onPressIn={nextPress.onPressIn}
onPressOut={nextPress.onPressOut}
style={nextPress.animatedStyle}
className="h-11 w-11 items-center justify-center rounded-full active:bg-accent"
accessibilityRole="button"
accessibilityLabel="Next month"
>
<Icon name="chevron-right" size="sm" color="foreground" />
</AnimatedPressable>
</View>
{/* Pure grouping row — role "none" so the week strip isn't announced
as a widget; each glyph carries its full day name instead. */}
<View className="mt-2 w-full flex-row" accessibilityRole="none">
{WEEKDAYS.map((d, i) => (
<View key={d} className="flex-1 items-center py-1">
<Text
className="text-xs font-medium text-muted-foreground"
accessibilityRole="text"
accessibilityLabel={WEEKDAY_NAMES[i]}
>
{d}
</Text>
</View>
))}
</View>
<View className="w-full">
<Animated.View style={enterStyle}>{renderWeeks(weeks)}</Animated.View>
{prevWeeks ? (
// Exit layer: floats over the entering grid, never takes taps, and
// is hidden from screen readers so stale days aren't announced.
<Animated.View
style={[{ position: "absolute", top: 0, left: 0, right: 0 }, exitStyle]}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
{renderWeeks(prevWeeks)}
</Animated.View>
) : null}
</View>
</View>
);
});
Calendar.displayName = "Calendar";
/** Day cell wrapper — owns a usePressAnimation so every day squashes on
* touch (0.92 reads tactile at 36pt), even before selection commits. */
function DayPressable({
onPress,
disabled,
label,
selected,
children,
}: {
onPress: () => void;
disabled: boolean;
label: string;
selected: boolean;
children: React.ReactNode;
}) {
const press = usePressAnimation({ pressedScale: 0.92, pressedOpacity: 0.75 });
return (
<AnimatedPressable
onPress={onPress}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
disabled={disabled}
style={press.animatedStyle}
className="h-11 flex-1 items-center justify-center"
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ selected, disabled }}
// RNW drops accessibilityState — mirror for the web day grid.
aria-selected={selected}
aria-disabled={disabled || undefined}
>
{children}
</AnimatedPressable>
);
}
function DayCell({ selected, today, disabled, label }: { selected: boolean; today: boolean; disabled: boolean; label: number }) {
const reduce = useReducedMotion();
const scale = useSharedValue(1);
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
React.useEffect(() => {
if (reduce || !selected) {
scale.value = 1;
return;
}
// Selection pop: a quick dip on timing.instant, then spring.press home.
// Exits (none here) would stay faster; entrance stays under 300ms.
scale.value = withSequence(withTiming(0.85, timing.instant), withSpring(1, spring.press));
}, [selected, scale, reduce]);
return (
<Animated.View
style={style}
className={cn(
"h-9 w-9 items-center justify-center rounded-full",
selected ? "bg-primary" : today ? "border border-border" : "bg-transparent"
)}
>
<Text
className={cn(
"text-sm",
selected ? "font-semibold text-primary-foreground" : "text-card-foreground",
// Today is never border-only: weight backs up the ring treatment.
!selected && today && "font-semibold",
disabled && "opacity-30"
)}
>
{label}
</Text>
</Animated.View>
);
}