AniUI

Calendar

A month grid calendar with single date and range selection. Tap the header to quickly jump to any year and month.

Su
Mo
Tu
We
Th
Fr
Sa
Web preview — components render natively on iOS & Android
import { Calendar } from "@/components/ui/calendar";

export function MyScreen() {
  const [date, setDate] = useState<Date | undefined>();
  return (
    <Calendar
      selected={date}
      onSelect={setDate}
    />
  );
}

Installation#

npx @aniui/cli add calendar

Usage#

app/index.tsx
import { Calendar } from "@/components/ui/calendar";

export function MyScreen() {
  const [date, setDate] = useState<Date | undefined>();
  return (
    <Calendar
      selected={date}
      onSelect={setDate}
    />
  );
}

Year & Month Picker#

Tap the month/year header to open the year grid. Select a year, then a month to quickly navigate to any date.

Range Selection#

Su
Mo
Tu
We
Th
Fr
Sa

Select a check-in date for your hotel stay

Web preview — components render natively on iOS & Android
<Calendar
  rangeStart={start}
  rangeEnd={end}
  onRangeChange={(s, e) => {
    setStart(s);
    setEnd(e);
  }}
/>

Props#

PropTypeDefault
selected
Date
onSelect
(date: Date) => void
rangeStart
Date
rangeEnd
Date
onRangeChange
(start: Date, end: Date | undefined) => void
min
Date
max
Date
className
string

Accessibility#

  • Date selection with day/month/year navigation.
  • Each day cell is focusable with accessibilityLabel for the full date.
  • Navigation buttons for previous/next month are labeled for screen readers.

Source#

components/ui/calendar.tsx
import React, { useState } from "react";
import { View, Text, Pressable } from "react-native";
import { ChevronLeft, ChevronRight } from "lucide-react-native";
import { cn } from "@/lib/utils";

export interface CalendarProps {
  className?: string; selected?: Date; onSelect?: (date: Date) => void;
  rangeStart?: Date; rangeEnd?: Date; onRangeChange?: (start: Date, end: Date | undefined) => void;
  min?: Date; max?: Date;
}

const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const same = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
type Mode = "days" | "months" | "years";

export function Calendar({ className, selected, onSelect, rangeStart, rangeEnd, onRangeChange, min, max }: CalendarProps) {
  const [viewing, setViewing] = useState(() => selected ?? rangeStart ?? new Date());
  const [mode, setMode] = useState<Mode>("days");
  const year = viewing.getFullYear(), month = viewing.getMonth();

  const handlePress = (day: number) => {
    const date = new Date(year, month, day);
    if ((min && date < min) || (max && date > max)) return;
    if (onRangeChange) {
      if (!rangeStart || rangeEnd || date < rangeStart) onRangeChange(date, undefined);
      else onRangeChange(rangeStart, date);
      return;
    }
    onSelect?.(date);
  };
  const handleHeaderPress = () => setMode(mode === "days" ? "years" : "days");
  const pickYear = (y: number) => { setViewing(new Date(y, month, 1)); setMode("months"); };
  const pickMonth = (m: number) => { setViewing(new Date(year, m, 1)); setMode("days"); };
  const decadeStart = Math.floor(year / 12) * 12;
  const label = new Date(year, month).toLocaleString("default", { month: "long", year: "numeric" });

  return (
    <View className={cn("rounded-lg bg-background p-3", className)}>
      <View className="flex-row items-center justify-between mb-3">
        <Pressable onPress={() => mode === "days" ? setViewing(new Date(year, month - 1, 1)) : mode === "months" ? setViewing(new Date(year - 1, month, 1)) : setViewing(new Date(decadeStart - 12, month, 1))} className="h-12 w-12 items-center justify-center rounded-md" accessibilityRole="button" accessibilityLabel="Previous">
          <ChevronLeft size={18} color="#71717a" />
        </Pressable>
        <Pressable onPress={handleHeaderPress} accessibilityRole="button">
          <Text className="text-sm font-semibold text-foreground">{mode === "days" ? label : mode === "months" ? `${year}` : `${decadeStart} – ${decadeStart + 11}`}</Text>
        </Pressable>
        <Pressable onPress={() => mode === "days" ? setViewing(new Date(year, month + 1, 1)) : mode === "months" ? setViewing(new Date(year + 1, month, 1)) : setViewing(new Date(decadeStart + 12, month, 1))} className="h-12 w-12 items-center justify-center rounded-md" accessibilityRole="button" accessibilityLabel="Next">
          <ChevronRight size={18} color="#71717a" />
        </Pressable>
      </View>
      {mode === "years" && (
        <View className="flex-row flex-wrap">
          {Array.from({ length: 12 }, (_, i) => decadeStart + i).map((y) => (
            <View key={y} className="w-1/3 items-center p-1">
              <Pressable onPress={() => pickYear(y)} className={cn("h-12 w-full items-center justify-center rounded-md", y === year && "bg-primary")} accessibilityRole="button">
                <Text className={cn("text-sm", y === year ? "text-primary-foreground font-semibold" : "text-foreground")}>{y}</Text>
              </Pressable>
            </View>
          ))}
        </View>
      )}

      {/* Month grid */}
      {mode === "months" && (
        <View className="flex-row flex-wrap">
          {MONTHS.map((m, i) => (
            <View key={m} className="w-1/3 items-center p-1">
              <Pressable onPress={() => pickMonth(i)} className={cn("h-12 w-full items-center justify-center rounded-md", i === month && year === viewing.getFullYear() && "bg-primary")} accessibilityRole="button">
                <Text className={cn("text-sm", i === month && year === viewing.getFullYear() ? "text-primary-foreground font-semibold" : "text-foreground")}>{m}</Text>
              </Pressable>
            </View>
          ))}
        </View>
      )}

      {/* Days grid */}
      {mode === "days" && (
        <>
          <View className="flex-row mb-1">
            {DAYS.map((d) => <View key={d} className="flex-1 items-center py-1"><Text className="text-xs font-medium text-muted-foreground">{d}</Text></View>)}
          </View>
          <View className="flex-row flex-wrap">
            {(() => {
              const firstDay = new Date(year, month, 1).getDay();
              const daysInMonth = new Date(year, month + 1, 0).getDate();
              const cells: (number | null)[] = [...Array(firstDay).fill(null), ...Array.from({ length: daysInMonth }, (_, i) => i + 1)];
              return cells.map((day, i) => {
                if (day === null) return <View key={`e-${i}`} className="w-[14.28%] h-12" />;
                const date = new Date(year, month, day);
                const sel = selected && same(date, selected);
                const rs = rangeStart && same(date, rangeStart);
                const re = rangeEnd && same(date, rangeEnd);
                const inR = rangeStart && rangeEnd && date.getTime() >= rangeStart.getTime() && date.getTime() <= rangeEnd.getTime();
                const today = same(date, new Date());
                const off = (min && date < min) || (max && date > max);
                return (
                  <View key={day} className="w-[14.28%] items-center">
                    <Pressable onPress={() => handlePress(day)} disabled={!!off} className={cn("h-12 w-12 items-center justify-center rounded-full", sel || rs || re ? "bg-primary" : inR ? "bg-accent" : "", today && !sel && "border border-primary", off && "opacity-30")} accessibilityRole="button" accessibilityLabel={`${label} ${day}`}>
                      <Text className={cn("text-sm", sel || rs || re ? "text-primary-foreground font-semibold" : "text-foreground")}>{day}</Text>
                    </Pressable>
                  </View>
                );
              });
            })()}
          </View>
        </>
      )}
    </View>
  );
}