Teseria
All elements

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

Terminal
npm i framer-motion lucide-react

2. Files

MonthCalendar.tsx201 lines
"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.css162 lines
/* 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;
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { MonthCalendar } from "./MonthCalendar";
import "./MonthCalendar.css";

export default function Demo() {
  return <MonthCalendar />;
}