Calendar month
A month grid with event dots, arrow key navigation and month paging.
npx shadcn@latest add https://ui.goodtabs.co/r/calendar-month.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/calendar-month
November 2026
MoTuWeThFrSaSu
Dots mark days with events, up to three. The selected day fills.
Sunday start
December 2026
SuMoTuWeThFrSa
Weeks begin on Sunday, and the grid adds a row when the month needs six.
Usage
import { CalendarMonth } from "@/components/ui/calendar-month"
<CalendarMonth defaultMonth="2026-11-01" events={events} selected={day} onSelect={setDay} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| defaultMonth | string | ISO date. Only the year and month are used. | |
| events | { date: string; label: string }[] | [] | Days that carry a dot. |
| selected | string | Selected day as an ISO date. | |
| onSelect | (date: string) => void | Called when a day is picked. | |
| weekStartsOn | 0 | 1 | 1 | 0 for Sunday, 1 for Monday. |
Source
calendar-month/calendar-month.tsx
"use client";
import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type CalendarEvent = {
/** ISO date, for example "2026-11-12". */
date: string;
/** What happens that day. Read out by screen readers. */
label: string;
};
type CalendarMonthProps = Omit<React.ComponentProps<"div">, "onSelect"> & {
/** First month shown, as an ISO date. Only the year and month are used. */
defaultMonth: string;
/** Days that carry a dot. */
events?: CalendarEvent[];
/** Selected day as an ISO date. */
selected?: string;
/** Called with the ISO date of the day the user picks. */
onSelect?: (date: string) => void;
/** 0 starts the week on Sunday, 1 on Monday. */
weekStartsOn?: 0 | 1;
};
const pad = (n: number) => String(n).padStart(2, "0");
const iso = (y: number, m: number, d: number) => `${y}-${pad(m + 1)}-${pad(d)}`;
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
/** Monday-first or Sunday-first offset of the 1st: (weekday - start + 7) mod 7. */
function leadingBlanks(year: number, month: number, start: number) {
return (new Date(year, month, 1).getDay() - start + 7) % 7;
}
/** The year and zero-based month of an ISO date. Anything else falls back to the current month. */
function parseMonth(value: string) {
const match = /^(\d{4})-(\d{1,2})(?:-|$)/.exec(value);
const month = match ? Number(match[2]) - 1 : -1;
if (match && month >= 0 && month <= 11) return { year: Number(match[1]), month };
const today = new Date();
return { year: today.getFullYear(), month: today.getMonth() };
}
/**
* A month grid with event dots. One tab stop, arrow keys move by day and week,
* Home and End jump to the ends of the week, Page Up and Page Down change month.
*/
export function CalendarMonth({
defaultMonth,
events,
selected,
onSelect,
weekStartsOn = 1,
className,
...props
}: CalendarMonthProps) {
const [view, setView] = React.useState(() => parseMonth(defaultMonth));
const [focus, setFocus] = React.useState(selected ?? iso(view.year, view.month, 1));
const gridRef = React.useRef<HTMLDivElement>(null);
const shouldFocus = React.useRef(false);
const { year, month } = view;
const daysIn = new Date(year, month + 1, 0).getDate();
const blanks = leadingBlanks(year, month, weekStartsOn);
const cells = Array.from({ length: Math.ceil((blanks + daysIn) / 7) * 7 }, (_, i) => i - blanks + 1);
const title = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric" }).format(new Date(year, month, 1));
const byDate = React.useMemo(() => {
const map = new Map<string, string[]>();
for (const e of events ?? []) map.set(e.date, [...(map.get(e.date) ?? []), e.label]);
return map;
}, [events]);
const order = [...WEEKDAYS.slice(weekStartsOn), ...WEEKDAYS.slice(0, weekStartsOn)];
React.useEffect(() => {
if (!shouldFocus.current) return;
shouldFocus.current = false;
gridRef.current?.querySelector<HTMLElement>(`[data-date="${focus}"]`)?.focus();
}, [focus, view]);
function moveTo(date: Date) {
shouldFocus.current = true;
setView({ year: date.getFullYear(), month: date.getMonth() });
setFocus(iso(date.getFullYear(), date.getMonth(), date.getDate()));
}
function shiftMonth(d: number, delta: number) {
const target = new Date(year, month + delta, 1);
const last = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
target.setDate(Math.min(d, last));
return target;
}
function onKeyDown(event: React.KeyboardEvent, d: number) {
const current = new Date(year, month, d);
const day = (current.getDay() - weekStartsOn + 7) % 7;
const step: Record<string, () => Date> = {
ArrowLeft: () => new Date(year, month, d - 1),
ArrowRight: () => new Date(year, month, d + 1),
ArrowUp: () => new Date(year, month, d - 7),
ArrowDown: () => new Date(year, month, d + 7),
Home: () => new Date(year, month, d - day),
End: () => new Date(year, month, d + 6 - day),
PageUp: () => shiftMonth(d, -1),
PageDown: () => shiftMonth(d, 1),
};
const next = step[event.key];
if (!next) return;
event.preventDefault();
moveTo(next());
}
// A focus date that names no cell here, such as "2026-11-5", must not leave the grid without a tab stop.
const focusDay = Number(focus.split("-")[2]);
const focusInView = focus === iso(year, month, focusDay) && focusDay >= 1 && focusDay <= daysIn;
return (
<div data-slot="calendar-month" className={cn("flex w-full max-w-64 flex-col gap-2 text-sm", className)} {...props}>
<div className="flex items-center justify-between">
<p aria-live="polite" className="m-0 pl-1 font-medium">
{title}
</p>
<div className="flex gap-0.5">
<Button
variant="ghost"
size="icon-sm"
className="rounded-full"
aria-label="Previous month"
onClick={() =>
setView(({ year, month }) => ({ year: month === 0 ? year - 1 : year, month: (month + 11) % 12 }))
}
>
<ChevronLeftIcon aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="rounded-full"
aria-label="Next month"
onClick={() =>
setView(({ year, month }) => ({ year: month === 11 ? year + 1 : year, month: (month + 1) % 12 }))
}
>
<ChevronRightIcon aria-hidden="true" />
</Button>
</div>
</div>
<div ref={gridRef} role="grid" aria-label={title} className="grid grid-cols-7 gap-px">
<div role="row" className="col-span-7 grid grid-cols-7">
{order.map((name) => (
<span
key={name}
role="columnheader"
aria-label={name}
className="grid h-7 place-items-center font-mono text-[11px] text-muted-foreground"
>
{name.slice(0, 2)}
</span>
))}
</div>
{Array.from({ length: cells.length / 7 }, (_, row) => (
<div key={row} role="row" className="col-span-7 grid grid-cols-7 gap-px">
{cells.slice(row * 7, row * 7 + 7).map((d, col) => {
if (d < 1 || d > daysIn) return <span key={col} role="gridcell" aria-hidden="true" />;
const date = iso(year, month, d);
const labels = byDate.get(date);
const isSelected = date === selected;
const tabbable = focusInView ? date === focus : d === 1;
const long = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric" }).format(
new Date(year, month, d),
);
return (
<span key={col} role="gridcell" aria-selected={isSelected}>
<button
type="button"
data-date={date}
tabIndex={tabbable ? 0 : -1}
aria-label={labels ? `${long}, ${labels.join(", ")}` : long}
onClick={() => {
setFocus(date);
onSelect?.(date);
}}
onKeyDown={(e) => onKeyDown(e, d)}
className={cn(
"relative grid h-9 w-full place-items-center rounded-md font-mono text-xs tabular-nums transition-colors duration-100 ease-out outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
isSelected ? "bg-foreground text-background" : "text-foreground hover:bg-muted",
)}
>
{d}
{labels && (
<span
aria-hidden="true"
className={cn(
"absolute bottom-1 flex gap-0.5",
"[&>i]:size-1 [&>i]:rounded-full [&>i]:bg-current [&>i]:opacity-60",
)}
>
{labels.slice(0, 3).map((_, i) => (
<i key={i} />
))}
</span>
)}
</button>
</span>
);
})}
</div>
))}
</div>
</div>
);
}
For AI agents
Hand your agent this page, the markdown at /components/calendar-month/prompt.md, or the Copy AI prompt button above.
When to use
- Showing what is on across a month at a glance
- A schedule or release calendar with a day detail beside it
When not to use
- Picking a date inside a form field: use a date field
- Hour by hour planning, which needs a week or day view
Agent notes
- Dates are ISO strings, never Date objects, so server and client agree on the day
- The grid is one tab stop. Arrows move by day and week, Home and End by week, Page Up and Down by month
- Event labels join the day button's accessible name; the dots are decorative
- It takes a function, so a demo that passes onSelect must be a client component
- Month and day names are formatted in en-US
The maths
- Blank cells before the 1st are (weekday of the 1st - week start + 7) mod 7.
- Rows are ceil((blanks + days in month) / 7), so a month takes four to six rows.