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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/calendar-month.jsonPrompt
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.
Stopwatch
A count-up stopwatch with start/pause/reset and lap recording, timed off a monotonic clock so laps stay exact and a throttled background tab never loses time.
Timeline Swimlane
Events across parallel resource lanes on one shared time axis, with overlap stacking, hour/day/week zoom, a caller-supplied now marker and four data states.