# Calendar 1: week view with mini month

> A week calendar: a time grid with overlapping events placed side by side, an all-day row, a now line, a mini month and calendar toggles, event popovers and a new-event dialog. One day at a time on phones.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/calendar-1).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/calendar-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/calendar-1-light-4c7f1ca3ff03a634.png
- Registry item: https://ui.ballmac.com/r/calendar-1.json
- Categories: blocks, calendar
- Listed: 2026-10-14, source last changed 2026-10-14

## Install

```bash
npx shadcn@latest add https://ui.ballmac.com/r/calendar-1.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: `lucide-react`
- Registry items: `utils`, `https://ui.ballmac.com/r/button.json`, `https://ui.ballmac.com/r/calendar.json`, `https://ui.ballmac.com/r/dialog.json`, `https://ui.ballmac.com/r/input.json`, `https://ui.ballmac.com/r/popover.json`, `https://ui.ballmac.com/r/state-view.json`, `https://ui.ballmac.com/r/skeleton.json`

## Files

### `registry/ballmac/components/blocks/calendar-1/calendar-1.tsx`

```tsx
// Ballmac UI: Calendar 1. https://ui.ballmac.com/blocks/calendar-1
"use client"

import * as React from "react"
import { ChevronLeft, ChevronRight, MapPin, Plus } from "lucide-react"

import { Button, buttonVariants } from "@/components/ballmac/button"
import { Calendar } from "@/components/ballmac/calendar"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ballmac/dialog"
import { Input } from "@/components/ballmac/input"
import { Popover, PopoverContent, PopoverDescription, PopoverTitle, PopoverTrigger } from "@/components/ballmac/popover"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"

type Calendar1Event = {
  id: string
  title: string
  /** Start, as local date and time: YYYY-MM-DDTHH:mm. */
  start: string
  /** End, as local date and time: YYYY-MM-DDTHH:mm. Ignored for all-day events. */
  end: string
  /** Which calendar it belongs to; sets the colour and the checkbox that shows or hides it. */
  calendar: string
  location?: string
  /** Show it in the all-day row instead of the time grid. */
  allDay?: boolean
}

type Calendar1Props = Omit<React.ComponentProps<"div">, "children" | "onSelect"> & Omit<StateProps, "emptyState"> & {
  /** Events to show. */
  defaultEvents?: Calendar1Event[]
  /** Calendars and their colours (chart tokens 1 to 5). */
  calendars?: { id: string; label: string; color: 1 | 2 | 3 | 4 | 5 }[]
  /** Today, as YYYY-MM-DD. Passed in so the page is the same on the server and in the browser. */
  today?: string
  /** The time of day for the "now" line, as HH:mm. Pass null to hide it. */
  now?: string | null
  /** First hour shown. */
  startHour?: number
  /** Last hour shown. */
  endHour?: number
  /** Called when an event is added. */
  onAdd?: (event: Calendar1Event) => void
  /** Height of the frame. */
  height?: string
}

const ROW = 56
const DAY_MS = 86400000

const palette: Record<number, { bg: string; bar: string }> = {
  1: { bg: "bg-chart-1/15", bar: "bg-chart-1" },
  2: { bg: "bg-chart-2/15", bar: "bg-chart-2" },
  3: { bg: "bg-chart-3/20", bar: "bg-chart-3" },
  4: { bg: "bg-chart-4/15", bar: "bg-chart-4" },
  5: { bg: "bg-chart-5/15", bar: "bg-chart-5" },
}

const defaultCalendars: NonNullable<Calendar1Props["calendars"]> = [
  { id: "work", label: "Work", color: 1 },
  { id: "team", label: "Team", color: 5 },
  { id: "personal", label: "Personal", color: 2 },
]

const defaultEvents: Calendar1Event[] = [
  { id: "e1", title: "Team standup", start: "2026-09-28T09:30", end: "2026-09-28T09:45", calendar: "team" },
  { id: "e2", title: "Design review", start: "2026-09-29T11:00", end: "2026-09-29T12:00", calendar: "work", location: "Room 3" },
  { id: "e3", title: "Team standup", start: "2026-09-30T09:30", end: "2026-09-30T09:45", calendar: "team" },
  { id: "e4", title: "1:1 with Maya", start: "2026-09-30T14:00", end: "2026-09-30T14:30", calendar: "work" },
  { id: "e5", title: "Team standup", start: "2026-10-01T09:30", end: "2026-10-01T09:45", calendar: "team" },
  { id: "e6", title: "Pricing page working session", start: "2026-10-01T10:00", end: "2026-10-01T12:00", calendar: "work", location: "Zoom" },
  { id: "e7", title: "Lunch with Amara", start: "2026-10-01T12:30", end: "2026-10-01T13:30", calendar: "personal", location: "Café Lisboa" },
  { id: "e8", title: "Roadmap sync", start: "2026-10-01T14:00", end: "2026-10-01T15:00", calendar: "team", location: "Room 1" },
  { id: "e9", title: "Customer call: Northwind", start: "2026-10-01T14:30", end: "2026-10-01T15:15", calendar: "work" },
  { id: "e10", title: "Team standup", start: "2026-10-02T09:30", end: "2026-10-02T09:45", calendar: "team" },
  { id: "e11", title: "Launch day", start: "2026-10-02T00:00", end: "2026-10-02T23:59", calendar: "work", allDay: true },
  { id: "e12", title: "Quarterly planning", start: "2026-10-02T13:00", end: "2026-10-02T16:00", calendar: "team", location: "Main hall" },
  { id: "e13", title: "Pottery class", start: "2026-10-03T10:00", end: "2026-10-03T12:00", calendar: "personal" },
]

const parseDay = (iso: string) => Date.parse(`${iso.slice(0, 10)}T00:00:00Z`)
const minutes = (iso: string) => Number(iso.slice(11, 13)) * 60 + Number(iso.slice(14, 16))
const iso = (ms: number) => new Date(ms).toISOString().slice(0, 10)
const mondayOf = (ms: number) => ms - ((new Date(ms).getUTCDay() + 6) % 7) * DAY_MS
const clock = (m: number) => {
  const h = Math.floor(m / 60)
  const mm = m % 60
  return `${h % 12 === 0 ? 12 : h % 12}${mm ? `:${String(mm).padStart(2, "0")}` : ""} ${h < 12 ? "AM" : "PM"}`
}

/** Stack events that overlap in time side by side. */
function lanes(list: Calendar1Event[]) {
  const sorted = [...list].sort((a, b) => minutes(a.start) - minutes(b.start) || minutes(b.end) - minutes(a.end))
  const placed: { e: Calendar1Event; lane: number; of: number }[] = []
  let group: typeof placed = []
  let groupEnd = -1
  const flush = () => {
    const of = Math.max(1, ...group.map((g) => g.lane + 1))
    group.forEach((g) => (g.of = of))
    placed.push(...group)
    group = []
  }
  for (const e of sorted) {
    if (minutes(e.start) >= groupEnd && group.length) flush()
    const used = new Set(group.filter((g) => minutes(g.e.end) > minutes(e.start)).map((g) => g.lane))
    let lane = 0
    while (used.has(lane)) lane++
    group.push({ e, lane, of: 1 })
    groupEnd = Math.max(groupEnd, minutes(e.end))
  }
  if (group.length) flush()
  return placed
}

function Calendar1Inner({
  defaultEvents: initial = defaultEvents,
  calendars = defaultCalendars,
  today = "2026-10-01",
  now = "10:24",
  startHour = 8,
  endHour = 18,
  onAdd,
  height = "44rem",
  className,
  style,
  ...props
}: Calendar1Props) {
  const [events, setEvents] = React.useState(initial)
  const [selected, setSelected] = React.useState(today)
  const [hidden, setHidden] = React.useState<string[]>([])
  const [adding, setAdding] = React.useState(false)
  const [draft, setDraft] = React.useState({ title: "", day: today, from: "09:00", to: "10:00", calendar: calendars[0]?.id ?? "work" })
  const monday = mondayOf(parseDay(selected))
  const week = Array.from({ length: 7 }, (_, i) => monday + i * DAY_MS)
  const title = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" }).format(new Date(monday + 3 * DAY_MS))
  const colorOf = (id: string) => palette[calendars.find((c) => c.id === id)?.color ?? 1]
  const hours = Array.from({ length: endHour - startHour }, (_, i) => startHour + i)
  const visible = events.filter((e) => !hidden.includes(e.calendar))
  const gridHeight = hours.length * ROW
  const sel = new Date(Number(selected.slice(0, 4)), Number(selected.slice(5, 7)) - 1, Number(selected.slice(8, 10)))
  const shift = (days: number) => setSelected(iso(parseDay(selected) + days * DAY_MS))
  const dayFmt = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" })
  const longFmt = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
  const nowMin = now ? minutes(`0000-00-00T${now}`) : null

  function addEvent(e: React.FormEvent) {
    e.preventDefault()
    if (!draft.title.trim() || draft.to <= draft.from) return
    const ev: Calendar1Event = { id: `new-${events.length + 1}`, title: draft.title.trim(), start: `${draft.day}T${draft.from}`, end: `${draft.day}T${draft.to}`, calendar: draft.calendar }
    setEvents((all) => [...all, ev])
    onAdd?.(ev)
    setSelected(draft.day)
    setAdding(false)
    setDraft({ ...draft, title: "" })
  }

  return (
    <div data-slot="calendar-1" className={cn("bg-background flex overflow-hidden rounded-2xl border shadow-[0_30px_80px_-50px_rgb(0_0_0/0.4)]", className)} style={{ height, ...style }} {...props}>
      <aside aria-label="Calendars" className="bg-muted/30 hidden w-64 shrink-0 flex-col gap-5 overflow-y-auto border-e p-4 lg:flex">
        <Dialog open={adding} onOpenChange={setAdding}>
          <DialogTrigger className={buttonVariants({ className: "w-full" })}><Plus /> New event</DialogTrigger>
          <DialogContent>
            <form onSubmit={addEvent}>
              <DialogHeader>
                <DialogTitle>New event</DialogTitle>
                <DialogDescription>It appears on your calendar straight away.</DialogDescription>
              </DialogHeader>
              <div className="my-5 grid gap-3">
                <Input aria-label="Title" placeholder="Event title" value={draft.title} onChange={(e) => setDraft({ ...draft, title: e.target.value })} required />
                <Input aria-label="Day" type="date" value={draft.day} onChange={(e) => setDraft({ ...draft, day: e.target.value })} />
                <div className="grid grid-cols-2 gap-3">
                  <label className="grid gap-1.5 text-sm font-medium">Starts<Input type="time" value={draft.from} onChange={(e) => setDraft({ ...draft, from: e.target.value })} /></label>
                  <label className="grid gap-1.5 text-sm font-medium">Ends<Input type="time" value={draft.to} onChange={(e) => setDraft({ ...draft, to: e.target.value })} aria-invalid={draft.to <= draft.from || undefined} /></label>
                </div>
                {draft.to <= draft.from && <p role="alert" className="text-destructive text-sm">The end time must be after the start time.</p>}
              </div>
              <DialogFooter>
                <Button type="button" variant="ghost" onClick={() => setAdding(false)}>Cancel</Button>
                <Button type="submit" disabled={!draft.title.trim() || draft.to <= draft.from}>Add event</Button>
              </DialogFooter>
            </form>
          </DialogContent>
        </Dialog>
        <Calendar mode="single" selected={sel} month={sel} today={new Date(Number(today.slice(0, 4)), Number(today.slice(5, 7)) - 1, Number(today.slice(8, 10)))} onMonthChange={(m) => setSelected(iso(Date.UTC(m.getFullYear(), m.getMonth(), 1)))} onSelect={(d) => d && setSelected(iso(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())))} className="-mx-2 p-0" />
        <fieldset>
          <legend className="text-muted-foreground mb-2 text-xs font-medium">My calendars</legend>
          <ul className="space-y-1">
            {calendars.map((c) => (
              <li key={c.id}>
                <label className="hover:bg-accent/60 flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm">
                  <input type="checkbox" checked={!hidden.includes(c.id)} onChange={() => setHidden((h) => (h.includes(c.id) ? h.filter((x) => x !== c.id) : [...h, c.id]))} className="peer sr-only" />
                  <span aria-hidden="true" className={cn("peer-focus-visible:ring-ring/50 flex size-4 items-center justify-center rounded border-2 transition-colors peer-focus-visible:ring-[3px]", hidden.includes(c.id) ? "border-border" : cn(palette[c.color].bar, "border-transparent"))}>{!hidden.includes(c.id) && <svg viewBox="0 0 12 12" className="text-background size-3"><path d="M2.5 6.5l2.5 2.5 4.5-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>}</span>
                  {c.label}
                </label>
              </li>
            ))}
          </ul>
        </fieldset>
      </aside>

      <div className="flex min-w-0 flex-1 flex-col">
        <header className="flex flex-wrap items-center gap-2 border-b p-3">
          <h2 className="me-2 text-lg font-semibold tracking-[-0.02em]">{title}</h2>
          <Button variant="outline" size="sm" onClick={() => setSelected(today)}>Today</Button>
          <div className="hidden md:flex">
            <Button variant="ghost" size="icon" aria-label="Previous week" onClick={() => shift(-7)}><ChevronLeft  className="rtl:rotate-180"/></Button>
            <Button variant="ghost" size="icon" aria-label="Next week" onClick={() => shift(7)}><ChevronRight  className="rtl:rotate-180"/></Button>
          </div>
          <Button variant="outline" size="icon" className="ms-auto lg:hidden" aria-label="New event" onClick={() => setAdding(true)}><Plus /></Button>
          <div className="flex items-center gap-1 md:hidden">
            <Button variant="ghost" size="icon" aria-label="Previous day" onClick={() => shift(-1)}><ChevronLeft  className="rtl:rotate-180"/></Button>
            <Button variant="ghost" size="icon" aria-label="Next day" onClick={() => shift(1)}><ChevronRight  className="rtl:rotate-180"/></Button>
          </div>
        </header>

        <div className="min-h-0 flex-1 overflow-auto" tabIndex={0} role="region" aria-label={`Week of ${longFmt.format(new Date(monday))}`}>
          <div className="min-w-0" style={{ minWidth: "0" }}>
            <div className="bg-background/95 sticky top-0 z-20 border-b backdrop-blur">
              <div className="grid grid-cols-[3.5rem_repeat(7,minmax(0,1fr))] max-md:grid-cols-[3.5rem_minmax(0,1fr)]">
                <div />
                {week.map((d) => {
                  const day = iso(d)
                  const isToday = day === today
                  const isSel = day === selected
                  return (
                    <button key={day} type="button" onClick={() => setSelected(day)} aria-current={isToday ? "date" : undefined} aria-label={longFmt.format(new Date(d))} className={cn("focus-visible:ring-ring/50 flex-col items-center gap-0.5 py-2 text-center outline-none focus-visible:ring-[3px] focus-visible:ring-inset", isSel ? "flex" : "hidden md:flex")}>
                      <span className="text-muted-foreground text-xs font-medium">{dayFmt.format(new Date(d))}</span>
                      <span className={cn("flex size-8 items-center justify-center rounded-full text-sm font-semibold tabular-nums", isToday && "bg-foreground text-background")}>{new Date(d).getUTCDate()}</span>
                    </button>
                  )
                })}
              </div>
              {visible.some((e) => e.allDay) && (
                <div className="grid grid-cols-[3.5rem_repeat(7,minmax(0,1fr))] border-t max-md:grid-cols-[3.5rem_minmax(0,1fr)]">
                  <span className="text-muted-foreground py-1.5 pe-2 text-end text-[10px]">all-day</span>
                  {week.map((d) => (
                    <div key={d} className={cn("min-h-7 space-y-0.5 border-s p-0.5", iso(d) !== selected && "max-md:hidden")}>
                      {visible.filter((e) => e.allDay && parseDay(e.start) === d).map((e) => (
                        <div key={e.id} className={cn("truncate rounded px-1.5 py-0.5 text-xs font-medium", colorOf(e.calendar).bg)}>{e.title}</div>
                      ))}
                    </div>
                  ))}
                </div>
              )}
            </div>

            <div className="relative grid grid-cols-[3.5rem_repeat(7,minmax(0,1fr))] max-md:grid-cols-[3.5rem_minmax(0,1fr)]" style={{ height: gridHeight }}>
              <div aria-hidden="true">
                {hours.map((h) => (
                  <div key={h} className="text-muted-foreground relative pe-2 text-end text-[10px]" style={{ height: ROW }}><span className="absolute -top-2 end-2">{h === startHour ? "" : clock(h * 60)}</span></div>
                ))}
              </div>
              {week.map((d) => {
                const day = iso(d)
                const items = lanes(visible.filter((e) => !e.allDay && parseDay(e.start) === d))
                return (
                  <div key={day} className={cn("relative border-s", day !== selected && "max-md:hidden", day === today && "bg-accent/20")}>
                    {hours.map((h) => <div key={h} className="border-b" style={{ height: ROW }} />)}
                    {day === today && nowMin !== null && nowMin >= startHour * 60 && nowMin <= endHour * 60 && (
                      <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 z-10 flex items-center" style={{ top: ((nowMin - startHour * 60) / 60) * ROW }}>
                        <span className="bg-destructive -ms-1 size-2 rounded-full" />
                        <span className="bg-destructive h-px flex-1" />
                      </div>
                    )}
                    {items.map(({ e, lane, of }) => {
                      const top = ((Math.max(minutes(e.start), startHour * 60) - startHour * 60) / 60) * ROW
                      const h = Math.max(((Math.min(minutes(e.end), endHour * 60) - Math.max(minutes(e.start), startHour * 60)) / 60) * ROW, 22)
                      const c = colorOf(e.calendar)
                      return (
                        <Popover key={e.id}>
                          <PopoverTrigger
                            className={cn("focus-visible:ring-ring/50 absolute z-[5] overflow-hidden rounded-md border-s-[3px] px-1.5 py-1 text-start text-xs leading-tight outline-none transition-[filter] hover:brightness-95 focus-visible:z-20 focus-visible:ring-[3px]", c.bg, c.bar.replace("bg-", "border-"))}
                            style={{ top, height: h - 2, left: `calc(${(lane / of) * 100}% + 2px)`, width: `calc(${100 / of}% - 4px)` }}
                            aria-label={`${e.title}, ${clock(minutes(e.start))} to ${clock(minutes(e.end))}${e.location ? `, ${e.location}` : ""}`}
                          >
                            <span className="block truncate font-semibold">{e.title}</span>
                            {h > 36 && <span className="block truncate opacity-90">{clock(minutes(e.start))}</span>}
                          </PopoverTrigger>
                          <PopoverContent className="w-64" label={e.title}>
                            <PopoverTitle>{e.title}</PopoverTitle>
                            <PopoverDescription>{longFmt.format(new Date(d))}<br />{clock(minutes(e.start))} to {clock(minutes(e.end))}</PopoverDescription>
                            {e.location && <p className="mt-2 flex items-center gap-1.5 text-sm"><MapPin className="size-4" aria-hidden="true" />{e.location}</p>}
                            <p className="text-muted-foreground mt-2 text-xs">{calendars.find((x) => x.id === e.calendar)?.label} calendar</p>
                          </PopoverContent>
                        </Popover>
                      )
                    })}
                  </div>
                )
              })}
            </div>
          </div>
        </div>
      </div>
    </div>
  )
}


/** The same sidebar and week grid as the ready view, so nothing jumps when events arrive. */
function Calendar1Skeleton() {
  return (
    <div className="flex h-full" aria-hidden="true">
      <div className="bg-muted/30 hidden w-64 shrink-0 flex-col gap-5 border-e p-4 lg:flex">
        <Skeleton className="h-9 w-full rounded-lg" />
        <Skeleton className="h-56 w-full rounded-xl" />
        <div className="grid gap-2">
          {[0, 1, 2].map((i) => (
            <Skeleton key={i} className="h-7 w-full" />
          ))}
        </div>
      </div>
      <div className="flex min-w-0 flex-1 flex-col">
        <div className="flex items-center gap-2 border-b p-3">
          <Skeleton className="h-6 w-36" />
          <Skeleton className="h-8 w-16 rounded-md" />
          <Skeleton className="size-8 rounded-md" />
          <Skeleton className="size-8 rounded-md" />
        </div>
        <div className="grid flex-1 grid-cols-[3rem_repeat(5,1fr)] gap-px p-3">
          {Array.from({ length: 6 }).map((_, c) => (
            <div key={c} className="grid content-start gap-3">
              {c > 0 ? <Skeleton className="mx-auto h-5 w-12" /> : null}
              {c > 0 ? <Skeleton className="h-12 w-full rounded-md" style={{ marginTop: `${(c * 37) % 60}px` }} /> : null}
              {c > 1 && c < 5 ? <Skeleton className="h-16 w-full rounded-md" /> : null}
            </div>
          ))}
        </div>
      </div>
    </div>
  )
}

/**
 * The calendar. `state="loading"` shows a skeleton of its sidebar and week, `state="error"` an error with retry in the same
 * frame, and `state="empty"` the real week with no events, so adding one still works.
 */
function Calendar1({ state = "ready", onRetry, errorState, defaultEvents, height = "44rem", className, style, ...props }: Calendar1Props) {
  if (state === "loading" || state === "error") {
    return (
      <div
        data-slot="calendar-1"
        data-state={state}
        className={cn("bg-background overflow-hidden rounded-2xl border shadow-[0_30px_80px_-50px_rgb(0_0_0/0.4)]", className)}
        style={{ height, ...style }}
        {...frameProps(props)}
      >
        <StateView
          state={state}
          bare
          className="h-full"
          loading={<Calendar1Skeleton />}
          label="Loading your calendar"
          error={{ title: "Could not load your calendar", description: "The calendar service did not answer. Your events are safe.", ...errorState }}
          onRetry={onRetry}
        >
          {null}
        </StateView>
      </div>
    )
  }
  return <Calendar1Inner defaultEvents={state === "empty" ? [] : defaultEvents} height={height} className={className} style={style} {...props} />
}

export { Calendar1, type Calendar1Props, type Calendar1Event }
```
