Display

Calendar Month

A month grid of events — multi-day bars packed into shared lanes, a "+N more" day peek, today and out-of-month cells distinguished, configurable week start and four data states.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, CalendarOff, ChevronLeft, ChevronRight } from "lucide-react"
import { Popover as PopoverPrimitive } from "radix-ui"

import { cn } from "@/lib/utils"
import type {
  CalendarEventTone,
  CalendarMonthData,
  CalendarMonthEvent,
} from "./calendar-month.contract"

/* -------------------------------------------------------------------------- *

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/calendar-month.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "CalendarMonth" component: a month grid
that lays events out across its cells. lucide-react for icons, zod for the
contract, Radix Popover for the day peek. No date library.

Contract
- A zod schema file is the single source of truth and the props are its
  z.infer, never a parallel interface. Event: { id, title, start, end,
  allDay?, tone?: "neutral"|"accent"|"primary"|"danger" }. View:
  { status: "loading"|"empty"|"error"|"ready", month: "YYYY-MM",
  events: Event[], today?: "YYYY-MM-DD" | null }.
- start / end are plain calendar strings — "YYYY-MM-DD" or
  "YYYY-MM-DDTHH:mm". No "Z", no offset: a month cell is a day on the
  reader's wall calendar, so the producer resolves the timezone once and hands
  over the day it landed on. The time half is display only (the "09:30" prefix
  and the tie-break between same-day events); bucketing uses the day half.
- end is INCLUSIVE. A 3–7 August workshop is start "2026-08-03", end
  "2026-08-07" and paints five cells. iCalendar and Google model all-day ends
  as exclusive — convert once in the producer instead of teaching every reader
  which convention today's payload uses.
- today is a prop, never Date.now(): a server render cannot know which day the
  visitor is living in, and guessing is how a calendar ships a hydration
  mismatch.
- Display props: weekStartsOn (0=Sunday…6, default 1), locale (default a FIXED
  "en-US" — pass your app's locale, never navigator.language), maxPerDay
  (chip lanes per day before "+N more", 1–8, default 3), fixedWeeks (always
  six rows, default true), onDayClick(date), onEventClick(event),
  onMonthChange(month), onRetry, emptyState, className. forwardRef to the
  root and spread the remaining native div props.

Behavior
- Grid maths. Parse "YYYY-MM" into year + month. lead = (weekday of the 1st −
  weekStartsOn + 7) % 7; the grid starts `lead` days before the 1st and runs
  fixedWeeks ? 42 : ceil((lead + daysInMonth) / 7) * 7 days. Every date is
  built with Date.UTC and read back with getUTC*, and the next day is + 86 400
  000 ms — exact, because UTC has no DST. Nothing reads the runtime's zone, so
  server and client markup are byte-identical.
- Layout, in four passes. (1) Normalise: read the day half of start/end, swap
  an inverted pair rather than dropping the event, convert both to a cell index
  and clip to the grid, remembering whether the event truly ran past either
  edge. (2) Order: longest span first, then earliest start, then all-day, then
  the clock, then id. Sorting by LENGTH before position is what pins passing
  bars to the top lanes — sort by position first and a Monday-only chip takes
  lane 0, pushing a Mon–Fri bar underneath it. (3) Slice: one segment per week
  row an event touches, because no box can wrap across two grid rows. (4) Lane:
  per row, drop each segment into the lowest lane whose columns are all free.
  A lane therefore means the same vertical line in all seven cells of that row,
  which is exactly why a five-day bar can pass over Wednesday without colliding
  with Wednesday's own chip.
- Multi-day geometry without pixel measuring. The week row is
  `grid-cols-7 gap-px` over a border-coloured backdrop (the 1px gaps ARE the
  grid lines, so cells carry no borders). A bar lives in the cell it starts in,
  absolutely positioned inside that cell's lane area, with
  width: calc(N * 100% + (N−1) * 1px − 2px) — N cells plus the N−1 hairlines
  between them. 100% resolves against exactly one cell, so the maths is closed;
  no ResizeObserver, no scroll syncing, nothing to clean up. Give the bar a
  z-index so it paints over the backgrounds of the cells it crosses.
- Overflow. Per row, visibleLanes = min(lanes used, maxPerDay); segments in
  higher lanes are hidden and counted per day they cover. The budget cuts WHOLE
  lanes, never half a bar: a bar is either drawn for its full run inside a row
  or it is counted into that row's "+N more". Each overflowing day gets a
  "+N more" button pinned to the bottom of the cell that opens a Popover
  listing ALL of that day's events (not just the hidden ones) with their times;
  wrap each row in Popover.Close so picking one dismisses the peek and Radix
  restores focus to the trigger.
- Degenerate data is reported, never swallowed. Events with an unreadable date,
  and events lying entirely outside the six visible weeks, are counted into a
  footer strip ("Not shown: 2 outside this view, 1 with an unreadable date").
  A day that does not exist ("2026-09-31") normalises the way the platform does
  (1 October) instead of throwing. A month key that cannot be parsed falls back
  to today's month, then to the first event's month, and only then renders an
  error card — it never invents a month from the clock. maxPerDay and
  weekStartsOn are clamped; an unsupported locale falls back to "en-US" instead
  of letting Intl throw the page down.
- Four first-class branches. loading → the real grid with pulsing bars instead
  of chips (aria-busy, no tab stops, deterministic placement — a random
  skeleton would differ between the server render and the first paint); error →
  a card with a message and a "Try again" button rendered only when onRetry is
  passed; empty → the REAL month (weekday header, all cells, the today ring)
  with a replaceable banner, because an empty August is still August; ready →
  the full grid.
- ARIA. role="grid" labelled by the caption, a role="row" of
  role="columnheader" weekdays (short text visible, full weekday name sr-only),
  then one role="gridcell" per day whose aria-label is the whole date plus its
  event count ("Tuesday, 4 August 2026, 3 events"). Today carries
  aria-current="date". The caption is aria-live="polite" so paging announces
  the new month rather than silently replacing 42 cells. Chips and the "+N
  more" button live inside their gridcell, so the tree stays valid.
- Keyboard. Roving tabindex over the cells: exactly one is tabbable — the
  focused day, else today, else the 1st. Left/Right ±1 day, Up/Down ±1 week,
  Home/End the first and last day of that week, PageUp/PageDown the same grid
  position one month away, Enter/Space activates the day. Walking off an edge
  pages the month when onMonthChange is wired, otherwise focus stays put.
  Because a month swap re-creates every cell, a move records a "focus pending"
  value (an index, or "first"/"last") in a ref and an effect keyed on the month
  restores DOM focus after the render; a month arriving from outside instead
  resets the tab stop to today.
- Event bubbling is the trap. A chip and the "+N more" button sit INSIDE the
  day cell, so both stopPropagation on click or a click would pick the event
  AND open the day underneath it. The peek is portalled to the body, yet React
  still bubbles its events back through the cell — so the cell's click handler
  tests currentTarget.contains(target), and its keydown handler ignores
  anything whose target is not the cell itself, leaving Enter, Escape and Tab
  to the widget that actually has focus.
- No fake affordances: a chip is a <button> only when onEventClick is wired,
  otherwise it renders as an inert element with role="img" carrying the full
  label. The cell gains cursor-pointer only when onDayClick exists.

Rendering & styling
- Semantic tokens only: bg-card (surface), bg-border (the lattice showing
  through the 1px gaps), bg-muted/40 (out-of-month cells), bg-primary +
  text-primary-foreground (today's numeral), bg-accent (hover), muted /
  accent / primary/15 / destructive/10 for the four tones, text-destructive for
  the error state, ring-ring for focus. No hex, no oklch, no palette classes.
  cn() merges the consumer className into the root of every status branch.
- Tones are semantic buckets, never hues: in a single-hue palette --accent and
  --muted resolve to the same surface, so separation is carried by text
  emphasis and by the leading dot — never by colour alone.
- One lane pitch (20px) sets the whole density: chip height, lane offsets and
  the cell's min-height all derive from it. Chips truncate, and the full
  "Design sprint, 3 August 2026 to 7 August 2026" lives in the aria-label and
  the title attribute. Bars that were cut by a week boundary lose the rounding
  on that edge and gain a chevron, so a continuation never reads as an end.
- Motion is decorative only: the peek's fade/zoom is dropped under
  prefers-reduced-motion, and the skeleton's pulse with it. Nothing about
  paging, focus or the peek depends on animation.

Customization levers
- Density: LANE_PX (20) and the cell's min-height are the whole vertical scale
  — shrink both for a dashboard widget, raise them for a wall display.
  maxPerDay trades chips for "+N more"; 1 turns the grid into a "something is
  happening" heat view, 8 makes it an agenda.
- Chip anatomy: the body is dot + time + title. Swap in an avatar, a status
  pill or a room name; the truncation and the aria-label are the only things
  keeping a dense cell legible.
- Cell anatomy: the day numeral, the "Aug" tag on the 1st and the "+N more"
  button are three independent blocks — add a per-day total, a weather glyph or
  a "create" affordance without touching the lane maths.
- Week shape: weekStartsOn moves the columns (0 for the US, 6 for much of the
  Gulf), fixedWeeks={false} drops the ghost sixth week for a five-row month,
  and hiding out-of-month days is one className away — but keep rendering them
  if events can start there, as the fixtures do.
- Peek depth: the popover currently lists the day's events; make it the entry
  point to a day view by putting a "Open 4 August" link in its header and
  wiring it to the same route onDayClick uses.
- RTL: the layout is written with logical properties (insetInlineStart,
  rounded-s/e, ms-auto), so it mirrors for free. If you ship RTL, mirror the
  Left/Right arrow keys too — everything else already flips.
- Extending the contract: a calendar id, an attendee list or a recurrence rule
  belongs on the event and flows straight into the chip body, the peek row and
  the aria-label. Drag-to-move is deliberately absent; add it by mapping a drop
  target's cell index back to a date and reporting through a new onEventMove
  callback, keeping the component controlled.

Concepts

  • Lane packing — overlap in a month view is vertical, not horizontal: each week row assigns every segment the lowest lane whose columns are all free, so a lane means the same line in all seven cells and a five-day bar can pass over Wednesday without colliding with Wednesday's own chip. Sorting by span before position is what keeps the bars on top.
  • Segment slicing — no box can wrap across two grid rows, so an event that crosses a week boundary becomes one segment per row; the cut edges lose their rounding and gain a chevron, which is how a continuation stops reading as an end.
  • Closed-form span geometry — a bar lives in the cell it starts in and is sized N × 100% + (N−1) × 1px, borrowing the row's own 1px gaps; because 100% resolves against exactly one cell there is nothing to measure, observe or re-sync on resize.
  • Overflow as a whole lane — the per-day budget hides complete lanes rather than trimming a bar, so "+N more" never leaves a half-drawn span behind, and the peek it opens lists everything on that day, not only what was cut.
  • Calendar strings, not instants — every date is "YYYY-MM-DD" and every Date is built and read in UTC, so no timezone can shift an event by a day and the server's markup matches the browser's; "today" arrives as a prop for the same reason.
  • Focus that survives a month swap — paging re-creates all 42 cells, so a keyboard move records where to land in a ref and an effect keyed on the month restores DOM focus afterwards; without it, arrowing off the 31st would drop focus to the body.

On This Page