CappyUI LogoCappyUI

Calendar with Date Range

A beautiful calendar component that highlights a range of dates with smart styling.

Preview

Generate with AI

Want to create this component using AI? Copy the prompt below and paste it into any LLM (ChatGPT, Claude, etc.):

Installation

npx shadcn@latest add https://uiregistry.cappychat.com/registry/calendar-range.json

Install dependencies

npm i framer-motion lucide-react clsx tailwind-merge

Add util file

lib/utils.ts

import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Copy the source code

components/ui/calendar-range.tsx

src/components/components/calenderRange.tsx
"use client";

import React, { useState } from "react";
import { motion } from "framer-motion";
import { ChevronLeft, ChevronRight, Check } from "lucide-react";
import { cn } from "@/lib/utils";

const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const MONTHS = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
];

interface CalendarRangeProps {
  className?: string;
  daysBeforeCurrent?: number;
  daysAfterCurrent?: number;
}

interface DayCell {
  day: number;
  month: number;
  year: number;
  isCurrentMonth: boolean;
}

export const CalendarRange = ({
  className,
  daysBeforeCurrent = 2,
  daysAfterCurrent = 2,
}: CalendarRangeProps) => {
  const today = new Date();
  const [currentMonth, setCurrentMonth] = useState(today.getMonth());
  const [currentYear, setCurrentYear] = useState(today.getFullYear());

  // Calculate range dates
  const rangeStart = new Date(today);
  rangeStart.setDate(today.getDate() - daysBeforeCurrent);

  const rangeEnd = new Date(today);
  rangeEnd.setDate(today.getDate() + daysAfterCurrent);

  const getDaysInMonth = (month: number, year: number) => {
    return new Date(year, month + 1, 0).getDate();
  };

  const getFirstDayOfMonth = (month: number, year: number) => {
    return new Date(year, month, 1).getDay();
  };

  const isDateInRange = (day: number, month: number, year: number) => {
    const date = new Date(year, month, day);
    date.setHours(0, 0, 0, 0);

    const start = new Date(rangeStart);
    start.setHours(0, 0, 0, 0);

    const end = new Date(rangeEnd);
    end.setHours(0, 0, 0, 0);

    return date >= start && date <= end;
  };

  const isToday = (day: number, month: number, year: number) => {
    return (
      day === today.getDate() &&
      month === today.getMonth() &&
      year === today.getFullYear()
    );
  };

  const isRangeStart = (day: number, month: number, year: number) => {
    const date = new Date(year, month, day);
    date.setHours(0, 0, 0, 0);
    const start = new Date(rangeStart);
    start.setHours(0, 0, 0, 0);
    return date.getTime() === start.getTime();
  };

  const isRangeEnd = (day: number, month: number, year: number) => {
    const date = new Date(year, month, day);
    date.setHours(0, 0, 0, 0);
    const end = new Date(rangeEnd);
    end.setHours(0, 0, 0, 0);
    return date.getTime() === end.getTime();
  };

  const handlePrevMonth = () => {
    if (currentMonth === 0) {
      setCurrentMonth(11);
      setCurrentYear(currentYear - 1);
    } else {
      setCurrentMonth(currentMonth - 1);
    }
  };

  const handleNextMonth = () => {
    if (currentMonth === 11) {
      setCurrentMonth(0);
      setCurrentYear(currentYear + 1);
    } else {
      setCurrentMonth(currentMonth + 1);
    }
  };

  const daysInMonth = getDaysInMonth(currentMonth, currentYear);
  const firstDay = getFirstDayOfMonth(currentMonth, currentYear);

  // Get previous month info
  const prevMonth = currentMonth === 0 ? 11 : currentMonth - 1;
  const prevMonthYear = currentMonth === 0 ? currentYear - 1 : currentYear;
  const daysInPrevMonth = getDaysInMonth(prevMonth, prevMonthYear);

  // Get next month info
  const nextMonth = currentMonth === 11 ? 0 : currentMonth + 1;
  const nextMonthYear = currentMonth === 11 ? currentYear + 1 : currentYear;

  const days: DayCell[] = [];

  // Days from previous month
  for (let i = firstDay - 1; i >= 0; i--) {
    days.push({
      day: daysInPrevMonth - i,
      month: prevMonth,
      year: prevMonthYear,
      isCurrentMonth: false,
    });
  }

  // Days of current month
  for (let i = 1; i <= daysInMonth; i++) {
    days.push({
      day: i,
      month: currentMonth,
      year: currentYear,
      isCurrentMonth: true,
    });
  }

  // Days from next month to fill the grid
  const remainingCells = 42 - days.length; // 6 rows * 7 days
  for (let i = 1; i <= remainingCells; i++) {
    days.push({
      day: i,
      month: nextMonth,
      year: nextMonthYear,
      isCurrentMonth: false,
    });
  }

  return (
    <div
      className={cn(
        "mx-auto w-full max-w-md rounded-3xl border border-fd-border bg-fd-background p-4 md:p-8 shadow-lg",
        className
      )}
    >
      {/* Header with navigation */}
      <div className="mb-8 flex items-center justify-between">
        <motion.button
          whileHover={{ scale: 1.1 }}
          whileTap={{ scale: 0.95 }}
          onClick={handlePrevMonth}
          className="flex h-10 w-10 items-center justify-center rounded-lg text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-foreground"
          aria-label="Previous month"
        >
          <ChevronLeft className="h-6 w-6" />
        </motion.button>

        <motion.span
          key={`${currentMonth}-${currentYear}`}
          initial={{ opacity: 0, y: -10 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.3 }}
          className="text-xl font-bold tracking-wider text-fd-foreground"
        >
          {MONTHS[currentMonth]} {currentYear}
        </motion.span>

        <motion.button
          whileHover={{ scale: 1.1 }}
          whileTap={{ scale: 0.95 }}
          onClick={handleNextMonth}
          className="flex h-10 w-10 items-center justify-center rounded-lg text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-foreground"
          aria-label="Next month"
        >
          <ChevronRight className="h-6 w-6" />
        </motion.button>
      </div>

      {/* Day headers */}
      <div className="mb-4 grid grid-cols-7 gap-1.5 md:gap-2">
        {DAYS.map((day, index) => (
          <div
            key={index}
            className="flex h-10 items-center justify-center text-sm font-medium text-fd-muted-foreground"
          >
            {day}
          </div>
        ))}
      </div>

      {/* Calendar grid */}
      <motion.div
        key={`${currentMonth}-${currentYear}-grid`}
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        transition={{ duration: 0.3 }}
        className="grid grid-cols-7 gap-1.5 md:gap-2"
      >
        {days.map((dayCell, index) => {
          const inRange = isDateInRange(
            dayCell.day,
            dayCell.month,
            dayCell.year
          );
          const isTodayDate = isToday(dayCell.day, dayCell.month, dayCell.year);
          const isStart = isRangeStart(
            dayCell.day,
            dayCell.month,
            dayCell.year
          );
          const isEnd = isRangeEnd(dayCell.day, dayCell.month, dayCell.year);
          const isMiddle = inRange && !isStart && !isEnd && !isTodayDate;

          return (
            <motion.div
              key={index}
              initial={{ scale: 0.8, opacity: 0 }}
              animate={{ scale: 1, opacity: 1 }}
              transition={{ delay: index * 0.01, duration: 0.2 }}
              className={cn(
                "relative flex h-10 md:h-12 items-center justify-center rounded-lg text-sm md:text-lg font-medium transition-all",
                !dayCell.isCurrentMonth && "text-fd-muted-foreground/40",
                dayCell.isCurrentMonth &&
                  !inRange &&
                  "text-fd-foreground hover:bg-fd-accent",
                isMiddle && "bg-fd-accent/50 text-fd-foreground",
                (isStart || isEnd) &&
                  "bg-fd-primary/90 text-fd-primary-foreground shadow-md",
                isTodayDate &&
                  "bg-fd-accent/50 text-fd-primary-foreground shadow-md"
              )}
            >
              {dayCell.day}
              {isTodayDate && (
                <motion.div
                  initial={{ scale: 0, opacity: 0 }}
                  animate={{ scale: 1, opacity: 1 }}
                  transition={{ delay: 0.3, duration: 0.3 }}
                  className="absolute inset-0 flex items-center justify-center rounded-lg  "
                >
                  <Check className="h-6 w-6 text-fd-foreground" />
                </motion.div>
              )}
            </motion.div>
          );
        })}
      </motion.div>
    </div>
  );
};

export default CalendarRange;

Usage

import { CalendarRange } from "@/components/ui/calendar-range";

export default function MyComponent() {
  return <CalendarRange daysBeforeCurrent={2} daysAfterCurrent={2} />;
}

Props

PropTypeDefaultDescription
classNamestringundefinedAdditional CSS classes
daysBeforeCurrentnumber2Number of days before current date to highlight
daysAfterCurrentnumber2Number of days after current date to highlight