Month calendar
A month grid. Hover (or arrow to) a day and that cell grows into a card with its events; the card slides to the next day you reach.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"use client";
// MonthCalendar: a month grid. Hover (or arrow to) a day and that cell grows into a card with its events; the card slides to the
// next day you reach. Paging months slides the grid and the title. Six rows are always reserved, so the height never changes.
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
export type MonthCalendarCue = "next" | "back" | "pip" | "choose";
export type CalEvent = {
/** Event name. */
title: string;
/** Time shown before the title, e.g. "09:30". */
time?: string;
};
export type MonthCalendarProps = {
/** Events keyed by ISO date (YYYY-MM-DD). Defaults to a few October 2026 demo events. */
events?: Record<string, CalEvent[]>;
/** Any date inside the month to show first. Defaults to October 2026 for the demo events, otherwise today. */
month?: Date;
/** Called with the date when a day (or its card) is pressed. */
onPick?: (date: Date) => void;
/** Cell size in px. */
cell?: number;
/** Sound hook: "next" / "back" when paging months, "pip" per day reached by keyboard (with `at`), "choose" on pick. */
onSound?: (cue: MonthCalendarCue, opts?: { at?: number }) => void;
className?: string;
style?: CSSProperties;
};
const DEFAULT_EVENTS: Record<string, CalEvent[]> = {
"2026-10-03": [{ title: "Design review", time: "10:00" }],
"2026-10-08": [{ title: "Sprint planning", time: "09:30" }, { title: "Lunch with Ravi", time: "12:30" }],
"2026-10-14": [{ title: "Ship v2.1" }],
"2026-10-21": [{ title: "1:1 Isla", time: "15:00" }, { title: "Retro", time: "16:30" }, { title: "Dinner", time: "19:00" }],
"2026-10-28": [{ title: "Board deck due" }],
"2026-11-04": [{ title: "Offsite" }],
};
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const WEEKDAY_INITIALS = ["M", "T", "W", "T", "F", "S", "S"];
const WEEK_LENGTH = 7;
const GRID_ROWS = 6; // always six rows, so paging between 5- and 6-row months never changes the height
const MAX_CARD_EVENTS = 3;
const SNAP = { type: "spring", stiffness: 380, damping: 34, mass: 0.8 } as const;
const cn = (...classes: (string | false | null | undefined)[]) => classes.filter(Boolean).join(" ");
const pad2 = (n: number) => String(n).padStart(2, "0");
const isoDate = (date: Date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const ARROW_STEPS: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -WEEK_LENGTH, ArrowDown: WEEK_LENGTH };
export function MonthCalendar({ events = DEFAULT_EVENTS, month = events === DEFAULT_EVENTS ? new Date(2026, 9, 1) : new Date(), onPick, cell = 34, onSound, className, style }: MonthCalendarProps) {
const [view, setView] = useState(() => new Date(month.getFullYear(), month.getMonth(), 1));
const [direction, setDirection] = useState(1);
const [hotDay, setHotDay] = useState<number | null>(null); // zero-based day of the month under the pointer or focus
const grid = useRef<HTMLDivElement>(null);
const year = view.getFullYear();
const monthIndex = view.getMonth();
const dayCount = new Date(year, monthIndex + 1, 0).getDate();
const leadingBlanks = (view.getDay() + 6) % WEEK_LENGTH; // weeks start on Monday
const gridWidth = cell * WEEK_LENGTH;
const gridHeight = cell * GRID_ROWS;
const today = isoDate(new Date());
const pageMonth = (step: number) => {
onSound?.(step > 0 ? "next" : "back");
setDirection(step);
setHotDay(null);
setView((current) => new Date(current.getFullYear(), current.getMonth() + step, 1));
};
// The hover card grows out of the hot cell and is kept inside the grid.
const hotDate = hotDay === null ? null : new Date(year, monthIndex, hotDay + 1);
const hotEvents = hotDate ? (events[isoDate(hotDate)] ?? []) : [];
const cardWidth = Math.min(gridWidth, cell * 4.4);
const cardHeight = 50 + clamp(hotEvents.length, 1, MAX_CARD_EVENTS) * 18;
const hotSlot = hotDay === null ? 0 : hotDay + leadingBlanks;
const hotColumn = hotSlot % WEEK_LENGTH;
const hotRow = Math.floor(hotSlot / WEEK_LENGTH);
const cardLeft = Math.min(hotColumn * cell, gridWidth - cardWidth);
const cardTop = Math.min(hotRow * cell, gridHeight - cardHeight);
const aimAt = (day: number | null) => {
if (day === hotDay) return;
setHotDay(day);
};
// keyboard focus follows the hot day, so arrow keys and hover share one cursor
useEffect(() => {
if (hotDay === null) return;
grid.current?.querySelectorAll<HTMLElement>(".tmc-day")[hotDay]?.focus({ preventScroll: true });
}, [hotDay]);
const onGridKeyDown = (e: KeyboardEvent) => {
const from = hotDay ?? 0;
let next: number | null | undefined;
if (e.key in ARROW_STEPS) next = clamp(from + ARROW_STEPS[e.key], 0, dayCount - 1);
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = dayCount - 1;
else if (e.key === "Escape") next = null;
if (next === undefined) return;
if (next !== null) (e.preventDefault(), next !== hotDay && onSound?.("pip", { at: next / dayCount }));
aimAt(next);
};
const pick = (date: Date) => {
onSound?.("choose");
onPick?.(date);
};
return (
<div className={cn("tmc-root", className)} style={{ width: gridWidth + 24, ...style }}>
<div className="tmc-head">
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
<motion.span key={view.getTime()} className="tmc-title" initial={{ opacity: 0, x: direction * 10, filter: "blur(3px)" }} animate={{ opacity: 1, x: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } }} exit={{ opacity: 0, x: direction * -10, filter: "blur(3px)" }} transition={{ ...SNAP, opacity: { duration: 0.16 } }}>
{MONTHS[monthIndex]} {year}
</motion.span>
</AnimatePresence>
<span className="tmc-nav">
<button type="button" aria-label="Previous month" onClick={() => pageMonth(-1)}>
<ChevronLeft size={16} strokeWidth={2} />
</button>
<button type="button" aria-label="Next month" onClick={() => pageMonth(1)}>
<ChevronRight size={16} strokeWidth={2} />
</button>
</span>
</div>
<div className="tmc-dow" style={{ width: gridWidth }}>
{WEEKDAY_INITIALS.map((d, i) => (
<span key={i} style={{ width: cell }}>
{d}
</span>
))}
</div>
<div
ref={grid}
className="tmc-grid"
role="group"
aria-label={`${MONTHS[monthIndex]} ${year}`}
style={{ width: gridWidth, height: gridHeight }}
onPointerOut={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && aimAt(null)}
onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && aimAt(null)}
onKeyDown={onGridKeyDown}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div key={view.getTime()} className="tmc-cells" initial={{ opacity: 0, x: direction * 16, filter: "blur(4px)" }} animate={{ opacity: 1, x: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } }} exit={{ opacity: 0, x: direction * -16, filter: "blur(4px)", transition: { duration: 0.16 } }} transition={{ ...SNAP, opacity: { duration: 0.2 } }}>
{Array.from({ length: leadingBlanks }, (_, i) => (
<span key={`b${i}`} style={{ width: cell, height: cell }} />
))}
{Array.from({ length: dayCount }, (_, i) => {
const date = new Date(year, monthIndex, i + 1);
const dayEvents = events[isoDate(date)];
return (
<button key={i} type="button" className="tmc-day" tabIndex={hotDay === i || (hotDay === null && i === 0) ? 0 : -1} data-today={isoDate(date) === today || undefined} data-hide={hotDay === i || undefined} aria-label={`${MONTHS[monthIndex]} ${i + 1}${dayEvents ? `, ${dayEvents.length} events` : ""}`} style={{ width: cell, height: cell }} onPointerEnter={() => aimAt(i)} onFocus={() => aimAt(i)} onClick={() => pick(date)}>
<span>{i + 1}</span>
{dayEvents && <i className="tmc-dot" />}
</button>
);
})}
</motion.div>
</AnimatePresence>
<AnimatePresence>
{hotDate && (
<motion.div
key="card"
className="tmc-card"
aria-hidden
initial={{ left: hotColumn * cell, top: hotRow * cell, width: cell, height: cell, opacity: 0 }}
animate={{ left: cardLeft, top: cardTop, width: cardWidth, height: cardHeight, opacity: 1 }}
exit={{ width: cell, height: cell, left: hotColumn * cell, top: hotRow * cell, opacity: 0, transition: { duration: 0.16 } }}
transition={{ ...SNAP, opacity: { duration: 0.12 } }}
onClick={() => pick(hotDate)}
>
<div className="tmc-card-in">
<div className="tmc-card-head">
<span className="tmc-card-day">{hotDate.getDate()}</span>
<span className="tmc-card-dow">{WEEKDAYS[hotDate.getDay()]}</span>
</div>
<AnimatePresence mode="popLayout" initial={false}>
<motion.ul key={hotDay} className="tmc-ev" initial={{ opacity: 0, filter: "blur(3px)" }} animate={{ opacity: 1, filter: "blur(0px)", transitionEnd: { filter: "none" } }} exit={{ opacity: 0, filter: "blur(3px)", transition: { duration: 0.1 } }} transition={{ duration: 0.18, delay: 0.06 }}>
{hotEvents.length === 0 ? <li className="tmc-none">Nothing on</li> : hotEvents.slice(0, MAX_CARD_EVENTS).map((event, j) => (
<li key={j}>
{event.time && <b>{event.time}</b>}
{event.title}
</li>
))}
</motion.ul>
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
/* MonthCalendar. Prefix tmc-. */
.tmc-root, .tmc-root *, .tmc-root *::before, .tmc-root *::after { box-sizing: border-box; }
.tmc-root { font-family: var(--ts-font, system-ui, sans-serif); }
:where(.tmc-root) button { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; cursor: pointer; }
:where(.tmc-root) :focus-visible { outline: 2px solid var(--ts-focus, #3446eb); outline-offset: 2px; }
.tmc-root {
padding: 16px 14px 14px;
border-radius: var(--ts-r-panel);
background: var(--ts-raised);
color: var(--ts-fg);
box-shadow: var(--ts-edge);
user-select: none;
}
.tmc-head {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 4px 0 8px;
margin-bottom: 6px;
overflow: hidden;
}
.tmc-title {
font-size: 14px;
font-weight: 500;
letter-spacing: -0.01em;
}
.tmc-nav {
display: flex;
gap: 2px;
}
.tmc-nav button {
width: 28px;
height: 28px;
border-radius: 999px;
display: grid;
place-items: center;
opacity: 0.6;
transition: opacity 0.16s cubic-bezier(.22, 1, .36, 1), background 0.16s cubic-bezier(.22, 1, .36, 1), scale 0.22s cubic-bezier(.22, 1, .36, 1);
}
.tmc-nav button:hover {
opacity: 1;
background: rgba(var(--ts-fg-rgb), 0.06);
}
.tmc-nav button:active {
scale: 0.92;
}
.tmc-dow {
display: flex;
margin-bottom: 2px;
}
.tmc-dow span {
text-align: center;
font-size: 12px;
font-weight: 500;
opacity: 0.4;
}
.tmc-grid {
position: relative;
outline: 0;
}
.tmc-cells {
display: flex;
flex-wrap: wrap;
}
.tmc-day {
position: relative;
font-size: 13px;
font-weight: 500;
font-variant-numeric: tabular-nums;
display: grid;
place-items: center;
border-radius: 10px;
transition: opacity 0.16s cubic-bezier(.22, 1, .36, 1), scale 0.22s cubic-bezier(.22, 1, .36, 1);
}
.tmc-day:focus-visible {
box-shadow: 0 0 0 2px var(--ts-raised), 0 0 0 4px var(--ts-accent);
}
.tmc-day > span {
width: 26px;
height: 26px;
border-radius: 999px;
display: grid;
place-items: center;
}
.tmc-day[data-today] > span {
background: var(--ts-fg);
color: var(--ts-raised);
}
.tmc-day[data-hide] {
opacity: 0;
}
.tmc-day[data-hide]:focus-visible {
opacity: 1;
z-index: 3;
}
.tmc-dot {
position: absolute;
bottom: 4px;
left: 50%;
width: 3px;
height: 3px;
margin-left: -1.5px;
border-radius: 999px;
background: currentColor;
opacity: 0.6;
}
.tmc-card {
position: absolute;
z-index: 2;
border-radius: var(--ts-r-pop);
background: var(--ts-raised);
color: var(--ts-fg);
box-shadow: var(--ts-edge), 0 14px 30px -16px rgba(0, 0, 0, 0.35);
overflow: hidden;
cursor: pointer;
pointer-events: none;
}
.tmc-card-in {
padding: 10px 12px;
width: max-content;
}
.tmc-card-head {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 6px;
}
.tmc-card-day {
font-size: 22px;
font-weight: 500;
letter-spacing: -0.02em;
line-height: 1;
}
.tmc-card-dow {
font-size: 12px;
opacity: 0.5;
}
.tmc-ev {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
line-height: 14px;
}
.tmc-ev li {
display: flex;
gap: 6px;
white-space: nowrap;
}
.tmc-ev b {
font-weight: 500;
opacity: 0.5;
font-variant-numeric: tabular-nums;
}
.tmc-none {
opacity: 0.45;
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { MonthCalendar } from "./MonthCalendar";
import "./MonthCalendar.css";
export default function Demo() {
return <MonthCalendar />;
}