# Calendar 1 - Month and Week Calendar

> A full height calendar that switches between month and week, where clicking any day opens a side sheet to add an event and events can be dragged to another day.

A shadcn/ui block from the [@shadcnblocks](https://shadcnblocks.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/shadcnblocks/calendar1).

- Page, with a live preview: https://shadcn.exchange/r/shadcnblocks/calendar1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/shadcnblocks/calendar1-light-549e34ad73ca55fa.png
- Registry item: https://shadcnblocks.com/r/calendar1.json
- Listed: 2026-10-13, source last changed 2026-10-16

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/shadcnblocks/calendar1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `cn`, `lucide-react`
- Registry items: `button`, `field`, `input`, `sheet`, `textarea`

## Files

### `block/calendar1/calendar1.tsx`

```tsx
import { ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { useMemo, useState, type DragEvent, type FormEvent } from "react";
import { Button } from "@/components/ui/button";
import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "cn";

type CalendarId = "work" | "meetings" | "social" | "personal" | "important";
type CalendarView = "month" | "week";

type CalendarEvent = {
  id: string;
  title: string;
  description?: string;
  date: string;
  start?: string;
  end?: string;
  calendar: CalendarId;
};

type Draft = {
  id?: string;
  title: string;
  description: string;
  date: string;
  start: string;
  end: string;
  calendar: CalendarId;
};

type Calendar1Props = {
  className?: string;
};

const TODAY = new Date(2026, 9, 21);
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];

const CALENDARS: Record<
  CalendarId,
  { label: string; dot: string; chip: string }
> = {
  work: {
    label: "Work",
    dot: "bg-chart-1",
    chip: "bg-chart-1/15 hover:bg-chart-1/25",
  },
  meetings: {
    label: "Meetings",
    dot: "bg-chart-2",
    chip: "bg-chart-2/15 hover:bg-chart-2/25",
  },
  social: {
    label: "Social",
    dot: "bg-chart-3",
    chip: "bg-chart-3/15 hover:bg-chart-3/25",
  },
  personal: {
    label: "Personal",
    dot: "bg-chart-4",
    chip: "bg-chart-4/15 hover:bg-chart-4/25",
  },
  important: {
    label: "Important",
    dot: "bg-chart-5",
    chip: "bg-chart-5/15 hover:bg-chart-5/25",
  },
};

const INITIAL_EVENTS: CalendarEvent[] = [
  {
    id: "1",
    title: "Sprint kickoff",
    date: "2026-09-28",
    start: "10:00",
    end: "11:00",
    calendar: "work",
  },
  {
    id: "2",
    title: "Team offsite",
    date: "2026-10-01",
    calendar: "social",
    description: "Full day at the lake house. Bus leaves at 8am.",
  },
  {
    id: "3",
    title: "Design review",
    date: "2026-10-02",
    start: "14:00",
    end: "15:00",
    calendar: "meetings",
  },
  {
    id: "4",
    title: "Standup",
    date: "2026-10-05",
    start: "09:00",
    end: "09:15",
    calendar: "meetings",
  },
  {
    id: "5",
    title: "Dentist",
    date: "2026-10-07",
    start: "15:30",
    end: "16:15",
    calendar: "personal",
  },
  {
    id: "6",
    title: "Investor update",
    date: "2026-10-08",
    start: "11:00",
    end: "12:00",
    calendar: "important",
  },
  {
    id: "7",
    title: "Product review",
    date: "2026-10-12",
    start: "11:00",
    end: "12:00",
    calendar: "meetings",
  },
  {
    id: "8",
    title: "Write release notes",
    date: "2026-10-12",
    start: "15:00",
    end: "16:00",
    calendar: "work",
  },
  {
    id: "9",
    title: "Interview",
    date: "2026-10-13",
    start: "16:30",
    end: "17:30",
    calendar: "important",
  },
  {
    id: "10",
    title: "Quarterly planning",
    date: "2026-10-16",
    start: "10:00",
    end: "12:30",
    calendar: "meetings",
  },
  {
    id: "11",
    title: "Farmers market",
    date: "2026-10-17",
    start: "09:00",
    end: "11:00",
    calendar: "personal",
  },
  {
    id: "12",
    title: "Standup",
    date: "2026-10-19",
    start: "09:00",
    end: "09:15",
    calendar: "meetings",
  },
  {
    id: "13",
    title: "Customer call",
    date: "2026-10-21",
    start: "10:30",
    end: "11:00",
    calendar: "work",
  },
  {
    id: "14",
    title: "Team lunch",
    date: "2026-10-21",
    start: "12:30",
    end: "13:30",
    calendar: "social",
  },
  {
    id: "15",
    title: "Yoga",
    date: "2026-10-21",
    start: "18:00",
    end: "19:00",
    calendar: "personal",
  },
  {
    id: "16",
    title: "Launch rehearsal",
    date: "2026-10-23",
    start: "16:00",
    end: "17:00",
    calendar: "work",
  },
  {
    id: "17",
    title: "Weekend hike",
    date: "2026-10-25",
    start: "08:00",
    end: "12:00",
    calendar: "personal",
  },
  {
    id: "18",
    title: "Board meeting",
    date: "2026-10-29",
    start: "09:30",
    end: "11:00",
    calendar: "important",
  },
  { id: "19", title: "Launch day", date: "2026-10-30", calendar: "important" },
  {
    id: "20",
    title: "Halloween party",
    date: "2026-10-31",
    start: "19:00",
    end: "23:00",
    calendar: "social",
  },
  {
    id: "21",
    title: "Retro",
    date: "2026-11-02",
    start: "14:00",
    end: "15:00",
    calendar: "meetings",
  },
];

const monthFormatter = new Intl.DateTimeFormat("en-US", {
  month: "long",
  year: "numeric",
});
const shortMonthFormatter = new Intl.DateTimeFormat("en-US", {
  month: "short",
});
const longDayFormatter = new Intl.DateTimeFormat("en-US", {
  weekday: "long",
  month: "long",
  day: "numeric",
});

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

function startOfWeek(date: Date) {
  return addDays(date, -((date.getDay() + 6) % 7));
}

function toIsoDate(date: Date) {
  const month = String(date.getMonth() + 1).padStart(2, "0");
  const day = String(date.getDate()).padStart(2, "0");
  return `${date.getFullYear()}-${month}-${day}`;
}

function parseIsoDate(value: string) {
  const [year, month, day] = value.split("-").map(Number);
  return new Date(year, month - 1, day);
}

function formatTime(value: string) {
  const [hour, minute] = value.split(":").map(Number);
  const suffix = hour >= 12 ? "pm" : "am";
  const displayHour = hour % 12 === 0 ? 12 : hour % 12;
  return minute === 0
    ? `${displayHour}${suffix}`
    : `${displayHour}:${String(minute).padStart(2, "0")}${suffix}`;
}

function buildMonthDays(anchor: Date) {
  const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
  const last = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0);
  const start = startOfWeek(first);
  const cells =
    Math.ceil((((first.getDay() + 6) % 7) + last.getDate()) / 7) * 7;
  return Array.from({ length: cells }, (_, index) => addDays(start, index));
}

function formatWeekRange(anchor: Date) {
  const start = startOfWeek(anchor);
  const end = addDays(start, 6);
  const endLabel =
    start.getMonth() === end.getMonth()
      ? String(end.getDate())
      : `${shortMonthFormatter.format(end)} ${end.getDate()}`;
  return `${shortMonthFormatter.format(start)} ${start.getDate()} – ${endLabel}, ${end.getFullYear()}`;
}

function sortEvents(a: CalendarEvent, b: CalendarEvent) {
  return (a.start ?? "").localeCompare(b.start ?? "");
}

const Calendar1 = ({ className }: Calendar1Props) => {
  const [events, setEvents] = useState(INITIAL_EVENTS);
  const [view, setView] = useState<CalendarView>("month");
  const [anchor, setAnchor] = useState(TODAY);
  const [draft, setDraft] = useState<Draft | null>(null);
  const [draggingId, setDraggingId] = useState<string | null>(null);
  const [dropDate, setDropDate] = useState<string | null>(null);

  const days = useMemo(
    () =>
      view === "month"
        ? buildMonthDays(anchor)
        : Array.from({ length: 7 }, (_, index) =>
            addDays(startOfWeek(anchor), index),
          ),
    [anchor, view],
  );
  const eventsByDay = useMemo(() => {
    const map = new Map<string, CalendarEvent[]>();
    events.forEach((event) => {
      map.set(event.date, [...(map.get(event.date) ?? []), event]);
    });
    map.forEach((list) => list.sort(sortEvents));
    return map;
  }, [events]);

  const isEditing = Boolean(draft?.id);
  const isAllDay = Boolean(draft && !draft.start && !draft.end);
  const hasTimeError = Boolean(
    draft &&
    !isAllDay &&
    (!draft.start || !draft.end || draft.end <= draft.start),
  );

  const step = (direction: number) =>
    setAnchor(
      view === "month"
        ? new Date(anchor.getFullYear(), anchor.getMonth() + direction, 1)
        : addDays(anchor, direction * 7),
    );

  const openNewEvent = (date: string) =>
    setDraft({
      title: "",
      description: "",
      date,
      start: "09:00",
      end: "10:00",
      calendar: "work",
    });

  const openEvent = (event: CalendarEvent) =>
    setDraft({
      id: event.id,
      title: event.title,
      description: event.description ?? "",
      date: event.date,
      start: event.start ?? "",
      end: event.end ?? "",
      calendar: event.calendar,
    });

  const updateDraft = (patch: Partial<Draft>) =>
    setDraft((current) => (current ? { ...current, ...patch } : current));

  const handleSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
    formEvent.preventDefault();
    if (!draft || !draft.title.trim() || hasTimeError) return;
    const saved: CalendarEvent = {
      id: draft.id ?? String(Date.now()),
      title: draft.title.trim(),
      description: draft.description.trim() || undefined,
      date: draft.date,
      start: isAllDay ? undefined : draft.start,
      end: isAllDay ? undefined : draft.end,
      calendar: draft.calendar,
    };
    setEvents((current) =>
      draft.id
        ? current.map((item) => (item.id === draft.id ? saved : item))
        : [...current, saved],
    );
    setDraft(null);
  };

  const handleDelete = () => {
    if (!draft?.id) return;
    setEvents((current) => current.filter((item) => item.id !== draft.id));
    setDraft(null);
  };

  const handleDrop = (dragEvent: DragEvent<HTMLDivElement>, date: string) => {
    dragEvent.preventDefault();
    if (draggingId) {
      setEvents((current) =>
        current.map((item) =>
          item.id === draggingId ? { ...item, date } : item,
        ),
      );
    }
    setDraggingId(null);
    setDropDate(null);
  };

  return (
    <section
      className={cn(
        "flex h-svh max-h-[1200px] min-h-[600px] w-full flex-col overflow-hidden bg-background text-foreground",
        className,
      )}
    >
      <header className="flex min-h-14 items-center justify-between gap-3 border-b px-4 sm:px-6">
        <div className="flex min-w-0 items-center gap-2">
          <h2 className="truncate text-lg font-semibold">
            Calendar -{" "}
            {view === "month"
              ? monthFormatter.format(anchor)
              : formatWeekRange(anchor)}
          </h2>
          <div className="flex shrink-0 items-center">
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label={view === "month" ? "Previous month" : "Previous week"}
              onClick={() => step(-1)}
            >
              <ChevronLeft />
            </Button>
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label={view === "month" ? "Next month" : "Next week"}
              onClick={() => step(1)}
            >
              <ChevronRight />
            </Button>
          </div>
        </div>
        <div className="flex shrink-0 items-center gap-1">
          {(["month", "week"] as const).map((mode) => (
            <button
              key={mode}
              type="button"
              aria-pressed={view === mode}
              onClick={() => setView(mode)}
              className="rounded-md px-2.5 py-1 text-sm font-medium text-muted-foreground capitalize transition-colors hover:bg-muted hover:text-foreground aria-pressed:bg-muted aria-pressed:text-foreground"
            >
              {mode}
            </button>
          ))}
        </div>
      </header>

      <div className="flex min-h-0 flex-1 flex-col overflow-auto">
        <div className="sticky top-0 z-10 grid min-w-[720px] grid-cols-7 border-b bg-muted/40 backdrop-blur">
          {WEEKDAYS.map((weekday) => (
            <div
              key={weekday}
              className="flex h-10 items-center justify-end border-r px-3 text-xs font-medium text-muted-foreground last:border-r-0"
            >
              {weekday}
            </div>
          ))}
        </div>

        <div
          className="grid min-w-[720px] flex-1 grid-cols-7"
          style={{
            gridTemplateRows: `repeat(${days.length / 7}, minmax(${view === "month" ? "7.5rem" : "0"}, 1fr))`,
          }}
        >
          {days.map((date) => {
            const iso = toIsoDate(date);
            const dayEvents = eventsByDay.get(iso) ?? [];
            const isOutside =
              view === "month" && date.getMonth() !== anchor.getMonth();
            const isToday = iso === toIsoDate(TODAY);
            return (
              <div
                key={iso}
                onClick={(clickEvent) => {
                  if ((clickEvent.target as HTMLElement).closest("button"))
                    return;
                  openNewEvent(iso);
                }}
                onDragOver={(dragEvent) => {
                  if (!draggingId) return;
                  dragEvent.preventDefault();
                  setDropDate(iso);
                }}
                onDrop={(dragEvent) => handleDrop(dragEvent, iso)}
                className={cn(
                  "group flex min-h-0 cursor-pointer flex-col gap-1 border-r border-b px-2 pt-1.5 pb-2 transition-colors hover:bg-muted/30 [&:nth-child(7n)]:border-r-0",
                  isOutside && "bg-muted/30",
                  dropDate === iso && "bg-muted/60",
                )}
              >
                <div className="flex items-center justify-end gap-1.5 text-xs font-medium">
                  {date.getDate() === 1 ? (
                    <span className="text-muted-foreground">
                      {shortMonthFormatter.format(date)}
                    </span>
                  ) : null}
                  <span
                    className={cn(
                      "flex size-6 items-center justify-center rounded-full",
                      isOutside && "text-muted-foreground",
                      isToday &&
                        "bg-primary font-semibold text-primary-foreground",
                    )}
                  >
                    {date.getDate()}
                  </span>
                </div>

                <div className="flex min-h-0 flex-col gap-1 overflow-y-auto">
                  {dayEvents.map((event) => (
                    <button
                      key={event.id}
                      type="button"
                      draggable
                      onDragStart={(dragEvent) => {
                        dragEvent.dataTransfer.effectAllowed = "move";
                        dragEvent.dataTransfer.setData("text/plain", event.id);
                        setDraggingId(event.id);
                      }}
                      onDragEnd={() => {
                        setDraggingId(null);
                        setDropDate(null);
                      }}
                      onClick={() => openEvent(event)}
                      title={event.title}
                      className={cn(
                        "flex h-7 w-full shrink-0 items-center gap-2 rounded-md px-2 text-left text-xs font-medium transition-colors",
                        CALENDARS[event.calendar].chip,
                        draggingId === event.id && "opacity-40",
                      )}
                    >
                      <span
                        className={cn(
                          "h-4 w-0.5 shrink-0 rounded-full",
                          CALENDARS[event.calendar].dot,
                        )}
                      />
                      <span className="min-w-0 truncate">
                        {event.start ? (
                          <span className="mr-1.5 text-muted-foreground">
                            {formatTime(event.start)}
                          </span>
                        ) : null}
                        {event.title}
                      </span>
                    </button>
                  ))}
                </div>

                <button
                  type="button"
                  onClick={() => openNewEvent(iso)}
                  className="mt-auto flex h-6 items-center gap-1.5 px-1 text-xs font-medium text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 hover:text-foreground focus-visible:opacity-100"
                >
                  <Plus className="size-3.5" />
                  Add event
                </button>
              </div>
            );
          })}
        </div>
      </div>

      <Sheet
        open={draft !== null}
        onOpenChange={(open) => !open && setDraft(null)}
      >
        <SheetContent className="w-full sm:max-w-md">
          <SheetHeader>
            <SheetTitle>{isEditing ? "Edit event" : "New event"}</SheetTitle>
            <SheetDescription>
              {draft
                ? longDayFormatter.format(parseIsoDate(draft.date))
                : "Add an event to your calendar."}
            </SheetDescription>
          </SheetHeader>

          {draft ? (
            <form
              id="calendar1-event-form"
              onSubmit={handleSubmit}
              className="flex-1 overflow-y-auto px-4"
            >
              <FieldGroup>
                <Field>
                  <FieldLabel htmlFor="calendar1-title">Title</FieldLabel>
                  <Input
                    id="calendar1-title"
                    value={draft.title}
                    onChange={(event) =>
                      updateDraft({ title: event.target.value })
                    }
                    placeholder="Planning session"
                    required
                    autoFocus
                  />
                </Field>
                <Field>
                  <FieldLabel htmlFor="calendar1-description">Notes</FieldLabel>
                  <Textarea
                    id="calendar1-description"
                    value={draft.description}
                    onChange={(event) =>
                      updateDraft({ description: event.target.value })
                    }
                    placeholder="Agenda, links, or anything to remember"
                    rows={3}
                  />
                </Field>
                <Field>
                  <FieldLabel htmlFor="calendar1-date">Date</FieldLabel>
                  <Input
                    id="calendar1-date"
                    type="date"
                    value={draft.date}
                    onChange={(event) =>
                      updateDraft({ date: event.target.value })
                    }
                    required
                  />
                </Field>
                <Field data-invalid={hasTimeError || undefined}>
                  <div className="grid grid-cols-2 gap-4">
                    <div className="flex flex-col gap-2">
                      <FieldLabel htmlFor="calendar1-start">Starts</FieldLabel>
                      <Input
                        id="calendar1-start"
                        type="time"
                        value={draft.start}
                        onChange={(event) =>
                          updateDraft({ start: event.target.value })
                        }
                      />
                    </div>
                    <div className="flex flex-col gap-2">
                      <FieldLabel htmlFor="calendar1-end">Ends</FieldLabel>
                      <Input
                        id="calendar1-end"
                        type="time"
                        value={draft.end}
                        onChange={(event) =>
                          updateDraft({ end: event.target.value })
                        }
                        aria-invalid={hasTimeError || undefined}
                      />
                    </div>
                  </div>
                  <FieldDescription>
                    {hasTimeError
                      ? "The end time must be after the start time."
                      : "Clear both times for an all day event."}
                  </FieldDescription>
                </Field>
                <div className="flex flex-col gap-3">
                  <span className="text-sm font-medium">Calendar</span>
                  <div className="flex flex-wrap gap-2">
                    {(Object.keys(CALENDARS) as CalendarId[]).map((id) => (
                      <button
                        key={id}
                        type="button"
                        aria-pressed={draft.calendar === id}
                        onClick={() => updateDraft({ calendar: id })}
                        className="flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors aria-pressed:border-foreground aria-pressed:text-foreground"
                      >
                        <span
                          className={cn(
                            "size-2.5 rounded-full",
                            CALENDARS[id].dot,
                          )}
                        />
                        {CALENDARS[id].label}
                      </button>
                    ))}
                  </div>
                </div>
              </FieldGroup>
            </form>
          ) : null}

          <SheetFooter className="flex-row justify-between gap-2">
            {isEditing ? (
              <Button type="button" variant="ghost" onClick={handleDelete}>
                Delete
              </Button>
            ) : (
              <span />
            )}
            <div className="flex gap-2">
              <Button
                type="button"
                variant="outline"
                onClick={() => setDraft(null)}
              >
                Cancel
              </Button>
              <Button type="submit" form="calendar1-event-form">
                {isEditing ? "Save" : "Add event"}
              </Button>
            </div>
          </SheetFooter>
        </SheetContent>
      </Sheet>
    </section>
  );
};

export { Calendar1 };
```
