Skip to main content
Urbicon UI

Calendar

An event calendar with month, week, day, year, and agenda views. It handles recurring events, date-range selection, and per-day constraints.

Playground

March 2026
Today
MonTueWedThuFriSatSun
Meeting
Deadline
Focus time
Social
Variant
Size
Selection Mode
<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 planning'
    },
    {
      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: 'Conference',
      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: 'Quarterly report',
      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: 'Focus time', color: '#3b82f6' },
    { id: 'social', label: 'Social', color: '#06b6d4' }
  ];
</script>

<Calendar
  {events}
  {categories}
/>

01 Examples

Events in a month view

The month grid is the default. An event can cover a single day, span consecutive days, or repeat on a schedule; the calendar expands a recurrence rule into its occurrences, so you define it once. Clicking a day opens its detail list.
März 2026
Today
MoDiMiDoFrSaSo
9
10
11
12
13
14
Meeting
Deadline
Social
Health
März 2026
Today
MoDiMiDoFrSaSo
SvelteConf
Sprint 14
Ski trip
Hackathon
Release Week
Konferenz
Urlaub
Sprint
Deadline
March 2026
Today
MonTueWedThuFriSatSun
9
10
11
12
13
14
Standup
Review
Sports
Waste collection
import type { CalendarEvent, CalendarEventCategory } 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 repeats every 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] }
  }
];

// A category colours its events' dots and labels the legend. `color` takes
// any CSS colour or a Tailwind class; `categoryId` on an event points here.
const categories: CalendarEventCategory[] = [
  { id: 'deadline', label: 'Deadline', color: '#ef4444' }
];

<Calendar {events} {categories} showLegend showWeekNumbers />

Week, year and agenda views

Same component, same events, one prop. Pick week with a time grid when the hour matters, year when the question is distribution across months, and agenda when the reader wants a list rather than a grid. The agenda's window runs agendaDays days from the reference date and its arrows step that whole window, so a one-day list is agendaDays={1} beside a defaultDate. The snippet shows only what differs between the three.
KW 11 – März 2026
Today
08:00
08:30
09:00
09:30
10:00
10:30
11:00
11:30
12:00
12:30
13:00
13:30
14:00
14:30
15:00
15:30
16:00
16:30
17:00
17:30
Meeting
Focus time
Break
2026
Today
Release
Holiday
Vacation
Mar 10 – 30, 2026
Today
Tuesday, March 10, 2026
Sprint Planning
Sprint 14 planning with the team
Wednesday, March 11, 2026
Dentist
10:00 AM
Code Review
Thursday, March 12, 2026
Yoga class
6:00 PM
Saturday, March 14, 2026
Release v3.0
Production Deployment
Sunday, March 15, 2026
Max's birthday party
Monday, March 16, 2026
Retro
Wednesday, March 18, 2026
Parent-teacher night
7:30 PM
Friday, March 20, 2026
Hackathon
Build an AI prototype
Saturday, March 21, 2026
Hackathon
Demo & presentation
Sunday, March 22, 2026
Morning run
7:00 AM
Wednesday, March 25, 2026
Quarterly report
Work
Personal
Health
<!-- One component, one prop. The default view is "month". -->
<Calendar
  view="week"
  {events}
  {categories}
  timeGridStartHour={8}
  timeGridEndHour={18}
  timeGridInterval={30}
/>

<Calendar view="year" views={['month', 'year']} {events} {categories} defaultYear={2026} />

<!-- The agenda counts its days from the reference date, so this lists three
     weeks starting on 10 March — and agendaDays={1} beside a defaultDate is
     one day's list. -->
<Calendar
  view="agenda"
  {events}
  {categories}
  agendaDays={21}
  defaultDate={new Date(2026, 2, 10)}
/>

Constrained selection

Set selectionMode='range' to pick a start and end in two clicks. Three props constrain what's pickable: minDate/maxDate bound the range, disabledDates locks named days like public holidays, and isDateDisabled locks a rule like weekends.
March 2026
Today
MonTueWedThuFriSatSun
March 2026
Today
MonTueWedThuFriSatSun
<!-- Two clicks pick a range; minDate/maxDate cap what you can reach and pick. -->
<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

The dayCell snippet lets you render each day yourself, for anything the event model does not cover. Here it draws a GitHub-style heatmap: more events on a day, more intense green.
March 2026
Today
MonTueWedThuFriSatSun
23
24
25
26
27
28
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
Less More
<!-- 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: 'Quarterly report', 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}>
    {#snippet dayCell(ctx: DayCellContext)}
      {@const level = heatLevel(ctx.events.length)}
      <!-- A heatmap cell is read-only, so it renders as a div, not a button. -->
      <div
        class="flex h-10 w-full items-center justify-center rounded-md text-sm tabular-nums
          {ctx.isOutsideMonth ? 'opacity-20' : ''}
          {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()}
      </div>
    {/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">Less</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">More</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 and days. In the week view, when the time grid overflows sideways on a narrow screen, a horizontal drag pans the grid instead of navigating; once it fits, swiping navigates weeks again. Swipe gestures can be disabled with swipeable={false}. Animations respect prefers-reduced-motion.

Narrow Viewports

Below phone widths the week keeps its seven columns behind a horizontal scroll with a sticky time column, so no information is dropped. Where a single day reads better, drive view from a MediaQuery (with a false server fallback, so a prerendered page does not flip on hydration) and pass views without week, so the switcher offers day instead.

Internationalization

All visible text and ARIA labels are localized through i18n keys. Date formatting relies on the native Intl.DateTimeFormat with the configured locale. Weekday names, month names, and date formats adapt automatically.

03 API Reference

62 props
62 props
Prop
Type
Default
Description

04 Types

Local type definitions used by this component.

14 types
Name
Kind
Category
Used by
Description

05 Installation

Import

import { Calendar, CalendarHeader, CalendarGrid } from '@urbicon-ui/blocks';
import type { CalendarEvent, DateCategory } from '@urbicon-ui/blocks';