Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
defaultMonthstringISO date. Only the year and month are used.
events{ date: string; label: string }[][]Days that carry a dot.
selectedstringSelected day as an ISO date.
onSelect(date: string) => voidCalled when a day is picked.
weekStartsOn0 | 110 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.