Kipachu
Components

Calendar

Animated month grid with expandable day cards.

Overview

The calendar components provide an animated month grid with a “pop” expanded day card. The main entry point is CalendarBody, which renders the 6x7 grid and manages selection/expansion internally.

Installation

Just copy and paste

Dependencies used by the calendar:

  • motion
  • tailwindcss (classes are hardcoded)

Usage

"use client";

import { useId, useMemo, useRef, useState } from "react";
import { CalendarBody } from "@/components/calendar/calendar-body";
import { Button } from "@/components/ui/button";

const today = new Date();

export function CalendarDemo() {
  const [month, setMonth] = useState(
    new Date(today.getFullYear(), today.getMonth(), 1),
  );

  const prevMonth = () =>
    setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1));

  const nextMonth = () =>
    setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1));

  return (
    <div className="space-y-3">
      <div className="flex items-center justify-between">
        <Button variant="secondary" size="sm" onClick={prevMonth}>
          Prev
        </Button>
        <div className="text-sm font-medium">
          {month.toLocaleString("en-US", {
            month: "long",
            year: "numeric",
          })}
        </div>
        <Button variant="secondary" size="sm" onClick={nextMonth}>
          Next
        </Button>
      </div>
      <CalendarBody month={month} today={today} />
    </div>
  );
}

Helper

CalendarBody uses toISODateUTC internally to derive stable IDs across time zones. If you want to align custom logic with the internal IDs or build related utilities, here’s the helper used in this codebase:

export function toISODateUTC(value: Date) {
  return new Intl.DateTimeFormat("en-CA", {
    timeZone: "UTC",
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  }).format(new Date(value));
}

Demo

February 2026
Mon
Tue
Wed
Thu
Fri
Sat
Sun

Source

calendar-body.tsx

import { useState } from "react";
import { AnimatePresence, motion, stagger } from "motion/react";
import { cn, toISODateUTC } from "@/lib/utils";
import { CalendarItem } from "./calendar-item";
import { CalendarExpandedItem } from "./calendar-expanded-item";

type TCalendarBodyProps = {
  month: Date;
  today: Date;
} & React.HTMLAttributes<HTMLDivElement>;
const DAYS_OF_WEEK = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];

export const CalendarBody: React.FC<TCalendarBodyProps> = ({
  month,
  className,
  today
}) => {
  const instanceId = useId();
  const parentRef = useRef<HTMLDivElement | null>(null);
  const [selectedDate, setSelectedDate] = useState<null | Date>(null);
  const [expandedStyle, setExpandedStyle] = useState({
    x: 0,
    y: 0,
    width: 0,
    height: 0,
  });
  const year = month.getFullYear();
  const monthIndex = month.getMonth();

  const firstDayOfWeek = 1; // Monday

  const calendarDays = useMemo(() => {
    const firstDay = new Date(year, monthIndex, 1);
    const startDate = new Date(firstDay);
    const dayOfWeek = startDate.getDay(); // 0=Sun, 1=Mon
    const offset = (dayOfWeek - firstDayOfWeek + 7) % 7;
    startDate.setDate(startDate.getDate() - offset);

    const days: Date[] = [];
    for (let i = 0; i < 42; i++) {
      const date = new Date(startDate);
      date.setDate(startDate.getDate() + i);
      days.push(date);
    }
    return days;
  }, [year, monthIndex, firstDayOfWeek]);

  const getItemId = (date: Date) =>
    `${instanceId}-${toISODateUTC(date)}-${monthIndex}`;

  const openExpandedCard = (date: Date) => {
    const parent = parentRef.current;
    if (!parent) return;

    const element = parent.querySelector(
      `[data-cal-id="${getItemId(date)}"]`,
    ) as HTMLDivElement | null;
    if (!element) return;

    const elementRect = element.getBoundingClientRect();
    const parentRect = parent.getBoundingClientRect();

    const newWidth = Math.min(elementRect.width * 3, 250);
    const newHeight = Math.min(elementRect.height * 2, 200);

    // Center of the element relative to parent
    const centerX = elementRect.left - parentRect.left + elementRect.width / 2;
    const centerY = elementRect.top - parentRect.top + elementRect.height / 2;

    // Position so the expanded element is centered
    let newX = centerX - newWidth / 2;
    let newY = centerY - newHeight / 2;

    // Clamp within parent bounds
    newX = Math.max(0, Math.min(newX, parentRect.width - newWidth));
    newY = Math.max(0, Math.min(newY, parentRect.height - newHeight));

    setExpandedStyle({
      x: newX,
      y: newY,
      width: newWidth,
      height: newHeight,
    });

    setSelectedDate(date);
  };

  const selectedId = selectedDate ? getItemId(selectedDate) : null;

  return (
    <AnimatePresence mode="popLayout">
      <motion.div
        ref={parentRef}
        initial="hidden"
        animate="visible"
        exit="hidden"
        key={`${year}-${monthIndex}`}
        onAnimationStart={() => {
          setSelectedDate(null);
        }}
        variants={{
          hidden: {
            transition: {
              delayChildren: stagger(0.01),
            },
          },
          visible: {
            transition: {
              delayChildren: stagger(0.02),
            },
          },
        }}
        className={cn("grid grid-cols-7 relative gap-1", className)}
      >
        {DAYS_OF_WEEK.map((day) => (
          <div key={day} className="text-center text-sm p-2">
            {day}
          </div>
        ))}
        <AnimatePresence>
          {calendarDays.map((date) => {
            const isCurrentMonth = date.getMonth() === monthIndex;
            const id = getItemId(date);
            const isToday = date.toDateString() === today.toDateString();
            return (
              <CalendarItem
                disabled={!isCurrentMonth}
                key={id}
                id={id}
                isToday={isToday}
                date={date}
                onClick={() => {
                  openExpandedCard(date);
                }}
              />
            );
          })}
        </AnimatePresence>
        <AnimatePresence propagate mode="popLayout">
          {selectedDate && selectedId && (
            <CalendarExpandedItem
              onClick={() => {
                setSelectedDate(null);
              }}
              style={expandedStyle}
              id={selectedId}
            />
          )}
        </AnimatePresence>
      </motion.div>
    </AnimatePresence>
  );
};

calendar-item.tsx

import { cn } from "@/lib/utils";
import { motion } from "motion/react";

export type TCalendarItemProps = {
  disabled?: boolean;
  date: Date;
  onClick?: () => void;
  id: string;
  className?: string;
  isToday?: boolean;
  children?: React.ReactNode;
};

export const CalendarItem: React.FC<TCalendarItemProps> = ({
  disabled,
  date,
  onClick,
  id,
  className,
  isToday,
  children,
}) => {
  return (
    <motion.button
      type="button"
      data-cal-id={id}
      disabled={disabled}
      variants={{
        hidden: {
          opacity: 0,
          scale: 0.8,
        },
        visible: {
          scale: 1,
          opacity: 1,
        },
      }}
      transition={{
        type: "spring",
      }}
      layoutId={id}
      className={cn(
        "text-center relative border text-sm h-15 rounded-md",
        !disabled
          ? "bg-muted/80"
          : "text-muted-foreground pointer-events-none border-border/50 bg-muted/30",
        isToday &&
          "dark:border-green-400/50 dark:bg-green-700/40 border-green-400 bg-green-300/70",
        className,
      )}
      onClick={() => {
        if (disabled) return;
        onClick?.();
      }}
    >
      <div className="flex p-1 flex-col items-center h-full">
        <span className="flex flex-none h-fit">{date.getDate()}</span>
        {children}
      </div>
    </motion.button>
  );
};

calendar-expanded-item.tsx

import { motion } from "motion/react";

type TCalendarExpandedItemProps = {
  id: string;
  style: {
    x: number;
    y: number;
    width: number;
    height: number;
  };
  onClick?: () => void;
  children?: React.ReactNode;
};

export const CalendarExpandedItem: React.FC<TCalendarExpandedItemProps> = ({
  id,
  style,
  onClick,
  children,
}) => {
  return (
    <motion.div
      key={id}
      layoutId={id}
      className="absolute bg-background/60 shadow-xl overflow-auto py-2 px-1 gap-1 flex flex-col no-scrollbar backdrop-blur-xs rounded-2xl border-3 border-border/60"
      style={{
        left: style.x,
        top: style.y,
        width: style.width,
        height: style.height,
      }}
      exit={{
        opacity: 0,
        scale: 0.8,
      }}
      initial={{
        opacity: 0,
        scale: 0.8,
      }}
      animate={{
        opacity: 1,
        scale: 1,
      }}
      transition={{
        type: "spring",
        stiffness: 842,
        damping: 80,
        mass: 4,
      }}
      onClick={onClick}
    >
      {children}
    </motion.div>
  );
};

Notes

  • The expanded card is positioned relative to the calendar grid, so it expects the grid to be visible and laid out.

API Reference

CalendarBody

PropTypeDescription
monthDateMonth to render. Only the year/month are used.
todayDateUsed to highlight the current day.
classNamestringOptional class names for the grid wrapper.

CalendarItem

PropTypeDescription
dateDateDate rendered by the cell.
idstringStable layout ID. Must be unique within the grid.
disabledbooleanDisables interaction and styles out-of-month days.
isTodaybooleanHighlights the current day.
onClick() => voidFired when the item is clicked (unless disabled).
classNamestringOptional class names for the item.
childrenReact.ReactNodeOptional content inside the cell.

CalendarExpandedItem

PropTypeDescription
idstringMust match the CalendarItem layoutId for animations.
style{ x: number; y: number; width: number; height: number }Position/size for the expanded card.
onClick() => voidFired when the expanded card is clicked.
childrenReact.ReactNodeContent for the expanded card.