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:
motiontailwindcss(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
| Prop | Type | Description |
|---|---|---|
month | Date | Month to render. Only the year/month are used. |
today | Date | Used to highlight the current day. |
className | string | Optional class names for the grid wrapper. |
CalendarItem
| Prop | Type | Description |
|---|---|---|
date | Date | Date rendered by the cell. |
id | string | Stable layout ID. Must be unique within the grid. |
disabled | boolean | Disables interaction and styles out-of-month days. |
isToday | boolean | Highlights the current day. |
onClick | () => void | Fired when the item is clicked (unless disabled). |
className | string | Optional class names for the item. |
children | React.ReactNode | Optional content inside the cell. |
CalendarExpandedItem
| Prop | Type | Description |
|---|---|---|
id | string | Must match the CalendarItem layoutId for animations. |
style | { x: number; y: number; width: number; height: number } | Position/size for the expanded card. |
onClick | () => void | Fired when the expanded card is clicked. |
children | React.ReactNode | Content for the expanded card. |