Event calendar (rich cells + recurrence)

SvCalendar extended into a FullCalendar-style scheduler: the `day` snippet fills each cell with event chips, and `recurrence` repeat patterns (weekly standups, every-other-week sprint, monthly invoices) mark repeating days and generate their events. Navigation, keyboard, selection and theming stay the component's. Pick a day for its agenda.

A live, editable Svelte 5 component example from the SvGrid gallery (Date & Time). Read the SvGrid UI component docs for the full API.

About this example

SvCalendar extended into an event calendar in Svelte 5. The day snippet fills each cell with event chips, and recurrence rules such as weekly standups, an every-other-week sprint and monthly invoices mark repeating days and generate their events through matchesRecurrence, with the component still owning navigation, keyboard, selection and theming across a six-week grid. Pick a day to see its agenda beside the calendar.

Event calendar - the extended SvCalendar as a FullCalendar-style scheduler. Two new capabilities do the work:

  • the day snippet renders rich content (event chips) in each cell, and
  • recurrence marks repeating days (the standup ring) and drives the recurring events via matchesRecurrence. SvCalendar still owns navigation, keyboard, selection and theming.

Imports, features and API used

Imports: @svgrid/grid

Frequently asked questions

How do I render content inside a day cell?

Provide the day snippet. It receives the date and the calendar's state for that cell and renders whatever you return, event chips here, while the component keeps the cell's selection and focus behaviour.

How are recurring events defined?

As recurrence rules with a frequency, an interval and an anchor date. The calendar marks days that match, and the demo calls matchesRecurrence to expand the rules into the agenda for the selected day.

Is this the full scheduler view?

No. This is the calendar component with rich cells, suited to month views and agendas. The grid's scheduler prop renders resources on a time axis with drag-and-drop.

Related documentation

Related articles

Source code (338-event-calendar.svelte)

<script lang="ts">
  /**
   * Event calendar - the extended SvCalendar as a FullCalendar-style scheduler.
   * Two new capabilities do the work:
   *   - the `day` snippet renders rich content (event chips) in each cell, and
   *   - `recurrence` marks repeating days (the standup ring) and drives the
   *     recurring events via `matchesRecurrence`.
   * SvCalendar still owns navigation, keyboard, selection and theming.
   */
  import { SvCalendar, matchesRecurrence, type RecurrenceRule, type CalendarDayState } from '@svgrid/grid'

  type Category = 'meeting' | 'deadline' | 'review' | 'focus' | 'personal'
  type Ev = { title: string; time?: string; category: Category }

  const CATS: Record<Category, { label: string; color: string }> = {
    meeting: { label: 'Meeting', color: '#2563eb' },
    deadline: { label: 'Deadline', color: '#dc2626' },
    review: { label: 'Review', color: '#7c3aed' },
    focus: { label: 'Focus', color: '#0891b2' },
    personal: { label: 'Personal', color: '#16a34a' },
  }

  const now = new Date()
  const iso = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
  const rel = (offset: number) => { const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + offset); return d }

  // --- Recurring events, expressed as repeat patterns ------------------------
  const recurring: { rule: RecurrenceRule; ev: Ev }[] = [
    { rule: { freq: 'weekly', weekdays: [1, 2, 3, 4, 5] }, ev: { title: 'Standup', time: '09:15', category: 'meeting' } },
    { rule: { freq: 'weekly', weekdays: [1], interval: 2, from: iso(rel(-(now.getDay() + 6) % 14)) }, ev: { title: 'Sprint planning', time: '10:00', category: 'meeting' } },
    { rule: { freq: 'weekly', weekdays: [5] }, ev: { title: 'Weekly review', time: '16:00', category: 'review' } },
    { rule: { freq: 'monthly', day: 1 }, ev: { title: 'Invoices due', category: 'deadline' } },
  ]
  // The union of recurring rules -> SvCalendar draws the "repeats" ring on these days.
  const recurrenceRules = recurring.map((r) => r.rule)

  // --- One-off events (keyed by ISO date) ------------------------------------
  const oneOff: Record<string, Ev[]> = {
    [iso(rel(0))]: [{ title: '1:1 with Sam', time: '11:00', category: 'meeting' }, { title: 'Focus: API refactor', time: '13:00', category: 'focus' }, { title: 'Ship v1.3', category: 'deadline' }],
    [iso(rel(1))]: [{ title: 'Customer call', time: '15:30', category: 'meeting' }],
    [iso(rel(2))]: [{ title: 'Team lunch', time: '12:00', category: 'personal' }, { title: 'Retro', time: '16:00', category: 'review' }],
    [iso(rel(4))]: [{ title: 'Release freeze', category: 'deadline' }],
    [iso(rel(5))]: [{ title: 'Conference talk', time: '09:00', category: 'personal' }],
    [iso(rel(-2))]: [{ title: 'Design review', time: '14:00', category: 'review' }],
    [iso(rel(9))]: [{ title: 'Security audit', category: 'deadline' }],
  }

  function eventsOn(date: Date): Ev[] {
    const out: Ev[] = []
    for (const { rule, ev } of recurring) if (matchesRecurrence(date, rule)) out.push(ev)
    out.push(...(oneOff[iso(date)] ?? []))
    return out.sort((a, b) => (a.time ?? '').localeCompare(b.time ?? ''))
  }

  let selected = $state<Date | null>(rel(0))
  const selectedEvents = $derived(selected ? eventsOn(selected) : [])
  const longDate = (d: Date) => d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })
  const MAX = 3
</script>

<div class="wrap">
  <header>
    <h2>Event calendar</h2>
    <p>The extended <code>SvCalendar</code>: the <code>day</code> snippet fills each cell with event chips, and <code>recurrence</code> patterns mark repeating days (the ring on weekday standups) and generate their events. Navigation, keyboard and theming are the component's.</p>
  </header>

  <div class="layout">
    <SvCalendar
      value={selected}
      onChange={(d) => (selected = d[0] ?? null)}
      recurrence={recurrenceRules}
      weeks={6}
    >
      {#snippet day(date: Date, _state: CalendarDayState)}
        {@const evs = eventsOn(date)}
        {#each evs.slice(0, MAX) as e, i (i)}
          <span class="ev" style:--c={CATS[e.category].color} title={`${e.time ? e.time + ' ' : ''}${e.title}`}>
            <span class="ev__dot"></span>
            {#if e.time}<span class="ev__time">{e.time}</span>{/if}
            <span class="ev__title">{e.title}</span>
          </span>
        {/each}
        {#if evs.length > MAX}<span class="ev__more">+{evs.length - MAX} more</span>{/if}
      {/snippet}
    </SvCalendar>

    <aside class="detail">
      {#if selected}
        <div class="detail__date">{longDate(selected)}</div>
        {#if selectedEvents.length}
          <ul class="detail__list">
            {#each selectedEvents as e, i (i)}
              <li class="detail__item" style:--c={CATS[e.category].color}>
                <span class="ev__dot"></span>
                <span class="detail__time">{e.time ?? 'All day'}</span>
                <span class="detail__title">{e.title}</span>
                <span class="detail__cat">{CATS[e.category].label}</span>
              </li>
            {/each}
          </ul>
        {:else}
          <p class="detail__empty">No events.</p>
        {/if}
      {:else}
        <p class="detail__empty">Pick a day.</p>
      {/if}

      <div class="legend">
        {#each Object.entries(CATS) as [key, c] (key)}
          <span class="legend__it"><span class="ev__dot" style:--c={c.color}></span>{c.label}</span>
        {/each}
      </div>
    </aside>
  </div>
</div>

<style>
  .wrap { padding: 20px; max-width: 1040px; }
  header h2 { margin: 0 0 4px; font-size: 20px; font-weight: 700; }
  header p { margin: 0 0 18px; color: var(--sg-muted, #64748b); font-size: 13.5px; line-height: 1.5; }
  header code { background: var(--sg-row-hover-bg, #f1f5f9); padding: 1px 5px; border-radius: 4px; font-size: 12px; }

  .layout { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 18px; align-items: start; }
  @media (max-width: 820px) { .layout { grid-template-columns: 1fr; } }

  /* The calendar fills the left column (rich mode makes cells flex to fit). */
  .layout :global(.sv-cal) { width: 100%; }
  .layout :global(.sv-cal__main) { width: 100%; }

  /* Event chips inside each day cell (rendered by the `day` snippet). */
  .ev {
    display: flex; align-items: center; gap: 5px; min-width: 0;
    padding: 1px 6px; border-radius: 5px; font-size: 11px; line-height: 1.35;
    background: color-mix(in srgb, var(--c) 14%, transparent);
    color: color-mix(in srgb, var(--c) 74%, var(--sg-fg, #0f172a));
  }
  .ev__dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--c, var(--sg-accent, #2563eb)); }
  .ev__time { flex: none; font-weight: 650; font-variant-numeric: tabular-nums; opacity: 0.85; }
  .ev__title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .ev__more { font-size: 10.5px; font-weight: 600; color: var(--sg-muted, #64748b); padding-inline-start: 2px; }
  /* Phone: a day cell is ~45px wide, so dot + time + title only ever showed
     "09:" - drop the time and let the title take what is left. */
  @media (max-width: 639px) {
    .ev { padding: 1px 4px; gap: 3px; }
    .ev__time { display: none; }
  }

  .detail {
    border: 1px solid var(--sg-border, #e2e8f0); border-radius: 12px; padding: 14px 16px;
    background: var(--sg-bg, #fff); display: flex; flex-direction: column; gap: 10px;
  }
  .detail__date { font-size: 14px; font-weight: 650; }
  .detail__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
  .detail__item { display: grid; grid-template-columns: 8px 54px 1fr; grid-template-areas: 'dot time title' '. cat cat'; align-items: center; gap: 3px 8px; font-size: 13px; }
  .detail__item .ev__dot { grid-area: dot; }
  .detail__time { grid-area: time; color: var(--sg-muted, #64748b); font-variant-numeric: tabular-nums; }
  .detail__title { grid-area: title; font-weight: 550; }
  .detail__cat { grid-area: cat; justify-self: start; font-size: 11px; padding: 1px 8px; border-radius: 999px; color: var(--c); background: color-mix(in srgb, var(--c) 13%, transparent); }
  .detail__empty { color: var(--sg-muted, #94a3b8); font-size: 13px; margin: 4px 0; }
  .legend { display: flex; flex-wrap: wrap; gap: 10px 14px; margin-top: auto; padding-top: 10px; border-top: 1px solid var(--sg-border, #eef1f5); }
  .legend__it { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--sg-muted, #64748b); }
</style>

View this example on GitHub

More Date & Time examples

  • Calendar - SvCalendar: a themeable month/year/decade calendar with single / range / week / multi selection, min-max, restricted + important dates and week numbers. The same component SvGrid mounts to edit a date cell - and usable standalone in any SvGrid app.
  • Time picker - SvTimePicker: an analog clock-dial picker with 12/24-hour, minute snapping and hour to minute auto-switch. Drag the hand or click a number. The SvGrid time cell editor, usable standalone in any SvGrid app.
  • Date-time picker - SvDateTimePicker: a formatted input plus a portalled dropdown with DATE / TIME tabs (calendar + clock). Type a masked value or pick it; invalid input reverts. min/max, nullable, 12/24-hour, spin buttons. The SvGrid datetime cell editor, standalone in any SvGrid app.
  • Date-range input - SvDateRangeInput: a compact start/end field that opens a two-month range calendar with one-click presets (Today, Last 7 days, ...). Composes the same headless range engine as SvCalendar, and carries the shared editor contract - label, hint, error validation, dir (RTL) and localizable messages.
  • Calendar - range + presets - SvCalendar as a date-range picker: selectionMode="range" with a one-click presets rail (Today, Last 7 days, This month, Year to date...), animated month navigation and mouse-wheel scrolling. Presets resolve relative to today. Same component, no extra dependency.