Calendar
Feature-rich calendar with month, week, day, year, and agenda views. Supports events, time grid, drag & drop, recurrence, date range selection, and custom rendering.
Playground
<script lang="ts">
import { Calendar } from '@urbicon-ui/blocks';
const events = [
{
id: '1',
title: 'Sprint Planning',
start: new Date('2026-03-02T00:00:00.000Z'),
categoryId: 'meeting',
description: 'Sprint 14 Planung'
},
{
id: '2',
title: 'Design Review',
start: new Date('2026-03-05T00:00:00.000Z'),
categoryId: 'meeting'
},
{
id: '3',
title: 'Release v3.0',
start: new Date('2026-03-07T00:00:00.000Z'),
categoryId: 'deadline'
},
{
id: '4',
title: 'Deep Work',
start: new Date('2026-03-09T10:00:00.000Z'),
end: new Date('2026-03-09T12:00:00.000Z'),
allDay: false,
categoryId: 'focus'
},
{
id: '5',
title: 'Team Lunch',
start: new Date('2026-03-10T00:00:00.000Z'),
categoryId: 'social'
},
{
id: '6',
title: 'Standup',
start: new Date('2026-03-10T09:00:00.000Z'),
end: new Date('2026-03-10T09:30:00.000Z'),
allDay: false,
categoryId: 'meeting'
},
{
id: '7',
title: '1:1 Sarah',
start: new Date('2026-03-11T14:00:00.000Z'),
end: new Date('2026-03-11T15:00:00.000Z'),
allDay: false,
categoryId: 'meeting'
},
{ id: '8', title: 'Retro', start: new Date('2026-03-12T00:00:00.000Z'), categoryId: 'meeting' },
{
id: '9',
title: 'Code Freeze',
start: new Date('2026-03-14T00:00:00.000Z'),
categoryId: 'deadline'
},
{
id: '10',
title: 'Konferenz',
start: new Date('2026-03-16T00:00:00.000Z'),
end: new Date('2026-03-18T00:00:00.000Z'),
categoryId: 'social',
description: 'SvelteConf Berlin'
},
{
id: '11',
title: 'Sprint Review',
start: new Date('2026-03-19T00:00:00.000Z'),
categoryId: 'meeting'
},
{
id: '12',
title: 'Hackathon',
start: new Date('2026-03-23T00:00:00.000Z'),
end: new Date('2026-03-24T00:00:00.000Z'),
categoryId: 'social'
},
{
id: '13',
title: 'Quartalsbericht',
start: new Date('2026-03-25T00:00:00.000Z'),
categoryId: 'deadline'
},
{
id: '14',
title: 'Sprint Planning',
start: new Date('2026-03-26T00:00:00.000Z'),
categoryId: 'meeting'
},
{
id: '15',
title: 'Go Live',
start: new Date('2026-03-31T00:00:00.000Z'),
categoryId: 'deadline'
}
];
const categories = [
{ id: 'meeting', label: 'Meeting', color: '#8b5cf6' },
{ id: 'deadline', label: 'Deadline', color: '#ef4444' },
{ id: 'focus', label: 'Fokuszeit', color: '#3b82f6' },
{ id: 'social', label: 'Social', color: '#06b6d4' }
];
</script>
<Calendar
{events}
{categories}
/>01 Examples
Events in a month view
import type { CalendarEvent } from '@urbicon-ui/blocks';
const events: CalendarEvent[] = [
// One day: start only.
{ id: '1', title: 'Code freeze', start: new Date(2026, 2, 18), categoryId: 'deadline' },
// A span: add end, and the event draws across every day between.
{ id: '2', title: 'Sprint 14', start: new Date(2026, 2, 9), end: new Date(2026, 2, 20) },
// A series: one object plus a rule, expanded by the calendar.
// byDay is 0-6 (Sunday-Saturday): on `weekly` it generates one occurrence
// per listed day, on `daily` it filters the days the interval produces.
// interval skips n periods; until ends the series (inclusive).
{
id: '3',
title: 'Standup',
start: new Date(2026, 2, 2),
recurrence: {
frequency: 'weekly',
byDay: [1, 2, 3, 4, 5],
until: new Date(2026, 2, 31)
}
},
{
id: '4',
title: 'Sprint review',
start: new Date(2026, 2, 6),
recurrence: { frequency: 'weekly', interval: 2, byDay: [5] }
}
];
<Calendar {events} {categories} showLegend showWeekNumbers />Week, year and agenda views
<!-- One component, one prop. The default view is "month". -->
<Calendar
view="week"
{events}
{categories}
showTimeGrid
timeGridStartHour={8}
timeGridEndHour={18}
timeGridInterval={30}
/>
<Calendar view="year" views={['month', 'year']} {events} {categories} defaultYear={2026} />
<Calendar view="agenda" {events} {categories} agendaDays={21} />Constrained selection
<!-- Two clicks pick a range; minDate/maxDate bound both ends. -->
<Calendar
selectionMode="range"
bind:value
variant="bordered"
minDate={new Date(2026, 2, 1)}
maxDate={new Date(2026, 3, 30)}
/>
<!-- disabledDates locks named days, isDateDisabled locks a rule. -->
<Calendar
bind:value={selectedDate}
variant="bordered"
{minDate}
{maxDate}
disabledDates={holidays}
isDateDisabled={(d) => d.getDay() === 0 || d.getDay() === 6}
/>Custom day cells – heatmap
<!-- urbicon-ignore raw-tailwind-color — the emerald ramp IS the demo: a
contribution-heatmap day cell, where four fixed opacity steps of one hue encode
the count. A semantic token has one value and cannot express a scale. -->
<script lang="ts">
import { Calendar } from '@urbicon-ui/blocks';
import type { CalendarEvent, DayCellContext } from '@urbicon-ui/blocks';
// Realistic activity data spread across the month
const events: CalendarEvent[] = [
{ id: '1', title: 'Standup', start: new Date(2026, 2, 2) },
{ id: '2', title: 'Code Review', start: new Date(2026, 2, 3) },
{ id: '3', title: 'Standup', start: new Date(2026, 2, 3) },
{ id: '4', title: 'Standup', start: new Date(2026, 2, 4) },
{ id: '5', title: 'Planning', start: new Date(2026, 2, 5) },
{ id: '6', title: 'Standup', start: new Date(2026, 2, 5) },
{ id: '7', title: 'Review', start: new Date(2026, 2, 5) },
{ id: '8', title: 'Standup', start: new Date(2026, 2, 9) },
{ id: '9', title: 'Deep Work', start: new Date(2026, 2, 9) },
{ id: '10', title: 'Standup', start: new Date(2026, 2, 10) },
{ id: '11', title: 'Release', start: new Date(2026, 2, 10) },
{ id: '12', title: 'Hotfix', start: new Date(2026, 2, 10) },
{ id: '13', title: 'Monitoring', start: new Date(2026, 2, 10) },
{ id: '14', title: 'Standup', start: new Date(2026, 2, 11) },
{ id: '15', title: 'Standup', start: new Date(2026, 2, 12) },
{ id: '16', title: 'Retro', start: new Date(2026, 2, 12) },
{ id: '17', title: 'Standup', start: new Date(2026, 2, 16) },
{ id: '18', title: 'Sprint Review', start: new Date(2026, 2, 16) },
{ id: '19', title: 'Demo', start: new Date(2026, 2, 16) },
{ id: '20', title: 'Standup', start: new Date(2026, 2, 17) },
{ id: '21', title: 'Hackathon', start: new Date(2026, 2, 19) },
{ id: '22', title: 'Hackathon', start: new Date(2026, 2, 19) },
{ id: '23', title: 'Hackathon', start: new Date(2026, 2, 19) },
{ id: '24', title: 'Demo', start: new Date(2026, 2, 19) },
{ id: '25', title: 'Hackathon', start: new Date(2026, 2, 19) },
{ id: '26', title: 'Standup', start: new Date(2026, 2, 20) },
{ id: '27', title: 'Standup', start: new Date(2026, 2, 23) },
{ id: '28', title: 'Planning', start: new Date(2026, 2, 23) },
{ id: '29', title: 'Standup', start: new Date(2026, 2, 24) },
{ id: '30', title: 'Review', start: new Date(2026, 2, 25) },
{ id: '31', title: 'Standup', start: new Date(2026, 2, 25) },
{ id: '32', title: 'Deploy', start: new Date(2026, 2, 25) },
{ id: '33', title: 'Standup', start: new Date(2026, 2, 26) },
{ id: '34', title: 'Standup', start: new Date(2026, 2, 30) },
{ id: '35', title: 'Quartalsbericht', start: new Date(2026, 2, 31) },
{ id: '36', title: 'Deploy', start: new Date(2026, 2, 31) },
{ id: '37', title: 'Review', start: new Date(2026, 2, 31) }
];
/** Map event count to a heatmap intensity level (0–4) */
function heatLevel(count: number): number {
if (count === 0) return 0;
if (count === 1) return 1;
if (count === 2) return 2;
if (count <= 4) return 3;
return 4;
}
const heatBg = [
'', // 0 – no bg
'bg-emerald-500/15',
'bg-emerald-500/30',
'bg-emerald-500/50',
'bg-emerald-500/75'
];
// Heatmap ink, mode-aware via the CSS light-dark() function (darker emerald in
// light mode, lighter in dark). This follows `color-scheme` natively — incl.
// system mode, where there is no `.dark` class — so it needs no `dark:` override
// (which the design linter flags and which would silently break in system mode).
const heatText = [
'text-text-primary', // 0 – no heat
'text-[color:light-dark(var(--color-emerald-700),var(--color-emerald-300))]',
'text-[color:light-dark(var(--color-emerald-800),var(--color-emerald-200))]',
'text-[color:light-dark(var(--color-emerald-900),var(--color-emerald-100))]',
'text-[color:light-dark(white,var(--color-emerald-950))]'
];
</script>
<div class="max-w-sm">
<Calendar
{events}
showEventList
showViewSwitcher={false}
defaultMonth={2}
defaultYear={2026}
locale="de-DE"
>
{#snippet dayCell(ctx: DayCellContext)}
{@const level = heatLevel(ctx.events.length)}
<button
class="flex h-10 w-full items-center justify-center rounded-md text-sm tabular-nums transition-all
{ctx.isOutsideMonth ? 'opacity-20' : ''}
{ctx.isDisabled ? 'cursor-not-allowed opacity-30' : 'cursor-pointer'}
{ctx.isSelected ? 'ring-text-primary ring-2 ring-offset-1' : ''}
{!ctx.isDisabled && !ctx.isSelected ? 'hover:ring-border-default hover:ring-1' : ''}
{heatBg[level]}
{ctx.isToday ? 'font-black underline decoration-2 underline-offset-2' : ''}
{level > 0
? heatText[level]
: ctx.isOutsideMonth
? 'text-text-quaternary'
: 'text-text-primary'}"
>
{ctx.date.getDate()}
</button>
{/snippet}
</Calendar>
<!-- Heatmap legend -->
<div class="mt-3 flex items-center justify-end gap-1.5 px-3">
<span class="text-text-tertiary text-xs">Weniger</span>
{#each [0, 1, 2, 3, 4] as lvl (lvl)}
<span class="border-border-subtle size-3 rounded-sm border {heatBg[lvl] || 'bg-surface-base'}"
></span>
{/each}
<span class="text-text-tertiary text-xs">Mehr</span>
</div>
</div>
02 Accessibility
ARIA Roles
The month grid uses role="grid" with role="row" for weeks and role="gridcell" for days. Each cell carries aria-selected, aria-disabled, and aria-current="date" for today.
Keyboard Navigation
← → move focus between days, ↑ ↓ between weeks. Home/End jump to the start/end of the week. PageUp/PageDown navigate between months. Enter/Space select the focused day. Focus rings use focus-visible: for keyboard-only visibility.
Screen Reader Labels
Every day cell has an aria-label with the full date
(e.g. "Thursday, March 12, 2026"). Navigation buttons have descriptive labels. Event dots
are aria-hidden; event details remain accessible through
the event list.
Touch & Gestures
Horizontal swiping navigates between months/weeks/days. Touch input is handled through the
Pointer Events API. Swipe gestures can be disabled via swipeable={false}. Animations respect prefers-reduced-motion.
Internationalization
All visible text and ARIA labels use i18n keys via bt(). Date formatting relies on the native Intl.DateTimeFormat with the configured locale. Weekday names, month names, and date formats
adapt automatically.
03 API Reference
Prop | Type | Default | Description | |
|---|---|---|---|---|
agendaDays | number | 30 | Number of days shown in agenda view. | |
animated | boolean | true | Enable animated transitions for navigation. | |
categories | CalendarEventCategory[] | [] | Event categories for color coding and legend. | |
children | Snippet | — | Default children snippet for custom layout composition. | |
class | string | — | Extra CSS classes on the root element. | |
dayCell | Snippet<[DayCellContext]> | — | Custom snippet for rendering a day cell. | |
defaultDate | Date | — | Initial reference day the grid is anchored on, without selecting it. Use
this to open a **week** or **day** view on a specific week — defaultMonth/
defaultYear resolve to the 1st, whose week can fall mostly in the previous
month. Ignored when value is set (the selection anchors instead); takes
priority over defaultMonth/defaultYear. Read at mount only. | |
defaultMonth | number | — | Initial displayed month (0–11). Used only when value and defaultDate
are unset; when value is provided, the calendar opens on the value's
month. Best for month/year views — for week/day views prefer defaultDate.
Defaults to current month. | |
defaultYear | number | — | Initial displayed year. Used only when value is unset; when
value is provided, the calendar opens on the value's year.
Defaults to current year. | |
disabled | boolean | false | Disable the entire calendar. | |
disabledDates | Date[] | — | Specific dates that are disabled (not selectable). | |
draggable | boolean | false | Enable drag & drop to move events between dates. | |
eventItem | Snippet<[EventItemContext]> | — | Custom snippet for rendering an event item in the list-based views (agenda and the month event list). Time-grid views (week/day) render events through their hour grid and ignore this snippet. | |
eventPopover | boolean | false | Show a rich popover on hover/focus for days with events (month view). | |
events | CalendarEvent[] | [] | Array of events to display on the calendar. | |
fixedWeeks | boolean | false | Always show 6 weeks in the grid. | |
header | Snippet<[HeaderContext]> | — | Custom snippet for the header area. | |
highlightToday | boolean | true | Visually mark today across every view — the month cell, the week column
header, the year mini-day, the agenda day header and the mini calendar.
aria-current="date" is **not** affected: it is a semantic pointer, so a
screen-reader user keeps the orientation a purely visual preference should
not take away. Neither is the time grid's current-time line, which marks the
current *time* rather than the day (see timeGridHourHeight's neighbours).
Matches Planner's prop of the same name. | |
isDateDisabled | (date: Date) => boolean | — | Function to test whether a date is disabled. | |
locale | string | 'auto' | BCP 47 locale tag for date formatting — month names, weekday names and the
header title. Defaults to 'auto', which follows the active
<I18nProvider> locale, so an app that already declares its language does
not have to repeat it here. SSR-safe: the locale comes from context, so the
server and the client resolve the same tag (Intl with undefined would
follow the runtime and disagree across hydration). Falls back to the base
locale (en) when no provider is mounted. Pass an explicit tag
(e.g. 'de-DE', 'ja-JP') to override.
Until 2026-07-31 this defaulted to the literal 'de-DE', so an
English app rendered German month names unless every date component was
passed locale by hand. | |
maxDate | Date | — | Latest selectable/navigable date. | |
minDate | Date | — | Earliest selectable/navigable date. | |
miniCalendarPosition | leftright | 'left' | Position of the mini calendar sidebar. | |
onDateClick | (date: Date) => void | — | Fires when a date cell is clicked (regardless of selection change). | |
onDateCreate | (date: Date, view: CalendarViewMode) => void | — | Fires on double-click on a day cell for event creation. The consumer shows their own form. | |
onDayChange | (date: Date) => void | — | Fires when the displayed day changes (day view). | |
onEventClick | (event: CalendarEvent) => void | — | Fires when an event is clicked. | |
onEventMove | (event: CalendarEvent, newStart: Date, newEnd: Date) => void | — | Fires when an event is moved via drag & drop. | |
onEventResize | (event: CalendarEvent, newEnd: Date) => void | — | Fires when an event is resized via drag handle. | |
onMonthChange | (month: number, year: number) => void | — | Fires when the displayed month/year changes via navigation. | |
onNavigate | (date: Date, range: DateRange) => void | — | Fires after **any** navigation, in every view, with the new reference date
and the visible range — load data here. The per-view callbacks
(onMonthChange / onWeekChange / onDayChange) still fire and are the
better fit when you only care about one view; this one spares you
reconstructing the window yourself. The range is view-accurate: month spans
the padded cell grid (spill days included), week/day the visible days, year
1 Jan–31 Dec, agenda agendaDays from the 1st. Matches Planner's
onNavigate. | |
onTimeSlotCreate | (start: Date, end: Date) => void | — | Fires on click on an empty time slot for event creation. Returns default 1h duration. | |
onValueChange | (value: CalendarSelection) => void | — | Fires when the selected date(s) change. | |
onViewChange | (view: CalendarViewMode) => void | — | Fires when the view mode changes. | |
onWeekChange | (weekStart: Date) => void | — | Fires when the displayed week changes (week view). | |
preset | string | — | Apply a named preset registered via <BlocksProvider presets={{ Calendar: {...} }}>.
Prefer this over class overrides when the requested look falls outside the
semantic intent palette — presets keep hover/active/dark-mode logic coherent
and make the custom look reusable across the project. | |
resizable | boolean | false | Enable resize handles on timed events in the time grid. | |
selectionMode | singlerangemultiple | 'single' | SelectionMode property for the Calendar component | |
showEventList | boolean | — | Whether to show the detail list when a date is selected. Auto-enabled when events are provided. | |
showLegend | boolean | — | Whether to show the built-in legend. Defaults to true when categories are provided. | |
showMiniCalendar | boolean | false | Show a mini month calendar sidebar (week/day/agenda views). | |
showOutsideDays | boolean | true | Show days from previous/next months to fill the grid. | |
showTimeGrid | boolean | — | Show time grid in week/day views. Auto-detected from events with allDay: false. | |
showViewSwitcher | boolean | true | Show the view switcher in the header. Below sm its labels condense to
their short form; the full label stays the accessible name. | |
showWeekNumbers | boolean | false | Show ISO week numbers in the left margin. | |
size | smmdlg | 'md' | Size variant that controls dimensions and spacing of the Calendar | |
slotClasses | Partial<Record<CalendarSlots, string>> | — | Per-slot class overrides. | |
swipeable | boolean | true | Enable swipe gestures for touch navigation. | |
timeGridEndHour | number | 20 | Last visible hour in time grid (exclusive). | |
timeGridHourHeight | number | — | Height of one hour row in the time grid, in pixels. Left unset it follows
size (sm 40 · md 48 · lg 64), which is the only reason a nine-hour day
costs 432 px of card height whether or not the consumer has it. Set a
smaller number for a compact day, a larger one for finer slots. Drives the
label column, the slot rows, the grid's min-height and the auto-scroll
to the current time, so it is a number rather than a CSS variable — the
scroll math has to read it. | |
timeGridInterval | 3060 | 60 | Time slot interval in minutes. | |
timeGridStartHour | number | 7 | First visible hour in time grid. | |
unstyled | boolean | — | Strip all default tv() classes. | |
value | CalendarSelection | — | Currently selected date(s). Supports bind:value. | |
variant | defaultborderedghost | 'default' | Visual style variant for the Calendar component | |
view | CalendarViewMode | 'month' | Active view mode. Supports bind:view. | |
views | CalendarViewMode[] | ['month', 'week', 'day', 'year', 'agenda'] | Which views appear in the view switcher. | |
weekStartsOn | 0123 +3 more | 1 | First day of the week. 0 = Sunday, 1 = Monday. | |
...CalendarVariants variant | VariantProps | — | Styling variants from CalendarVariants | |
...HTMLAttributes<HTMLDivElement> inherited | HTMLAttributes | — | HTML attributes (excluding: 'children') |
04 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
CalendarSlotName | type | helper | 0 | — | |
CalendarProps | interface | props | 0 | — | |
CalendarHeaderProps | interface | props | 0 | — | |
CalendarVariants | type | variant | 0 | — | |
CalendarSlots | type | variant | 0 | — | |
CalendarEvent | interface | helper | 1 | A single calendar event/appointment. | |
CalendarEventCategory | interface | helper | 1 | Category for grouping events by type with shared color coding. | |
CalendarSelection | type | helper | 1 | Selection value depending on selection mode. | |
CalendarViewMode | type | helper | 2 | Available view modes for the calendar. | |
DateRange | interface | helper | 0 | A date range with inclusive start and end. | |
DayCellContext | interface | helper | 0 | Context passed to custom dayCell snippets. | |
EventItemContext | interface | helper | 0 | Context passed to custom eventItem snippets. | |
HeaderContext | interface | helper | 0 | Context passed to custom header snippets. | |
RecurrenceRule | interface | helper | 0 | Recurrence rule for repeating events. |
05 Installation
Import
import { Calendar, CalendarHeader, CalendarGrid } from '@urbicon-ui/blocks';
import type { CalendarEvent, CalendarEventCategory } from '@urbicon-ui/blocks';