Event calendar

Displays scheduled events across month, week, day, and agenda views.

Installation
$Terminal
npx shadcn@latest add https://sdk-components.thesqd.com/r/event-calendar.json
Usage
Feed it an `events` array. Everything else — the toolbar, view switching, overlap layout, and the event detail popover — is handled for you.
TSImport
import { EventCalendar } from "@/components/ui/event-calendar";
TSExample
import { EventCalendar } from "@/components/ui/event-calendar";

const events = [
  {
    id: "1",
    title: "Standup",
    description: "Daily sync with the design pod.",
    draftDate: "2026-09-01",
    estimateMinutes: 30,
    createdAt: "2026-08-28T14:02:00",
    color: "blue",
    user: { id: "1", name: "Avery Diaz" },
  },
  {
    id: "2",
    title: "Concept review",
    draftDate: "2026-09-01",
    estimateMinutes: 90,
    createdAt: "2026-08-29T09:14:00",
    color: "purple",
    user: { id: "2", name: "Jordan Lee" },
  },
  {
    id: "3",
    title: "Offsite",
    draftDate: "2026-09-03",
    estimateMinutes: 2 * 24 * 60,
    allDay: true,
    color: "yellow",
    user: { id: "2", name: "Jordan Lee" },
  },
];

export function Example() {
  return <EventCalendar events={events} defaultView="month" />;
}
Composition
Anatomy of the EventCalendar primitive.
EventCalendar                     (single component — pass everything as props)
├── variant                       ("calendar" | "workload")
├── events={CalendarEvent[]}      (id, title, draftDate | startDate, endDate,
│                                  estimateMinutes,
│                                  createdAt, color, allDay, user,
│                                  statusText, statusColor, url,
│                                  blocked, blockedBy)
├── view / defaultView            ("month" | "week" | "day" | "agenda")
├── views={[…]}                   (which views the toolbar switcher offers)
├── title / icon / countLabel     (the toolbar band's identity — the same band
│                                  data grid v3 and the board view wear)
├── date / defaultDate            (focused date — controlled or uncontrolled)
├── badgeVariant                  ("dot" | "colored" | "mixed")
├── visibleHours={{ start, end }} (hour range drawn by the week/day grid)
├── workingHours={{ start, end }} (unshaded hours inside the grid)
├── showUserFilter                (builds the filter from event.user)
├── onEventClick / onDayClick     (omit onEventClick to get the detail popover)
├── eventAction={{ label, icon, onSelect }}
│                                  (one action per event, as a button in the
│                                  detail popover)
├── eventActions={[{ label, icon, onSelect, available? }]}
│                                  (several, in order: the first outlined,
│                                  the rest quieter; wins over eventAction)
├── weekStartsOn                  (0 = Sunday, 1 = Monday)
└── workload only ───────────────
    ├── assignees={WorkloadAssignee[]}  (id, name, role, capacityMinutes, capacityByDate)
    ├── capacityMinutes                 (default daily capacity, in minutes)
    ├── tasksPerCell                    (cards per cell before "+N more")
    ├── workloadTaskStatusStyle         ("tint" | "bar" — status color treatment)
    ├── collapsedAssignees              (collapsed rows, by assignee id)
    ├── defaultCollapsedAssignees       (rows that start collapsed)
    ├── onCollapsedAssigneesChange      (fires with the whole collapsed set)
    └── onCapacityChange                ({ assignee, date, minutes, scope })
Month
The default grid. Each cell shows up to three events; the rest collapse into a “+N more” popover.
Week
Hour grid across seven columns with an all-day strip, overlap lanes, working-hour shading, and a live current-time line.
Day
The same time grid narrowed to a single column. Pass `views={["day"]}` to lock the switcher.
Agenda
A chronological list of the month's events grouped by day — days with nothing scheduled are skipped.
Dot badges
`badgeVariant="dot"` swaps the tinted chip for a colored dot and plain label — quieter in dense months.
User filter
`showUserFilter` derives the filter options from `event.user` and adds an avatar group footer.
Workload
`variant="workload"` swaps the calendar for a ClickUp-style capacity grid: a row per assignee, a column per day, and a load pill + capacity bar per cell that turns amber at 85% and red past 100%. Click any cell's load block to edit that person's capacity — for that day, every same weekday onward, or every day onward. The assignee column carries each person's week (booked / capacity, load %, task count), and a totals row pinned to the bottom edge sums the team per day plus the whole week. Every row keeps room for three cards so a light day doesn't collapse next to a busy one, and each row's caret collapses it to a single line — name, role, and each day's load pill with that day's task count (`collapsedAssignees` / `defaultCollapsedAssignees` / `onCollapsedAssigneesChange` if the page wants to own that state). Each task card carries a status dot, sits on a soft glow of its status color, and opens a preview popover with an “Open task” link. Entries flagged `blocked` are placeholders rather than tasks — blocked-out time (PTO, meetings, admin) rendered dashed with the hours they hold and who blocked them. Under 48rem of width the grid swaps itself for the mobile layout: a day strip across the week, then one card per assignee for the selected day — avatar, that day's load pill (still the capacity editor's trigger), and the task stack with “+N more today”, over two totals bars pinned to the bottom edge — the selected day, then the whole week.
API Reference
Props exposed by the EventCalendar component.
PropTypeDefaultDescription
variant"calendar" | "workload""calendar"`calendar` renders the month/week/day/agenda views. `workload` renders the capacity grid — one row per assignee, one column per day of the week.
assigneesWorkloadAssignee[]—Workload rows — `{ id, name, role?, src?, capacityMinutes?, capacityByDate? }`. Omitted, the rows are derived from `event.user`.
capacityMinutesnumber420Default daily capacity for the workload grid. Overridden per person by `assignee.capacityMinutes`, and per day by `assignee.capacityByDate["yyyy-MM-dd"]`.
tasksPerCellnumber2Task cards shown in a workload cell before the rest collapse into “+N more”.
workloadTaskStatusStyle"tint" | "bar""tint"How a workload task card carries its status color — `"tint"` washes the card in it, `"bar"` is the alert-v2 left accent stripe (which replaces the status dot).
collapsedAssigneesstring[]—Controlled collapsed workload rows, by assignee id. A collapsed row drops its task cards and span bars and keeps one line per person: name, role, and each day's load pill with that day's task count under it. Click a row's caret to toggle.
defaultCollapsedAssigneesstring[]—Rows that start collapsed when `collapsedAssignees` is not passed — the grid owns the state from there.
onCollapsedAssigneesChange(assigneeIds: string[]) => void—Fires with the full set of collapsed assignee ids whenever a row's caret is clicked.
onCapacityChange(change: CapacityChange) => void—Fires when a capacity edit is saved — `{ assignee, date, minutes, scope }` where scope is `"day"`, `"weekday"` (every same weekday from that date on), or `"all"` (every day from that date on). The grid also applies the edit to its own state, so this is persistence-only.
eventsCalendarEvent[]—Events to render — each is a `draftDate` plus an `estimateMinutes` duration.
view / defaultView"month" | "week" | "day" | "agenda""month"Active view. Pass `view` + `onViewChange` to control it, or `defaultView` to let the toolbar own it.
viewsEventCalendarView[]["month","week","day","agenda"]Views offered by the toolbar switcher. A single entry hides the switcher.
titleReactNode | false"Calendar"The toolbar band's identity, left of the controls — the same band data grid v3 and the board view wear. `false` drops the left side and the controls take the width.
iconReactNode | false<CalendarDots /> (workload: <UsersThree />)Leading glyph beside the title. `false` drops it.
countLabel(count: number) => ReactNode`${n} events`The count pill beside the title, given the events the calendar holds after the user filter. Return `null` for no pill. Defaults to "N events", and to "N people" (the rows on the grid) in the workload variant.
date / defaultDateDatenew Date()Focused date. Pair `date` with `onDateChange` for a controlled calendar.
badgeVariant"dot" | "colored" | "mixed""colored"`dot` = colored dot + plain label, `colored` = tinted chip, `mixed` = dots in month/agenda and tinted blocks in week/day.
visibleHours{ start: number; end: number }{ start: 0, end: 24 }Hour range rendered by the week and day grids.
workingHours{ start: number; end: number }—Hours drawn on the plain background; everything outside is shaded.
showUserFilterbooleanfalseAdds an all-users Select built from `event.user`, plus an avatar group footer.
weekStartsOn0 | 100 = Sunday, 1 = Monday.
onEventClick(event: CalendarEvent) => void—Given, chips call this instead of opening their built-in detail popover.
eventAction{ label, icon?, onSelect(event) }—One action offered on every event, as a button at the foot of the chip's detail popover. Ignored when onEventClick is given — there is no popover then. Shapeless on purpose: what the action IS belongs to the app.
eventActions{ label, icon?, onSelect(event), available?(event) }[]—Several actions on every event, in order — the first drawn as the main outlined button, the rest as quieter ghost buttons under it. `available` limits one to the events it returns true for (a whole-series action on repeating events only). Wins over eventAction; ignored when onEventClick is given.
onDayClick(date: Date) => void—Fires when a month cell or week column header is clicked.
actionsReactNode—The page's own controls (Filters, a ⋯ menu), drawn at the end of the toolbar after the date and view controls, so a page with one header does not draw a second.
classNamestring—Extends the card surface wrapper.
CalendarEvent
Shape of each entry in the `events` array.
PropTypeDefaultDescription
idstring—Unique key.
titlestring—Chip label.
descriptionstring—Shown in the detail popover.
draftDateDate | string—The day the task is drafted on — `"2026-09-01"` or a Date. Date-only: any time component is ignored. (A bare `"yyyy-MM-dd"` string is parsed in local time, so it never slips a day.)
estimateMinutesnumber30Duration. In the workload grid these sum per assignee per day — that total is what runs against the person's capacity. On a multi-day event it is what the event books on *each* covered day.
startDateDate | string—First day the event covers — the blockout-shaped alias of `draftDate` (blockouts arrive as a start/end pair). One of the two is required; `startDate` wins if both are given.
endDateDate | string—Last day the event covers — a week-long block, PTO, a multi-day task. The workload grid repeats the card on every day from `draftDate` through `endDate` and books `estimateMinutes` against each of them; the month/week views draw it as one spanning bar.
color"brand" | "blue" | "green" | "red" | "yellow" | "purple" | "orange""brand"Chip tint / dot color.
allDayboolean—Pins the event to the all-day strip above the week/day grid. Multi-day events land there too.
createdAtDate | string—Orders tasks within a workload cell, oldest first. Without it the cell falls back to alphabetical by title.
statusTextstring—Status label — renders a dot on the workload task card and a pill in its preview.
statusColorBadgeColor | `#${string}`—Status color: a Badge palette name (`"sky"`, `"coral"`, …) or a literal hex (`"#ffcf00"`), e.g. a ClickUp status color straight off the API.
blockedbooleanfalseMarks the entry as blocked-out time rather than a real task — a placeholder for PTO, meetings or admin time. Renders as a dashed card labelled with the hours it holds, and still counts against the day's capacity.
blockedBystring—Who put the block on the calendar. Shown in the placeholder's preview popover.
urlstring—Target for the task preview's “Open task” link. Omitted, the link is hidden. External links pick up the global `a[target="_blank"]` ↗ affordance.
user{ id: string; name: string; src?: string }—Owner — powers the user filter and the avatar footer.