@nomideusz/svelte-calendar v1.8.2
A themeable Svelte 5 calendar: day and week Planner time grids, a multi-week Scroll view, Agenda lists, a Month grid and touch-first Mobile layouts, with drag to move, resize and create events, timezones, a headless API, an embeddable web component, and a smart auto-theme that adapts to any page.
On this page
Installation#
pnpm add @nomideusz/svelte-calendar
Requirements:
- Svelte
^5.29.0(peer dependency: the package uses attachments,{@attach}). - Runtime dependencies (installed for you):
date-fns,date-fns-tz,@chenglou/pretext. - Node ≥ 22 is only needed to develop the package itself, not to use it.
Quick Start#
<script>
import { Calendar, createMemoryAdapter } from '@nomideusz/svelte-calendar';
// Today at a given hour, so the events land in the week the calendar opens on
const today = new Date();
const at = (hour) => new Date(today.getFullYear(), today.getMonth(), today.getDate(), hour);
let events = $state([
{ id: '1', title: 'Team Sync', start: at(9), end: at(10) },
{ id: '2', title: 'Lunch', start: at(12), end: at(13) },
]);
// A new adapter whenever the events change; the calendar reloads without blanking
const adapter = $derived(createMemoryAdapter(events));
// Drag on empty time (or press Enter on a day column) to create
function create({ start, end }) {
events = [...events, { id: crypto.randomUUID(), title: 'New event', start, end }];
}
// Drag an event to move it, or its edge to resize it
function move(event, start, end) {
events = events.map((e) => (e.id === event.id ? { ...e, start, end } : e));
}
</script>
<Calendar {adapter} view="week-planner" oneventcreate={create} oneventmove={move} />
That's a working, editable calendar. The calendar stores nothing on create: without oneventcreate, dragging on empty time does nothing. Moves and resizes write to the adapter by themselves, and oneventmove keeps your own data in step, so a move survives the next new adapter. The default auto theme probes your page's background, accent color and fonts, so the calendar adapts to any design. Events without a color draw in ink; to color them automatically, give the adapter a palette.
Views#
Without view the calendar opens on day-planner, the first registered view. The Quick Start, the demo, the widget and createCalendar open on week-planner, so pass view="week-planner" for the week grid.
The default registry (defaultViews) holds eight views. Six are the ones you choose with view; the two *-mobile views are what the others swap to in a narrow container (see Mobile), so the demo's view list shows six.
| View ID | Mode | What it is |
|---|---|---|
day-plannerTry in demo |
day | Vertical time grid, one column. The default when view is unset. |
week-plannerTry in demo |
week | The same time grid, one column per day (days, default 7). |
week-scrollTry in demo |
week | Weeks stacked in one infinite vertical scroller (Hey-Calendar style): each chip sits at the height of its start time, month names run down the side. Drag to move, drop onto a day; it scrolls under a drag. |
day-agendaTry in demo |
day | Live list for one day: now / up next / done. |
week-agendaTry in demo |
week | List of the week's days (columns turns it into a timetable grid). |
day-mobile |
day | Touch-first time grid with swipe navigation. Used automatically in a narrow container. |
week-mobile |
week | Touch-first list of the week's days. Used automatically in a narrow container. |
month-gridTry in demo |
month | Week-aligned month grid, chips per day with a "+N more" overflow. |
<Calendar {adapter} view="week-planner" />
<Calendar {adapter} view="week-scroll" />
<Calendar {adapter} view="month-grid" />
Users switch with the built-in pills: Day / Week / Month for the mode, plus view-type pills (Planner / Scroll / Agenda) when a mode has more than one view. Hide them when your app controls the view (view stays reactive after mount):
<Calendar {adapter} view="day-planner" showModePills={false} />
Planner views are designed for direct manipulation:
- Move: drag an event to another day or time; a ghost previews the target before the move commits.
- Resize: drag an event's top/bottom grip to change its duration;
minDuration/maxDurationclamp the result. - Create: press on empty canvas and sweep to draw a range, or click for a default-length slot. Both go through the same validation (blocked slots, disabled dates,
minDuration/maxDuration) and then fireoneventcreate. - External drop: an HTML5 drag from outside the calendar (a class chip, a template) dropped on the planner or
week-scrollfiresonexternaldrop({ start, dataTransfer }).
week-scroll supports move, click-to-create and external drops. In week-agenda with columns and an oneventmove handler, a card can be dragged to another day (it keeps its time of day); that move goes straight to oneventmove without writing the adapter.
The month grid shows up to three chips per day. Clicking a day or its "+N more" fires ondayclick(date); without ondayclick it opens that date in a day view (the day view of the type you last used).
Keyboard#
When focus is inside the calendar:
t: go to today.←/→: previous / next period (mirrored in RTL). Ignored while typing in a field or inside a pill group, where arrows move between pills.- In
month-grid: arrow keys move between days,Enter/Spaceopens the focused day (ondayclick). - In
week-planner/day-planner: the day columns are one tab stop;←/→move between days,Enter/Spacecreates an event (oneventcreate) at the next slot today, or 09:00. On a focused event,Alt+↑/Alt+↓move it onesnapInterval,Alt+←/Alt+→move it a day, andAlt+Shift+↑/Alt+Shift+↓move its end; each goes through the same validation as a drag and firesoneventmove.
Custom Views#
The views prop replaces the registry. Spread defaultViews to add your own view and keep the built-ins:
<script lang="ts">
import { Calendar, defaultViews, type CalendarView } from '@nomideusz/svelte-calendar';
import KanbanDay from './KanbanDay.svelte';
const views: CalendarView[] = [
...defaultViews,
{ id: 'day-kanban', label: 'Kanban', mode: 'day', component: KanbanDay, props: { columns: 3 } },
];
</script>
<Calendar {adapter} {views} view="day-kanban" />
A CalendarView is { id, label, mode: 'day' | 'week' | 'month', component, props? }. label names the view-type pill; views sharing a label across modes are what the Day/Week/Month pills switch between.
The component receives CalendarViewProps (plus your props); declare only the ones it uses:
| Prop | Type | Notes |
|---|---|---|
events |
TimelineEvent[] |
Everything loaded for the current range |
style |
string |
The active --dt-* theme string |
height |
number | null |
Always null: the view fills its container, sized by the Calendar's own height (default 600) |
mode |
'day' | 'week' | 'month' |
|
mondayStart |
boolean |
|
locale |
string | undefined |
|
focusDate |
Date |
|
oneventclick |
(event, anchor?: DOMRect) => void |
Selects the event and calls the host |
oneventcreate |
((range) => void) | undefined |
Already validated; undefined when readOnly or no host handler |
onexternaldrop |
((info) => void) | undefined |
|
readOnly |
boolean |
|
visibleHours |
[number, number] | undefined |
|
selectedEventId |
string | null |
<!-- KanbanDay.svelte -->
<script lang="ts">
import { useCalendarContext, type CalendarViewProps } from '@nomideusz/svelte-calendar';
let { events, oneventclick, columns = 3 }:
Pick<CalendarViewProps, 'events' | 'oneventclick'> & { columns?: number } = $props();
const ctx = useCalendarContext(); // viewState, drag, labels, config…
</script>
<h3>{ctx.labels.today}</h3>
{#each events as ev (ev.id)}
<button onclick={() => oneventclick(ev)}>{ev.title}</button>
{/each}
useCalendarContext() (type CalendarContext) gives a view the running Calendar's engines and config: viewState, drag, commitDrag, labels, readOnly, snapInterval, minColumnWidth, blockedSlots, disabledDates / disabledSet, minDuration / maxDuration, hideDays, equalDays, showDates, compact, columns, isMobile, autoHeight, timezone, oneventmove, oneventhover, ondayclick, eventSnippet, emptySnippet, dayHeaderSnippet and loadRange (a view can widen the range the store loads). Call it at component init, inside a <Calendar>.
The raw view components (Planner, PlannerScroll, Agenda, Mobile, MonthGrid) are exported so you can register them under your own ids. Building blocks for custom views are exported as primitives: EventBlock (an event card), TimeGutter (hour labels), DayHeader, NowIndicator, EmptySlot (click-to-create target) and FloatingPanel (see Event details panel).
Mobile#
When the calendar's container is narrower than 768px, it swaps to touch-first Mobile views with swipe navigation and a compact header:
<!-- Auto-detect (default): switches at a 768px container width -->
<Calendar {adapter} view="week-planner" />
<!-- Force mobile layout -->
<Calendar {adapter} mobile={true} />
<!-- Force desktop layout -->
<Calendar {adapter} mobile={false} />
- MobileDay: vertical time grid with hour labels, swipe left/right to change days, all-day chips at the top, tap-to-create, stable columns for overlapping events.
- MobileWeek: vertical day list with relative labels (Today, Tomorrow, …) and accessible event buttons in each row.
In mobile mode every view except *-agenda and *-mobile is swapped for the {mode}-mobile entry of the same mode when one is registered, custom views included. Agenda views keep their list layout; month-grid has no mobile entry and stays. The mobile header shows only the Day/Week/Month pills.
With 'auto', the first render (and the server render) is the desktop layout; the container is measured on mount, so SSR hydrates cleanly.
Callbacks#
<Calendar
{adapter}
oneventclick={(event, anchor) => console.log('Clicked', event.title, anchor)}
oneventcreate={(range) => console.log('New slot', range.start, range.end)}
oneventmove={(event, start, end) => console.log('Moved', event.title, start, end)}
onexternaldrop={({ start, dataTransfer }) => console.log('Dropped', start, dataTransfer.getData('text/plain'))}
onviewchange={(viewId) => console.log('View', viewId)}
ondatechange={(date) => console.log('Date', date)}
ondayclick={(date) => console.log('Day', date)}
onerror={(error) => console.error('Calendar', error)}
/>
| Callback | When it fires |
|---|---|
oneventclick(event, anchor?) |
An event is clicked. The event is also selected (highlighted across views). anchor is the clicked block's viewport rect where the view has one (planner, week-scroll, month-grid), for positioning a FloatingPanel. |
oneventcreate({ start, end }) |
A drag-create or click-to-create passed validation. The calendar does not store anything: add the event to your data and hand the Calendar the updated adapter. |
oneventmove(event, start, end) |
A drag or resize committed: after the adapter stored it, or when the adapter refused with a read-only error (the host owns persistence then; the block stays where it was dropped). Other adapter failures revert the block and go to onerror. (The week-agenda columns day-drag is the exception: it only calls oneventmove.) |
onexternaldrop({ start, dataTransfer }) |
An outside HTML5 drag was dropped on the grid; start is the pointer's time, snapped. |
onviewchange(viewId) |
Once on mount, then on every view change. |
ondatechange(date) |
Once on mount, then whenever the focused date changes (navigation, scrolling, a tapped day). |
ondayclick(date) |
A month-grid or agenda day is clicked (an instant, like ondatechange). Default: open it in a day view. |
oneventhover(event) |
The pointer enters an event (tooltips, previews). |
onerror(error) |
Adapter load failures and failed drag commits that would otherwise only reach the console. |
oneventcreate, oneventmove and onexternaldrop receive real instants, also with timezone. None of the write callbacks fire when readOnly is set.
To show a newly created or moved event, update your data and give the Calendar a new adapter. The store reloads on a new adapter identity without blanking the grid:
<script lang="ts">
import { Calendar, createMemoryAdapter, type TimelineEvent } from '@nomideusz/svelte-calendar';
let events = $state<TimelineEvent[]>([]);
const adapter = $derived(createMemoryAdapter(events));
</script>
<Calendar
{adapter}
oneventcreate={({ start, end }) => (events = [...events, { id: crypto.randomUUID(), title: 'New', start, end }])}
/>
Read-only#
Set readOnly to disable drag, resize, click-to-create and external drops:
<Calendar {adapter} readOnly />
To lock single events, set data.readOnly: true on them: views refuse to move or resize those events, which can still be clicked. The recurring adapter uses this for its projected occurrences.
Event details panel#
FloatingPanel is a non-modal panel that opens beside the clicked block's rect (to its right, or left near the viewport edge), can be dragged by its header, and becomes a bottom sheet below a 640px viewport. Escape, its close button or a click outside closes it; the outside click is spent on closing, so it doesn't also open the event under it.
<script lang="ts">
import { Calendar, FloatingPanel, type TimelineEvent } from '@nomideusz/svelte-calendar';
let open = $state<{ ev: TimelineEvent; anchor?: DOMRect } | null>(null);
</script>
<Calendar {adapter} oneventclick={(ev, anchor) => (open = { ev, anchor })} />
{#if open}
<FloatingPanel title={open.ev.title} anchor={open.anchor} onclose={() => (open = null)}>
<p>{open.ev.location}</p>
</FloatingPanel>
{/if}
| Prop | Type | Default | Description |
|---|---|---|---|
title |
string |
'' |
Header text and dialog label |
anchor |
FloatingPanelAnchor |
{ x: 24, y: 24 } |
{ x, y, width?, height? }; a DOMRect works as is |
width |
number |
440 |
Panel width in px |
theme |
string |
'' |
A --dt-* theme string, as the Calendar takes |
closeLabel |
string |
'Close' |
Close button label |
closeOnOutside |
boolean |
true |
Close on a pointerdown outside |
onclose |
() => void |
— | Called on Escape, the close button, or an outside click |
children |
Snippet |
required | Panel body |
More options#
Hide nav controls (prev/next/today) and treat all days equally (no past-day dimming):
<!-- Fixed weekly schedule, no browsing, all days equal -->
<Calendar
{adapter}
view="week-agenda"
readOnly
showNavigation={false}
showDates={false}
equalDays
/>
Hide weekends for a workweek view:
<Calendar {adapter} view="week-planner" hideDays={[6, 7]} />
Control which date the calendar shows from your app:
<script>
let date = $state(new Date());
</script>
<Calendar
{adapter}
currentDate={date}
ondatechange={(d) => date = d}
/>
Show a rolling 3-day view (week views other than 7 days start at the focused date):
<Calendar {adapter} view="week-planner" days={3} />
Block lunch hours and enforce 30–120 min events:
<script>
import type { BlockedSlot } from '@nomideusz/svelte-calendar';
const blocked: BlockedSlot[] = [
{ start: 12, end: 13, label: 'Lunch' }, // every day 12–1 PM
{ day: 6, start: 0, end: 24, label: 'Saturday' }, // block all Saturday (ISO weekday)
];
</script>
<Calendar {adapter} blockedSlots={blocked} minDuration={30} maxDuration={120} />
Disable specific dates:
<Calendar
{adapter}
disabledDates={[new Date('2026-03-25'), new Date('2026-04-01')]}
/>
Disabled dates refuse creating or moving events into those days, which suits holidays or fully booked days. In the planner, agenda and mobile views their existing events stay visible and clickable; month-grid dims a disabled day, and its chips ignore clicks.
Custom day headers and hover previews:
<Calendar {adapter} oneventhover={(ev) => showTooltip(ev)}>
{#snippet dayHeader({ date, isToday, dayName })}
<span style:font-weight={isToday ? 'bold' : 'normal'}>{dayName}</span>
{/snippet}
</Calendar>
Replace the built-in navigation or the entire header chrome with your own controls:
<Calendar {adapter}>
{#snippet navigation({ prev, next, goToday, isViewOnToday, mode })}
<button onclick={prev}>‹</button>
{#if !isViewOnToday}<button onclick={goToday}>Today</button>{/if}
<button onclick={next}>›</button>
{/snippet}
</Calendar>
<Calendar {adapter}>
{#snippet header({ dateLabel, mode, modes, switchMode, prev, next, goToday })}
<nav class="my-toolbar">
<h2>{dateLabel}</h2>
{#each modes as m}
<button class:active={mode === m} onclick={() => switchMode(m)}>{m}</button>
{/each}
<button onclick={prev}>←</button>
<button onclick={goToday}>Today</button>
<button onclick={next}>→</button>
</nav>
{/snippet}
</Calendar>
Let agenda content determine height instead of forcing a fixed box, for a calendar inside a scrolling page:
<Calendar {adapter} view="week-agenda" height="auto" compact />
Themes#
Three built-in presets:
| Preset | Description |
|---|---|
auto |
Default. Probes the host page at mount (background, accent color, fonts, light/dark mode) and writes matching --dt-* tokens. Re-probes when the host theme changes. |
neutral |
Explicit light theme. White bg, blue accent. Use when embedding standalone. |
midnight |
Explicit dark theme. Charcoal bg, red accent. |
<script>
import { Calendar, neutral, midnight } from '@nomideusz/svelte-calendar';
</script>
<Calendar {adapter} /> <!-- auto: adapts to host page -->
<Calendar {adapter} theme={neutral} /> <!-- explicit light mode -->
<Calendar {adapter} theme={midnight} /> <!-- explicit dark mode -->
presets maps the names to the strings (presets.neutral); the type PresetName is 'auto' | 'neutral' | 'midnight'.
Smart Auto Theme#
The default auto preset probes the page around the calendar and generates a theme that blends in. It detects:
- Background color: from common CSS variables on
:root(--bg,--background,--color-bg,--bs-body-bg, …), inline styles, or computed styles - Light/dark mode: from background luminance
- Accent/brand color: from common CSS variables (
--accent,--primary,--brand,--bs-primary, …), link colors, or button colors - Text color: validated for contrast against the background
- Fonts: the host's computed font stack; the mono stack from
--font-monoand similar variables
It watches for changes (a dark-mode toggle on <html>/<body>, the system color scheme) and updates automatically.
The probing engine is exported for standalone use: probeHostTheme(element, options?) returns a --dt-* CSS string for the page around element; observeHostTheme(element, callback, options?) re-probes on theme changes and returns a stop function.
Fine-tune auto-detection with the autoTheme prop (AutoThemeOptions):
<!-- Force dark mode even if the page background is light -->
<Calendar {adapter} autoTheme={{ mode: 'dark' }} />
<!-- Override the accent color (skip probing) -->
<Calendar {adapter} autoTheme={{ accent: '#e11d48' }} />
<!-- Override the font stack -->
<Calendar {adapter} autoTheme={{ font: '"Poppins", sans-serif' }} />
<!-- Disable probing entirely: inherit --dt-* from ancestors -->
<Calendar {adapter} autoTheme={false} />
Your own tokens#
The calendar writes its theme inline on its own root element, so it wins over --dt-* values set on an ancestor or :root. With the default auto theme it writes a full probed set. To use your own values:
Change a few tokens: append them to a preset (later declarations win):
import { neutral } from '@nomideusz/svelte-calendar'; const custom = `${neutral}; --dt-accent: #e11d48;`;<Calendar {adapter} theme={custom} />Write your own theme string: any
--dt-*declarations; tokens you leave out are inherited from ancestors or fall back to neutral defaults.Inherit from the page: pass
autoTheme={false}(keeping the defaultautotheme). The calendar then writes no tokens and reads whatever--dt-*its ancestors define:<div style="--dt-accent: #e11d48; --dt-bg: #1a1a2e; --dt-text: rgba(255,255,255,0.87);"> <Calendar {adapter} autoTheme={false} /> </div>
Component fallbacks use system fonts and a neutral blue accent, so the package stays clean in apps that set no tokens.
All design tokens
| Token | Purpose |
|---|---|
--dt-bg |
Calendar background |
--dt-surface |
Elevated surface (headers, alternating rows, chips) |
--dt-border |
Default border |
--dt-border-day |
Day-column / day-cell dividers |
--dt-text |
Primary text |
--dt-text-2 |
Secondary text |
--dt-text-3 |
Tertiary text |
--dt-accent |
Accent color |
--dt-accent-dim |
Accent at ~12% opacity (selection, highlights) |
--dt-ev-fill |
Event wash, as a percentage of the event's colour (default 0%; the eventFill prop sets it) |
--dt-ev-bar |
Width of an event's colour bar (default 3px; the eventBar prop sets it) |
--dt-event |
Bar color of an event without its own color (the presets set it to the text color, so such events draw in ink) |
--dt-accent-fg |
Text on the planner's today-number badge (default #fff; not set by the presets) |
--dt-btn-text |
Text on accent-filled buttons and badges |
--dt-glow |
Accent glow / focus ring |
--dt-today-bg |
Today column highlight |
--dt-weekend-bg |
Weekend cell tint |
--dt-hover |
Hover background |
--dt-scrollbar |
Scrollbar thumb |
--dt-success |
Completed indicator |
--dt-sans / --dt-mono |
Font stacks |
--dt-radius |
FloatingPanel corner radius (the calendar's own radius is the borderRadius prop) |
--dt-shadow |
FloatingPanel shadow |
--dt-stage-bg |
The page background around the calendar. Set by the presets and the auto theme for your page to use; the components don't read it. |
Events#
TimelineEvent#
| Field | Type | Description |
|---|---|---|
id |
string |
Unique identifier |
title |
string |
Event title |
start / end |
Date |
Time range |
color |
string? |
The event's bar color. Without it the event draws in --dt-event (ink), or takes a color from the adapter's palette |
category |
string? |
Grouping key: with a palette, events of the same category share a color |
subtitle |
string? |
Secondary text below the title |
tags |
string[]? |
Small accent-colored pills |
allDay |
boolean? |
Render as an all-day event |
location |
string? |
Room, venue, or address |
status |
EventStatus? |
'confirmed' (default), 'cancelled', 'tentative', 'full', 'limited' |
externalId |
string? |
ID from an upstream system (booking platform, CRM, LMS) |
resourceId |
string? |
Resource this event belongs to (room, instructor, court) |
data |
Record<string, unknown>? |
Arbitrary payload for your app. data.readOnly: true locks the event against drags. |
Cancelled events render with a strikethrough but stay visible, so the slot isn't mistaken for free time.
Event colors#
An event with a color draws its bar in that color. An event without one draws in ink: --dt-event, which the presets set to the text color. Nothing is colored for you unless you ask.
To color events automatically, pass a palette to the memory, recurring or mapped adapter. Events without a color then take a palette color, grouped by category (or title):
import { createMemoryAdapter, VIVID_PALETTE } from '@nomideusz/svelte-calendar';
const events = [
{ id: '1', title: 'Yoga', category: 'wellness', start, end },
{ id: '2', title: 'Pilates', category: 'wellness', start, end }, // same color
{ id: '3', title: 'Standup', start, end }, // different color
];
const adapter = createMemoryAdapter(events, { palette: VIVID_PALETTE });
Or generate a theme-harmonious palette from any accent color:
import { createMemoryAdapter, generatePalette } from '@nomideusz/svelte-calendar';
const palette = generatePalette('#e11d48'); // (accent?, count = 15)
const adapter = createMemoryAdapter(events, { palette });
Events are transparent by default: a bar in the event's colour, ink text on the paper. To give them a background, or change the bar:
<Calendar {adapter} eventFill={0.15} /> <!-- a light wash in each event's colour -->
<Calendar {adapter} eventFill={0.15} eventBar={0} /> <!-- wash only, no bar -->
<Calendar {adapter} eventBar={5} /> <!-- a heavier bar -->
Related exports: VIVID_PALETTE (the vivid palette, the default before 1.4) and extractAccent(themeString) (the accent color of a --dt-* theme string).
Multi-day & All-day#
Events spanning multiple days or flagged allDay: true render in a strip above timed events:
const events = [
{ id: '1', title: 'Conference', start: new Date('2026-03-15'), end: new Date('2026-03-18'), allDay: true },
{ id: '2', title: 'Sprint', start: new Date('2026-03-15T00:00'), end: new Date('2026-03-17T00:00') },
];
Custom Event Rendering#
The event snippet replaces the event content in every view (the interactive shell for click, keyboard, drag and selection stays intact). The empty snippet replaces the empty state of the day agenda, the day on mobile, and an empty planner week:
<Calendar {adapter}>
{#snippet event(ev)}
<div style="padding: 4px 8px;">
<strong>{ev.title}</strong>
{#if ev.subtitle}<small>{ev.subtitle}</small>{/if}
</div>
{/snippet}
{#snippet empty()}
<p>Nothing booked. Drag on the grid to add a class.</p>
{/snippet}
</Calendar>
Adapters#
The calendar reads its events through an adapter. Which one:
| Adapter | Use it when |
|---|---|
createMemoryAdapter(events) |
The events are already in the page (a fixed list, your own state). Writes stay in memory. See Quick Start. |
createRecurringAdapter(rules) |
The schedule repeats by rule: class timetables, office hours. Read-only. |
createRestAdapter(options) |
The events live behind your own HTTP API, which also stores creates and moves. |
createIcsAdapter(source) |
The events come from Google Calendar, Apple Calendar, Outlook or any other calendar app: an imported .ics file or a subscription URL. Read-only. |
createJmapAdapter(client, options) |
The events are in a JMAP Calendars server (Fastmail, Stalwart, Cyrus). Read-only. |
createMappedAdapter(records, options) |
You have records from another system with their own field names: map the fields instead of writing an adapter. |
createCompositeAdapter(adapters) |
One calendar shows several sources, e.g. a recurring timetable plus one-off events. |
Custom CalendarAdapter |
None of the above fits: implement fetchEvents, plus the writes you need. |
REST Adapter#
Connect to any REST API:
import { createRestAdapter } from '@nomideusz/svelte-calendar';
type ApiItem = { id: string; name: string; startAt: string; endAt: string };
const adapter = createRestAdapter({
baseUrl: 'https://api.example.com/v1',
headers: { Authorization: 'Bearer TOKEN' },
// Optional: map your API's list shape to TimelineEvent[]
mapEvents: (data) =>
(data as { items: ApiItem[] }).items.map((item) => ({
id: item.id,
title: item.name,
start: new Date(item.startAt),
end: new Date(item.endAt),
})),
});
The adapter calls GET {baseUrl}/events?start=…&end=… (ISO strings), POST /events, PATCH /events/:id and DELETE /events/:id, sending JSON with your headers. By default a list response must be an array of events and a single response one event; start/end are parsed into Dates. Override with mapEvents(data) for lists and mapEvent(data) for single events. A non-2xx response or invalid JSON throws.
JMAP Adapter#
Read events from a JMAP Calendars server (Fastmail, Stalwart, Cyrus):
import { createJmapAdapter, type JmapClient } from '@nomideusz/svelte-calendar';
const client: JmapClient = {
request: (calls) =>
fetch('https://api.example.com/jmap', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ using: ['urn:ietf:params:jmap:calendars'], methodCalls: calls }),
}).then((r) => r.json()),
};
const adapter = createJmapAdapter(client, {
getAccountId: () => accountId,
calendarId: 'primary', // optional: restrict to one calendar
timeZone: 'Europe/Warsaw', // optional: default Etc/UTC
});
Read-only. Expands recurrences server-side and handles all-day events, ISO-duration ends and per-calendar colors (pass calendars: [{ id, name, color }], or a function returning them).
iCalendar (.ics)#
Every calendar app speaks iCalendar: Google Calendar, Apple Calendar / iCloud, Outlook / Microsoft 365, Thunderbird, Proton, Fastmail, Nextcloud. One adapter reads all of them, from a file or a feed:
<script>
import { Calendar, createIcsAdapter } from '@nomideusz/svelte-calendar';
let adapter = $state(createIcsAdapter(''));
async function importFile(e) {
const file = e.currentTarget.files?.[0];
if (file) adapter = createIcsAdapter(await file.text());
}
</script>
<input type="file" accept=".ics,.ical,.ifb,text/calendar" onchange={importFile} />
<Calendar {adapter} />
A subscription URL loads on the first fetch (webcal:// reads as https://); refreshInterval refetches once the copy is older than that many ms:
// Google: Settings → your calendar → "Secret address in iCal format"
// Outlook: Settings → Calendar → Shared calendars → Publish → ICS link
// iCloud: share the calendar as "Public Calendar" → webcal:// link
const adapter = createIcsAdapter({ url: '/api/calendar-feed', refreshInterval: 15 * 60_000 });
Google, Outlook and iCloud send no CORS headers, so a browser can't fetch their feeds directly: serve them through a route of your own (the feed URL is a secret, so keep it on the server anyway):
// src/routes/api/calendar-feed/+server.ts
export const GET = async ({ fetch }) => new Response(await (await fetch(FEED_URL)).text(), {
headers: { 'content-type': 'text/calendar; charset=utf-8' },
});
- Recurrence is expanded per viewed range:
RRULE(daily, weekly, monthly, yearly;INTERVAL,COUNT,UNTIL,BYDAYwith ordinals like-1FR,BYMONTHDAY,BYMONTH,BYSETPOS,WKST),RDATE,EXDATE, and moved or edited occurrences (RECURRENCE-ID). - Times keep their zone: UTC, IANA
TZIDs, and theVTIMEZONEblocks Outlook writes under Windows zone names. Floating times read in the calendar'sX-WR-TIMEZONE, else the browser's. All-day dates are local midnights, end exclusive. SUMMARY→title,LOCATION→location,STATUS→status, the first ofCATEGORIES→category,COLOR→color,UID→externalId,DESCRIPTION/URL→data.description/data.url. Events without a colour takeoptions.color, else the calendar's own (X-APPLE-CALENDAR-COLOR).- Read-only: each occurrence carries
data.readOnly, and a write throwsCalendarReadOnlyError, so in a composite a drag reachesoneventmove. - The source may also be a function returning the text:
createIcsAdapter(() => loadIt()).
To use the events outside an adapter (copy an import into your own store), parse once and expand any range:
import { parseIcs } from '@nomideusz/svelte-calendar';
const cal = parseIcs(text); // { name, color, events(range) }
const imported = cal.events({ start, end });
Export. toIcs(events, { name }) writes an .ics file that Google, Apple and Outlook import, or subscribe to when you serve it from a URL. For a single event, googleCalendarUrl(event) and outlookCalendarUrl(event, { work }) (personal outlook.live.com, or Microsoft 365) open the app's "add event" page:
import { toIcs, googleCalendarUrl, outlookCalendarUrl } from '@nomideusz/svelte-calendar';
const file = new Blob([toIcs(events, { name: 'Studio' })], { type: 'text/calendar' });
const href = URL.createObjectURL(file); // <a {href} download="studio.ics">
Mapped Adapter#
Wrap any static array of external records (class schedules, appointments, timetables) without writing a custom adapter. Supply a declarative field mapping (or a mapEvent function for full control) and the adapter handles parsing, colors, tags and status:
import { createMappedAdapter } from '@nomideusz/svelte-calendar';
const adapter = createMappedAdapter(rawClasses, {
fields: {
title: 'class_name',
start: 'starts_at_iso',
end: 'ends_at_iso',
subtitle: 'teacher',
location: 'room',
externalId: 'reference_id',
status: 'is_cancelled', // boolean → 'cancelled' / 'confirmed'
tags: ['is_free', 'is_bookable_online'],
},
});
- Works without options too: it looks for common keys (
id,title/name,start/end,starts_at_iso/ends_at_iso,date+start_time/end_time,room/location,is_cancelled). A missing end defaults to one hour after the start. start/endaccept ISO strings,Dates or millisecond timestamps. When records split date and time (date: "2026-03-03",startTime: "07:00"), mapdate+startTime/endTime; times may be7:00,07:00or07:00:00.- Source colors are kept. Records without one draw in ink, unless you pass a
paletteto fill them by category/title.autoColor: trueignores the source colors and colors every event frompalette(defaultVIVID_PALETTE). includeData(default'*') copies unmapped source fields intoevent.data; pass a list of keys to limit it.
For full control, pass mapEvent(raw, index) and skip fields:
const adapter = createMappedAdapter(rawData, {
mapEvent: (raw) => ({
id: raw.uid,
title: raw.procedure_name,
start: new Date(raw.scheduled_at),
end: new Date(raw.scheduled_end),
location: raw.office,
resourceId: raw.doctor_id,
}),
});
Mapped adapters are read-only by default (writes throw a read-only error, so drags reach oneventmove). Set readOnly: false to allow writes; supply onMutate: { onCreate, onUpdate, onDelete } to persist them (each returns the stored source record, which is mapped again), or omit it to keep changes in memory.
Composite Adapter#
Merge several adapters into one, e.g. a recurring weekly schedule plus one-off events:
import { createMemoryAdapter, createRecurringAdapter, createCompositeAdapter } from '@nomideusz/svelte-calendar';
const memory = createMemoryAdapter(oneOffEvents);
const recurring = createRecurringAdapter(weeklySchedule);
const adapter = createCompositeAdapter([memory, recurring]);
- Reads query every child in parallel and merge the results (the first event with a given id wins).
- Creates go to the primary child: the first by default, or
primaryIndex. - Updates and deletes go to the child that returned the event. If that child can't write, the composite refuses with a read-only error (so
oneventmovereaches the host). An id no fetch returned is offered to each writable child in turn. onFetchError(error, adapterIndex): with it, a failing child is reported to you and the others still load; without it, one failing child fails the whole load (surfacing throughonerror).
const adapter = createCompositeAdapter([recurring, memory], {
primaryIndex: 1,
onFetchError: (error, i) => console.warn(`source ${i} failed`, error),
});
Custom Adapter#
Implement the CalendarAdapter interface to connect any data source. Only fetchEvents is required:
import type { CalendarAdapter, DateRange, TimelineEvent } from '@nomideusz/svelte-calendar';
const adapter: CalendarAdapter = {
// Required: events overlapping the range
fetchEvents: async (range: DateRange) => { /* return TimelineEvent[] */ },
// Optional: the same answer synchronously, when the data is already in memory
fetchEventsSync: (range) => { /* return TimelineEvent[], or undefined to go async */ },
// Optional writes; omit them for a read-only source
createEvent: async (event) => { /* return the created TimelineEvent with its id */ },
updateEvent: async (id, patch) => { /* return the updated TimelineEvent */ },
deleteEvent: async (id) => { /* void */ },
};
WritableCalendarAdapter is the same interface with all three write methods required.
fetchEventsSync lets the first render (the server render too) hold the events instead of a loading state. The memory, recurring and seeded adapters implement it. To seed an async adapter with events you already loaded on the server, wrap it:
import { createRestAdapter, withInitialEvents } from '@nomideusz/svelte-calendar';
// data.events: TimelineEvent[] for the range the page opens on
const adapter = withInitialEvents(createRestAdapter({ baseUrl: '/api' }), data.events);
The first load is answered synchronously from the seed; every later load (navigation, refresh) goes to the wrapped adapter.
Refusing a write#
The Calendar tells two refusals apart when updateEvent throws:
| Throw | Meaning | What happens |
|---|---|---|
new CalendarReadOnlyError(message?) |
"I hold this event but can't store the change." | The block stays where it was dropped and oneventmove fires; the host stores the move and refreshes. |
new EventNotFoundError(id) |
"Not my event." | A composite asks its next child; a drag ends quietly. |
| any other error | A real failure | The block reverts and the error goes to onerror (or console.warn). |
An adapter without updateEvent counts as read-only. Plain Errors whose message contains read-only (lower case) or not found (any case) are read the same way, so older adapters keep working. isReadOnlyError(e) and isNotFoundError(e) test both forms.
Recurring Schedules#
For fixed repeating events (class timetables, office hours):
<script>
import { Calendar, createRecurringAdapter } from '@nomideusz/svelte-calendar';
const adapter = createRecurringAdapter([
{ id: '1', title: 'Yoga', dayOfWeek: 1, startTime: '07:00', endTime: '08:30' },
{ id: '2', title: 'Standup', frequency: 'daily', startTime: '09:00', endTime: '09:15',
startDate: '2026-03-01', until: '2026-03-31' },
{ id: '3', title: 'Review', frequency: 'monthly', dayOfMonth: 15,
startTime: '10:00', endTime: '11:00' },
{ id: '4', title: 'Night shift', dayOfWeek: [5, 6], startTime: '22:00', endTime: '02:00' },
]);
</script>
<Calendar {adapter} view="week-planner" />
RecurringEvent fields
| Field | Type | Default | Description |
|---|---|---|---|
id |
string |
required | Rule identifier |
title |
string |
required | Event title |
startTime |
string |
required | Start time "HH:MM", 24-hour |
endTime |
string |
required | End time "HH:MM". Earlier than startTime → ends the next day (overnight). |
frequency |
'daily' | 'weekly' | 'monthly' |
'weekly' |
Recurrence frequency |
interval |
number |
1 |
Every N periods (2 + weekly = biweekly). Needs startDate when > 1. |
dayOfWeek |
number | number[] |
— | ISO weekday 1=Mon…7=Sun. Required for weekly. |
dayOfMonth |
number |
1 |
Day of month (1–31) for monthly; clamped to shorter months. |
startDate |
string |
— | First possible occurrence "YYYY-MM-DD" |
until |
string |
— | Last possible occurrence "YYYY-MM-DD" |
count |
number |
— | Max occurrences from startDate (requires it). With until, the stricter wins. |
excludeDates |
string[] |
— | "YYYY-MM-DD" dates this rule skips |
color |
string |
— | Bar color; else from palette, else ink (--dt-event) |
subtitle / tags / category / location / resourceId |
— | Copied to every occurrence | |
data |
Record<string, unknown> |
— | Copied to every occurrence, plus recurringId |
Options (RecurringAdapterOptions): mondayStart (default true), palette (colors rules without a color; off by default), movable (default false).
Occurrences get the id `${rule.id}--YYYYMMDD` and data.recurringId. The adapter projects and stores nothing, so by default every occurrence carries data.readOnly and can't be dragged (it can still be clicked). With movable: true the views allow the drag and the adapter refuses the write with a read-only error, so oneventmove fires and the host stores the move: add the date to the rule's excludeDates and keep the moved occurrence with your one-off events (for example the memory side of a composite adapter).
Headless API#
For full control over rendering, skip <Calendar> and drive everything from reactive state. createCalendar() returns computed layouts, navigation actions, drag helpers and the raw engines. Zero DOM: bring your own UI. Call it during component initialisation.
<script lang="ts">
import { createCalendar, createMemoryAdapter } from '@nomideusz/svelte-calendar';
const adapter = createMemoryAdapter([/* ... */]);
const cal = createCalendar({ adapter, view: 'week-planner' });
</script>
<header>
<button onclick={cal.prev}>←</button>
<span>{cal.headerContext.dateLabel}</span>
<button onclick={cal.next}>→</button>
<button onclick={cal.goToday}>Today</button>
</header>
{#each cal.weeks as week}
<div class="week-row">
{#each week.days as day}
<div class:today={day.isToday} class:past={day.isPast}>
<h3>{day.dayNum}</h3>
{#each day.events as ev}
<div style:background={ev.color}>{ev.title}</div>
{/each}
</div>
{/each}
</div>
{/each}
Options (HeadlessCalendarOptions) mirror the Calendar props: adapter, view (default 'week-planner'), mondayStart, initialDate, timezone, locale, visibleHours, snapInterval, equalDays, hideDays, blockedSlots, disabledDates, days, readOnly, minDuration, maxDuration, and the callbacks oneventclick, oneventcreate, oneventmove.
cal.days gives flat HeadlessDay[] with events attached; cal.weeks groups them into periods (HeadlessWeek). cal.todayQueue returns { past, current, upcoming } for today (TodayQueue), updated every second. cal.hours yields the visible hour numbers. Drag is fully wired: beginDragMove, beginDragCreate, updateDrag, commitDrag (validates like the Calendar), cancelDrag, plus isDragging / dragPayload / dragMode. headerContext / navigationContext match the Calendar's header / navigation snippet contexts. Raw engines (store, viewState, selection, dragState, clock) are exposed for advanced cases.
The engine factories are exported individually (createEventStore(adapter), createViewState(options), createSelection(), createDragState(), createClock(timezone?)) if you want to compose your own calendar loop (types: EventStore, ViewState, ViewStateOptions, CalendarSelection, DragState, DragMode, DragPayload, CalendarViewId, BuiltInViewId, ViewMode).
createAgenda#
A live single-day list: createAgenda({ adapter, initialDate?, locale?, lookahead?, timezone? }).
<script lang="ts">
import { createAgenda, createMemoryAdapter } from '@nomideusz/svelte-calendar';
const adapter = createMemoryAdapter([/* ... */]);
const agenda = createAgenda({ adapter });
</script>
<h2>{agenda.dateLabel}</h2>
{#each agenda.upcoming as ev}
<div>
<time>{agenda.fmtTime(ev.start)}</time>
<span>{ev.title}</span>
<small>{agenda.eta(ev)}</small>
</div>
{/each}
It returns dayEvents, allDay, past, current, upcoming, upcomingSlots (TimeSlot[]: overlapping events grouped as { startMs, endMs, events }), count, eta(ev), progress(ev), fmtTime / fmtDuration / fmtRange, and prev / next / goToday / setDate.
createRangeAgenda#
A window of days with events grouped per day; no clock, drag or selection. Built for read-only schedule surfaces (public timetables, embeds):
<script lang="ts">
import { createRangeAgenda } from '@nomideusz/svelte-calendar';
const agenda = createRangeAgenda({ adapter, days: 7, locale: 'pl-PL', timezone: 'Europe/Warsaw' });
</script>
<button onclick={agenda.prev}>←</button>
<button onclick={agenda.next}>→</button>
{#each agenda.days.filter((d) => d.events.length > 0) as day (day.ms)}
<h3>{day.date.toLocaleDateString()}</h3>
{#each day.events as ev (ev.id)}
<p>{agenda.fmtRange(ev)} {ev.title}</p>
{/each}
{/each}
Options (RangeAgendaOptions): adapter (or a getter), days (default 7), initialDate (the first day, not aligned to a week start), locale, timezone. It returns days (RangeAgendaDay[]: ms, date, weekday, isToday, isPast, events), range, count, loading, error, prev(), next(), goToday(), setDate(date), refresh(), fmtTime(date), fmtDuration(ev), fmtRange(ev).
Localization (i18n)#
The locale prop controls date/time formatting (BCP 47). Without it, the global default locale is used: 'en-US' unless you call setDefaultLocale():
<Calendar {adapter} locale="de-DE" />
setDefaultLocale('de-DE') sets it for every calendar and formatter; getDefaultLocale() reads it back; is24HourLocale(tag?) tells you how times will format. An RTL locale (ar-SA, he-IL, …) sets dir="rtl" where the browser reports the locale's text direction (Intl.Locale text info); pass dir to be explicit.
Override UI labels per calendar with the labels prop. It's reactive (swap it to switch language live) and instance-scoped (two calendars on one page can carry different languages):
<Calendar {adapter} locale="de-DE" labels={{
today: 'Heute', day: 'Tag', week: 'Woche', month: 'Monat',
noEvents: 'Keine Termine',
nMore: (n) => `+${n} weitere`,
}} />
Or set labels globally before mount:
import { setLabels } from '@nomideusz/svelte-calendar';
setLabels({ today: 'Heute', day: 'Tag', week: 'Woche' });
Both take a Partial<CalendarLabels>, and that is the supported input: pass the keys you translate, and keys added in later minor releases fall back to English. The labels prop merges over the global set. resetLabels() restores English (defaultLabels exports it; getLabels() reads the active set). Global setLabels() calls after mount don't re-render mounted calendars; use the labels prop to switch language live. Standalone primitives (DayHeader, EventBlock, …) and the format helpers read the global set.
All label keys
| Key | Default |
|---|---|
today / yesterday / tomorrow |
'Today' / 'Yesterday' / 'Tomorrow' |
day / week / month |
'Day' / 'Week' / 'Month' (mode pills) |
planner / agenda / scroll |
'Planner' / 'Agenda' / 'Scroll' (view-type pills) |
now / free / allDay |
'now' / 'free' / 'All day' |
done / upNext / until |
'Done' / 'Up next' / 'until' |
noEvents |
'No events' |
nothingScheduled / nothingScheduledYet / nothingWasScheduled |
'Nothing scheduled' / 'Nothing scheduled yet' / 'Nothing was scheduled' |
allDoneForToday |
'All done for today' |
goToToday |
'Go to today' |
previousDay / nextDay |
'Previous day' / 'Next day' |
previousWeek / nextWeek |
'Previous week' / 'Next week' |
previousMonth / nextMonth |
'Previous month' / 'Next month' |
calendar / viewMode |
'Calendar' / 'View mode' (aria) |
cancelled / tentative / full / limited |
'cancelled' / 'tentative' / 'full' / 'limited' (event status) |
unavailable |
'Unavailable' (a blocked slot without its own label) |
noViews |
'No views registered.' |
dayNavigation / weekNavigation |
'Day navigation' / 'Week navigation' (aria) |
dayPlanner / scrollableDayPlanner |
'Day planner' / 'Scrollable day planner' (aria) |
todaysLineup / weekAhead / multiWeekGrid |
"Today's lineup" / 'Week ahead' / 'Multi-week calendar grid' (aria) |
currentTime / createEvent |
'Current time' / 'Create event' (aria) |
happeningNow / past / completed / inProgress |
'happening now' / 'past' / 'completed' / 'in progress' |
showLess |
'Show less' |
nMore(n) |
+3 more |
nEvents(n) |
1 event, 5 events |
nCompleted(n) |
3 completed |
dayNOfTotal(current, total) |
day 2 of 4 |
percentComplete(pct) |
75% complete |
inMinutes(mins) / inHours(hours, mins) / inDays(days) |
in 45m / in 2h 15m or in 2h / in 3d |
Timezone Support#
Render the whole calendar in any IANA timezone: events, the now-indicator and day boundaries all shift:
<Calendar {adapter} timezone="Europe/Warsaw" />
initialDateandcurrentDateare instants, read in that zone.oneventcreate,oneventmoveandonexternaldropreceive real instants.ondatechangeandondayclickreceive instants too (the start of that day in the zone), so feeding one back is stable:currentDate={date} ondatechange={(d) => (date = d)}. To read the date, format it in the zone:import { formatInTimeZone } from '@nomideusz/svelte-calendar'; formatInTimeZone(d, 'Europe/Warsaw', { year: 'numeric', month: '2-digit', day: '2-digit' }, 'en-CA'); // "2026-10-24"Events handed to
oneventclick/oneventmove/ snippets are shifted the same way: their local fields show the zone's wall-clock time.timezoneis read when the Calendar mounts. To change zones at runtime, remount it:{#key timezone} <Calendar {adapter} {timezone} /> {/key}
Under the hood the adapter is wrapped with wrapAdapterWithTimezone(adapter, zone) (also exported), so views do plain local-time math on a zoned wall-clock plane. Known limit shared by every wall-clock calendar: the repeated hour of a DST fall-back is ambiguous, and writes inside it resolve to one of the two instants.
The headless helpers take the same option: createCalendar({ timezone }), createAgenda({ timezone }), createRangeAgenda({ timezone }), plus createClock(timezone) and createViewState({ timezone }).
Convert dates manually with the built-in helpers:
import { toZonedTime, fromZonedTime, nowInZone, formatInTimeZone } from '@nomideusz/svelte-calendar';
// A Date whose local fields show the instant's wall-clock time in New York
const local = toZonedTime(utcDate, 'America/New_York');
// Back to the real instant before saving
const utc = fromZonedTime(local, 'America/New_York');
// Current time in a timezone (wall-clock Date)
const now = nowInZone('Asia/Tokyo');
// Format an instant directly in a timezone: (date, zone, Intl options?, locale?)
formatInTimeZone(utcDate, 'America/New_York', { hour: '2-digit', minute: '2-digit' });
Utilities#
Small helpers used by the built-in views, exported for custom rendering. Formatters take an optional locale (default: the global default locale); day helpers work on millisecond timestamps.
| Export | Purpose |
|---|---|
fmtTime(date, locale?) / fmtH(hour, locale?) |
Compact time / hour labels: 9:00a / 9a in 12-hour locales, 9:00 / 9 in 24-hour ones |
fmtDuration(start, end) |
"45m", "1h", "1h 30m" |
fmtDay(ms, todayMs, opts?, locale?) |
"Today · Feb 21", "Mon, Feb 17"; { short: true } for the short form |
fmtWeekRange(startMs, locale?, endMs?) |
A date range in the locale's own order: "Sep 21 – 27, 2026", "21–27 wrz 2026". endMs is the last day shown (default: six days on). |
dateShort / dateWithWeekday |
"Feb 21" / "Mon, Feb 17" |
weekdayShort / weekdayLong / monthShort / monthLong |
Name parts of a timestamp |
sod(ms) |
Start of that day (local midnight) |
startOfWeek(ms, mondayStart = true) |
Start-of-week timestamp |
addDaysMs(ms, n) / diffDays(a, b) |
Add / count calendar days, DST-safe (a DST day is 23 or 25 hours, so don't add n * 86_400_000) |
isAllDay(ev) / isMultiDay(ev) |
Event classification |
segmentForDay(ev, dayMs) |
The slice of a multi-day event on one day (DaySegment), or null |
createClock(timezone?) |
Reactive clock: tick (ms, every second), today, hm, s, fractionalHour, destroy() |
textWidth(text, font) / textHeight(text, font, width, lineHeight) / lineCount(text, font, width, options?) / fits(text, font, width, lines = 1) |
Text measurement via pretext, before render, no layout thrash. options is TextLayoutOptions (whiteSpace, wordBreak, letterSpacing). Browser-only. |
pickFit(candidates, font, width, lines = 1) |
The longest candidate that fits |
fitLabel(candidates, lines = 1) |
Attachment: <span {@attach fitLabel([ev.title, ev.short])}> keeps the longest label that fits its box, re-fitting on resize |
fontOf(el) |
An element's computed font as a canvas font string |
fitParts(parts, budget, measure?) |
Which parts of a dense chip fit, along either axis → Record<key, boolean>. Each ChipPart is { key, text, font } to measure or { key, size } to state its cost, plus priority (0 is an anchor and never drops; higher numbers drop first) and extra (a gap or icon). The first optional part that doesn't fit ends the list, so a chip never keeps a later detail after dropping an earlier one. A budget of 0 (server-rendered, not yet measured) leaves only the anchors. |
typeset(text) / breakLines(text, font, width) |
Knuth-Plass paragraph breaking over the text's own spaces and soft hyphens. <p {@attach typeset(text)}> sets justified lines, re-done on resize; if the browser disagrees with the measure, the plain text goes back. Progressive: SSR text stays. |
Embeddable Widget#
Drop into any HTML page, no build tools needed. Registers a <day-calendar> custom element:
<script src="https://cdn.jsdelivr.net/npm/@nomideusz/svelte-calendar/widget/widget.js"></script>
<day-calendar
api="https://myschool.com/api/events"
theme="neutral"
view="week-planner"
height="600"
locale="en-US"
></day-calendar>
In a bundler, import '@nomideusz/svelte-calendar/widget' registers the same element.
The widget renders inside shadow DOM: host-page CSS cannot affect the calendar and calendar styles never leak out, while the auto theme still probes the host page's colors and fonts across the shadow boundary.
| Attribute | Default | Description |
|---|---|---|
api |
— | Events endpoint, fetched as GET {api}?start=…&end=… (ISO instants; appended with & if api already has a query). It must answer JSON: an array of events, or { "events": [...] }. |
events |
— | Inline JSON array of events, used when there is no api |
headers |
— | JSON object of extra request headers for api. Without it the GET is a simple CORS request (no preflight). |
readonly |
read-only | The widget is read-only unless readonly="false". There is no write endpoint: with api, drags are only visual; with events, they are kept in memory. |
view |
week-planner |
Initial view ID (week-planner, day-agenda, month-grid, …) |
theme |
auto |
auto, neutral or midnight (unknown names fall back to neutral) |
height |
600 |
Height in px ("600" or "600px"), or auto |
locale |
en-US |
BCP 47 locale tag |
dir |
from locale |
ltr, rtl or auto |
mondaystart |
true |
"false" starts weeks on Sunday |
pills |
true |
"false" hides the view pills |
nav |
true |
"false" hides prev/next/today |
mobile |
auto |
auto, true or false |
days |
7 |
Days in week views, 1–7 |
compact |
false |
"true" for compact agenda rows |
timezone |
viewer's zone | IANA zone, e.g. Europe/Warsaw (read when the element mounts) |
mincolumnwidth |
110 |
Minimum day-column width in px for the week grid; a container narrower than the days need scrolls sideways |
hours |
0-24 |
Visible hours of the time grids, "start-end" in whole hours (e.g. "6-22") |
labels |
— | UI words as a JSON object over the English defaults, e.g. '{"today":"Dziś","week":"Tydzień"}' (keys: CalendarLabels) |
Each event in api responses and events needs start and end as date strings (ISO 8601) and may carry id, title, color, allDay, subtitle and location; other fields are ignored, as are events with an invalid or empty range. Changing an attribute updates the calendar in place.
All Props#
Every <Calendar> prop (CalendarProps), grouped by what it controls.
Data and editing
| Prop | Type | Default | Description |
|---|---|---|---|
adapter |
CalendarAdapter |
required | Data source (memory, recurring, mapped, composite, REST, JMAP, or custom) |
readOnly |
boolean |
false |
Disable drag, resize, click-to-create and external drops |
snapInterval |
number |
15 |
Drag snap in minutes |
minDuration |
number |
— | Minimum duration in minutes; clamps creates and resizes |
maxDuration |
number |
— | Maximum duration in minutes; clamps creates and resizes |
blockedSlots |
BlockedSlot[] |
— | { day?, start, end, label? } hour ranges that can't be booked (hatched in planner views) |
disabledDates |
Date[] |
— | Dates that refuse creates/moves; existing events stay visible and clickable (dimmed and inert in month-grid) |
View and navigation
| Prop | Type | Default | Description |
|---|---|---|---|
view |
CalendarViewId |
first registered ('day-planner') |
Active view ID. Reactive. |
views |
readonly CalendarView[] |
defaultViews |
View registry (the eight built-ins). Spread defaultViews to add your own. |
initialDate |
Date |
today | Date to focus at mount (an instant; read in timezone) |
currentDate |
Date |
— | Controlled focus date (an instant; read in timezone) |
days |
number |
7 |
Days shown in week mode (e.g. 3 for a rolling 3-day view) |
mobile |
'auto' | boolean |
'auto' |
'auto' switches to mobile views below a 768px container width |
showModePills |
boolean |
true |
Show the Day/Week/Month pills and the view-type pills |
showNavigation |
boolean |
true |
Show prev/next/today |
Display
| Prop | Type | Default | Description |
|---|---|---|---|
height |
number | 'auto' |
600 |
Height in px, or 'auto' to grow with content (ideal for Agenda views) |
visibleHours |
[number, number] |
— | Crop the grid to [startHour, endHour) |
hideDays |
number[] |
— | ISO weekdays to hide (1=Mon … 7=Sun), e.g. [6, 7] |
mondayStart |
boolean |
true |
Start weeks on Monday |
showDates |
boolean |
true |
Show date numbers and the date label. false = day names only (templates, recurring schedules) |
equalDays |
boolean |
false |
Treat all days equally (no past-day dimming/collapsing) |
compact |
boolean |
false |
Minimal text rows in Agenda views (dot + time + title) |
columns |
boolean |
false |
Timetable layout: week-agenda days side by side on desktop. Pairs with equalDays; overrides compact |
minColumnWidth |
number |
110 |
Minimum day-column width (px) in planner views; below the total the grid scrolls horizontally |
Theming
| Prop | Type | Default | Description |
|---|---|---|---|
theme |
string |
auto |
CSS theme string of --dt-* properties. auto probes the host page. |
autoTheme |
AutoThemeOptions | false |
undefined (probe) |
Fine-tune auto-detection: { mode, accent, font }. false disables probing. |
borderRadius |
number |
0 |
Corner radius in px (square by default) |
eventFill |
number |
0 |
Wash each event in its own colour, 0–1 (0.15 = a light tint). Sets --dt-ev-fill |
eventBar |
number |
3 |
Width in px of the colour bar on an event's leading edge; 0 removes it. Sets --dt-ev-bar |
Locale and timezone
| Prop | Type | Default | Description |
|---|---|---|---|
locale |
string |
global default ('en-US') |
BCP 47 locale tag; see setDefaultLocale |
labels |
Partial<CalendarLabels> |
— | Per-instance UI labels, merged over the global set. Reactive. |
dir |
'ltr' | 'rtl' | 'auto' |
from locale |
Text direction |
timezone |
string |
viewer's zone | IANA zone to render in. Read at mount. |
Snippets
| Prop | Type | Default | Description |
|---|---|---|---|
event |
Snippet<[TimelineEvent]> |
— | Custom event content |
empty |
Snippet |
— | Empty state for the day agenda, mobile day and an empty planner week |
dayHeader |
Snippet<[{ date, isToday, dayName }]> |
— | Custom day header |
header |
Snippet<[HeaderContext]> |
— | Replace the whole header (dateLabel, mode, modes, switchMode, prev, next, goToday, isViewOnToday, focusDate) |
navigation |
Snippet<[NavigationContext]> |
— | Replace prev/next/today (prev, next, goToday, isViewOnToday, focusDate, mode) |
Callbacks
| Prop | Type | Default | Description |
|---|---|---|---|
oneventclick |
(event, anchor?: DOMRect) => void |
— | Event clicked (and selected) |
oneventcreate |
(range: { start, end }) => void |
— | A validated new range (real instants) |
oneventmove |
(event, start, end) => void |
— | Drag/resize stored, or refused read-only (real instants) |
onexternaldrop |
(info: { start, dataTransfer }) => void |
— | Outside HTML5 drag dropped on the grid |
onviewchange |
(viewId) => void |
— | Once on mount, then on every view change |
ondatechange |
(date) => void |
— | Once on mount, then on every focus-date change |
ondayclick |
(date) => void |
open a day view | A month-grid day clicked |
oneventhover |
(event) => void |
— | Pointer enters an event |
onerror |
(error: Error) => void |
— | Load and commit failures |
Troubleshooting#
- Dragging on empty time does nothing. Creating needs an
oneventcreatehandler (and noreadOnly); see the Quick Start. - A created event doesn't appear. The calendar stores nothing on create: add the event to your data and hand the Calendar a new adapter (
$derived(createMemoryAdapter(events))). - Moved events jump back after the next change. A new adapter is built from your array, so write each move into it in
oneventmove. - The
autotheme picked the wrong colours. It probes the host page around the calendar, and probes again when<html>/<body>change class, style ordata-theme, when the system scheme flips and when a background transition ends. Steer it withautoTheme, or setthemeto a preset or your own tokens to opt out. - Events are all one colour. Since 1.4 events without a
colordraw in ink; give the adapter apalette. - The calendar stops at 600px. That's the default
height; pass a number, or'auto'to grow with the content. - The phone layout shows on a wide page (or the desktop one in a narrow column). The switch reads the calendar's container, not the window; force it with
mobile={false}ormobile={true}.
Versioning#
The package follows semver from 1.0:
- The public API is exactly what the
.and./widgetentry points export. Deep imports intodist/are not supported. TimelineEvent,CalendarAdapterandDateRangeare stable shapes: fields may be added in minor releases, never renamed, removed or given new meaning outside a major.- The
--dt-*theme tokens are part of the contract, as are the<day-calendar>attributes. CalendarLabelsgains keys in minor releases, so pass aPartial(see Localization).
Development#
pnpm install
pnpm dev # demo site
pnpm check # svelte-check
pnpm test # vitest
pnpm run package # build the library into dist/
pnpm run build:widget # build widget/widget.js
License#
MIT