Skip to content

CalendarView

LG · 3300B gzip budget

Month, week, and day calendar views for event display.

Category
Data display
Budget
lg, composes several components
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale
September 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/calendar-view.json

Copies the source into your project. Pulls in 3 shared registry items: cn, use-locale, locale.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import { useMemo, useState } from "react";
import { cn } from "../lib/cn";
import { useLocale } from "../lib/use-locale";

export type CalendarViewMode = "month" | "week" | "day";

export interface CalendarEvent {
  id: string;
  title: string;
  date: Date;
  color?: string;
}

export interface CalendarViewProps {
  events?: CalendarEvent[];
  className?: string;
}

function startOfMonth(date: Date): Date {
  return new Date(date.getFullYear(), date.getMonth(), 1);
}

function startOfWeek(date: Date): Date {
  const d = new Date(date);
  d.setDate(d.getDate() - d.getDay());
  return d;
}

function addDays(date: Date, days: number): Date {
  const d = new Date(date);
  d.setDate(d.getDate() + days);
  return d;
}

function isSameDay(a: Date, b: Date): boolean {
  return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}

function formatMonthYear(date: Date, locale: string): string {
  return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(date);
}

function formatDay(date: Date, locale: string): string {
  return new Intl.DateTimeFormat(locale, { weekday: "short", month: "short", day: "numeric" }).format(date);
}

/**
 * Calendar with month/week/day views and keyboard navigation. The month grid
 * uses `role="grid"` with proper row/cell structure. Arrow keys navigate days;
 * Enter/Space selects.
 */
export function CalendarView({ events = [], className }: CalendarViewProps) {
  const { locale } = useLocale();
  const [mode, setMode] = useState<CalendarViewMode>("month");
  const [cursor, setCursor] = useState(new Date());
  const [selected, setSelected] = useState<Date | null>(null);

  const weekdayLabels = useMemo(() =>
    Array.from({ length: 7 }, (_, i) => {
      const d = new Date(2024, 0, 7 + i);
      return new Intl.DateTimeFormat(locale, { weekday: "short" }).format(d);
    }), [locale]);

  const monthDays = useMemo(() => {
    const start = startOfMonth(cursor);
    const gridStart = startOfWeek(start);
    return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
  }, [cursor]);

  const weekDays = useMemo(() => {
    const start = startOfWeek(cursor);
    return Array.from({ length: 7 }, (_, i) => addDays(start, i));
  }, [cursor]);

  const dayEvents = events.filter((e) => isSameDay(e.date, cursor));

  function navigatePrev() {
    if (mode === "month") {
      setCursor(new Date(cursor.getFullYear(), cursor.getMonth() - 1, 1));
    } else if (mode === "week") {
      setCursor(addDays(cursor, -7));
    } else {
      setCursor(addDays(cursor, -1));
    }
  }

  function navigateNext() {
    if (mode === "month") {
      setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1));
    } else if (mode === "week") {
      setCursor(addDays(cursor, 7));
    } else {
      setCursor(addDays(cursor, 1));
    }
  }

  function handleDayKeyDown(event: React.KeyboardEvent, date: Date) {
    if (event.key === "Enter" || event.key === " ") {
      event.preventDefault();
      setSelected(date);
    } else if (event.key === "ArrowRight") {
      event.preventDefault();
      setCursor(addDays(cursor, 1));
    } else if (event.key === "ArrowLeft") {
      event.preventDefault();
      setCursor(addDays(cursor, -1));
    } else if (event.key === "ArrowDown") {
      event.preventDefault();
      setCursor(addDays(cursor, 7));
    } else if (event.key === "ArrowUp") {
      event.preventDefault();
      setCursor(addDays(cursor, -7));
    }
  }

  const headerLabel = mode === "month" ? formatMonthYear(cursor, locale) : mode === "week" ? formatMonthYear(cursor, locale) : formatDay(cursor, locale);

  return (
    <div className={cn("w-80 text-start", className)}>
      <div className="mb-3 flex items-center justify-between">
        <div className="flex items-center gap-1">
          <button
            type="button"
            aria-label="Previous"
            onClick={navigatePrev}
            className="rounded p-1 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:hover:bg-neutral-800"
          >
            <span aria-hidden="true" className="rtl:-scale-x-100">‹</span>
          </button>
          <button
            type="button"
            aria-label="Next"
            onClick={navigateNext}
            className="rounded p-1 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:hover:bg-neutral-800"
          >
            <span aria-hidden="true" className="rtl:-scale-x-100">›</span>
          </button>
          <span className="ms-2 text-sm font-semibold">{headerLabel}</span>
        </div>
        <div className="flex gap-1">
          {(["month", "week", "day"] as const).map((m) => (
            <button
              key={m}
              type="button"
              aria-pressed={mode === m}
              onClick={() => setMode(m)}
              className={cn(
                "rounded px-2 py-1 text-xs font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600",
                mode === m
                  ? "bg-neutral-900 text-white dark:bg-neutral-100 dark:text-neutral-900"
                  : "text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800",
              )}
            >
              {m.charAt(0).toUpperCase() + m.slice(1)}
            </button>
          ))}
        </div>
      </div>

      {mode === "month" && (
        <div role="grid" aria-label={formatMonthYear(cursor, locale)} className="grid grid-cols-7 gap-0.5 text-center text-xs">
          <div role="row" className="contents">
            {weekdayLabels.map((label) => (
              <div role="columnheader" key={label} className="p-1 font-medium text-neutral-500">
                {label}
              </div>
            ))}
          </div>
          {monthDays.map((day, i) => {
            const isCurrent = isSameDay(day, new Date());
            const isSelected = selected && isSameDay(day, selected);
            const hasEvents = events.some((e) => isSameDay(e.date, day));
            return (
              <div role="row" key={i} className="contents">
                <button
                  type="button"
                  role="gridcell"
                  aria-selected={isSelected ?? undefined}
                  aria-current={isCurrent ? "date" : undefined}
                  tabIndex={isCurrent ? 0 : -1}
                  onClick={() => setSelected(day)}
                  onKeyDown={(e) => handleDayKeyDown(e, day)}
                  className={cn(
                    "relative rounded p-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600",
                    isCurrent && !isSelected && "bg-blue-50 dark:bg-blue-950",
                    isSelected && "bg-blue-600 text-white",
                  )}
                >
                  {day.getDate()}
                  {hasEvents && (
                    <span aria-hidden="true" className="absolute bottom-0.5 start-1/2 size-1 -translate-x-1/2 rounded-full bg-blue-500" />
                  )}
                </button>
              </div>
            );
          })}
        </div>
      )}

      {mode === "week" && (
        <div className="flex flex-col gap-1">
          {weekDays.map((day) => {
            const dayEvts = events.filter((e) => isSameDay(e.date, day));
            const isCurrent = isSameDay(day, new Date());
            return (
              <button
                key={day.toISOString()}
                type="button"
                aria-label={formatDay(day, locale)}
                onClick={() => {
                  setSelected(day);
                  setCursor(day);
                }}
                className={cn(
                  "flex items-center gap-3 rounded p-2 text-start text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600",
                  isCurrent && "bg-blue-50 dark:bg-blue-950",
                )}
              >
                <span className="w-8 text-center font-medium">{day.getDate()}</span>
                <span className="flex-1 text-neutral-600 dark:text-neutral-400">
                  {dayEvts.length > 0 ? `${dayEvts.length} event(s)` : "No events"}
                </span>
              </button>
            );
          })}
        </div>
      )}

      {mode === "day" && (
        <div className="flex flex-col gap-2">
          {dayEvents.length === 0 ? (
            <p className="py-8 text-center text-sm text-neutral-400">No events</p>
          ) : (
            dayEvents.map((event) => (
              <div
                key={event.id}
                className="rounded-md border border-neutral-200 p-3 dark:border-neutral-800"
              >
                <span className="text-sm font-medium">{event.title}</span>
              </div>
            ))
          )}
        </div>
      )}
    </div>
  );
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation