Atrium // Modules
Calendar
An endless, zoomable week-row calendar as a dependency-free custom element.
The Calendar module provides calendar-view, an endless, zoomable calendar shipped as a dependency-free custom element. Every row is a week. Zooming goes continuously from about 40 weeks (a year) down to a single week (a day planner), and rows get an all-day band and a time area once they are tall enough.
The calendar only shows the events it is given and reports what the user wants to change through DOM events. It never changes events itself, so the host application stays the single source of truth.
Example
Drag on empty space to create an event, drag an event or its edges to move or resize it, and use Ctrl/Cmd + wheel or pinch to zoom.
/* @jsxImportSource vue */
import type { CalendarEvent, CalendarEventMap, CalendarView } from "@atrium-ui/calendar";
import { defineComponent, ref, shallowRef } from "vue";
type View = CalendarEventMap["view-change"];
const calendars = [
{ id: "work", name: "Work", color: "#5b8def" },
{ id: "personal", name: "Personal", color: "#3ccf7e" },
];
const today = new Date();
const at = (days: number, hours: number) =>
new Date(today.getFullYear(), today.getMonth(), today.getDate() + days, hours);
const initialEvents: CalendarEvent[] = [
{ id: "1", calendarId: "work", title: "Standup", start: at(0, 9), end: at(0, 10), allDay: false },
{ id: "2", calendarId: "work", title: "Design review", start: at(1, 14), end: at(1, 16), allDay: false },
{ id: "3", calendarId: "personal", title: "Climbing", start: at(2, 18), end: at(2, 20), allDay: false },
{ id: "4", calendarId: "personal", title: "Vacation", start: at(7, 0), end: at(12, 0), allDay: true },
];
async function setup(calendar: CalendarView | null, onView: (view: View) => void) {
if (!calendar) return;
// The element extends HTMLElement, so it can only be loaded in the browser.
await import("@atrium-ui/calendar");
if (calendar.calendars.length) return;
calendar.addEventListener("view-change", (e) => onView((e as CustomEvent<View>).detail));
calendar.calendars = calendars;
calendar.activeCalendar = "work";
calendar.events = initialEvents;
calendar.addEventListener("event-create", (e) => {
const { start, end, allDay, calendarId } = (e as CustomEvent<CalendarEventMap["event-create"]>).detail;
const id = crypto.randomUUID();
calendar.events = [...calendar.events, { id, calendarId, title: "New event", start, end, allDay }];
});
calendar.addEventListener("event-change", (e) => {
const { id, changes } = (e as CustomEvent<CalendarEventMap["event-change"]>).detail;
calendar.events = calendar.events.map((event) => (event.id === id ? { ...event, ...changes } : event));
});
calendar.addEventListener("events-delete", (e) => {
const { ids } = (e as CustomEvent<CalendarEventMap["events-delete"]>).detail;
calendar.events = calendar.events.filter((event) => !ids.includes(event.id));
});
}
const monthTitle = (view: View | null) =>
view ? view.date.toLocaleDateString(undefined, { month: "long", year: "numeric" }) : "";
/** The calendar has no toolbar of its own, the host builds it from the element's methods and `view-change`. */
export const CalendarExample = defineComponent(() => {
const calendar = shallowRef<CalendarView | null>(null);
const view = ref<View | null>(null);
const mount = (el: unknown) => {
calendar.value = el as CalendarView | null;
void setup(calendar.value, (next) => {
view.value = next;
});
};
return () => (
<div class="w-full overflow-hidden rounded-xl border border-zinc-200 text-zinc-900 dark:border-zinc-800 dark:text-zinc-100">
<div class="flex flex-wrap items-center gap-2 border-zinc-200 border-b bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900">
<button
type="button"
aria-label="Back"
disabled={!view.value?.canBack}
class="h-8 w-8 rounded-md border border-zinc-200 bg-white disabled:opacity-40 dark:border-zinc-700 dark:bg-zinc-800"
onClick={() => calendar.value?.back()}
>
‹
</button>
<button
type="button"
aria-label="Forward"
disabled={!view.value?.canForward}
class="h-8 w-8 rounded-md border border-zinc-200 bg-white disabled:opacity-40 dark:border-zinc-700 dark:bg-zinc-800"
onClick={() => calendar.value?.forward()}
>
›
</button>
<button
type="button"
class="h-8 rounded-md border border-zinc-200 bg-white px-3 text-sm dark:border-zinc-700 dark:bg-zinc-800"
onClick={() => calendar.value?.goToday()}
>
Today
</button>
<span class="flex-1 truncate text-center font-semibold">{monthTitle(view.value)}</span>
<input
type="search"
placeholder="Filter events…"
aria-label="Filter events"
class="h-8 w-40 rounded-md border border-zinc-200 bg-white px-3 text-sm placeholder:text-zinc-400 dark:border-zinc-700 dark:bg-zinc-800 dark:placeholder:text-zinc-500"
onInput={(e: Event) => {
if (calendar.value) calendar.value.filter = (e.target as HTMLInputElement).value;
}}
/>
</div>
<calendar-view ref={mount} storage-key="atrium-docs-calendar" theme="auto" class="block h-[600px] w-full" />
</div>
);
}); Single week
Set minDate and maxDate to limit the calendar to a fixed range, like the schedule of a conference. Zoom is capped to the range, so the week fills the whole view, and the weekend outside of it can't be used. Locking the calendar makes the schedule read-only.
/* @jsxImportSource vue */
import type { CalendarEvent, CalendarView } from "@atrium-ui/calendar";
import { defineComponent } from "vue";
const today = new Date();
const monday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - ((today.getDay() + 6) % 7));
const at = (days: number, hours: number) =>
new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + days, hours);
const events: CalendarEvent[] = [
{ id: "1", calendarId: "conference", title: "Conference", start: at(0, 0), end: at(5, 0), allDay: true },
{ id: "2", calendarId: "conference", title: "Registration", start: at(0, 8), end: at(0, 10), allDay: false },
{ id: "3", calendarId: "conference", title: "Keynote", start: at(0, 10), end: at(0, 12), allDay: false },
{ id: "4", calendarId: "conference", title: "Workshops", start: at(1, 9), end: at(1, 17), allDay: false },
{ id: "5", calendarId: "conference", title: "Talks", start: at(2, 9), end: at(2, 13), allDay: false },
{ id: "6", calendarId: "conference", title: "Speaker dinner", start: at(2, 19), end: at(2, 22), allDay: false },
{ id: "7", calendarId: "conference", title: "Talks", start: at(3, 9), end: at(3, 16), allDay: false },
{ id: "8", calendarId: "conference", title: "Closing", start: at(4, 10), end: at(4, 12), allDay: false },
];
async function setup(calendar: CalendarView | null) {
if (!calendar) return;
// The element extends HTMLElement, so it can only be loaded in the browser.
await import("@atrium-ui/calendar");
if (calendar.calendars.length) return;
calendar.firstDayOfWeek = 1;
calendar.minDate = at(0, 0);
calendar.maxDate = at(4, 0);
calendar.calendars = [{ id: "conference", name: "Conference", color: "#5b8def" }];
calendar.lockedCalendars = ["conference"];
calendar.events = events;
}
/** A read-only schedule limited to the work days of a single week through `minDate` and `maxDate`. */
export const WeekExample = defineComponent(() => {
return () => (
<div class="w-full overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-800">
<calendar-view
ref={(el: unknown) => void setup(el as CalendarView | null)}
storage-key="atrium-docs-calendar-week"
theme="auto"
class="block h-[600px] w-full"
/>
</div>
);
}); Guidelines
- Give it a size. The element fills its box, so set an explicit height on
calendar-viewor its container. - Own the data. Listen to
event-create,event-changeandevents-delete, apply the change in your store, and assign a neweventsarray. - Register every calendar. Each event's
calendarIdmust exist incalendars. UselockedCalendarsfor read-only sources like holidays. - Build the toolbar. The calendar has no navigation of its own. Wire buttons to
goToday(),goMonth(),back()andforward(), a search field tofilter, and show the month and back/forward state fromview-change, as the example does. - Set the locale. Use the
localeattribute or property so all generated text and the first day of the week match your users.
<calendar-view storage-key="my-calendar" theme="auto" locale="de-DE"></calendar-view>
<script type="module">
import "@atrium-ui/calendar";
const calendar = document.querySelector("calendar-view");
calendar.calendars = [{ id: "work", name: "Work", color: "#5b8def" }];
calendar.activeCalendar = "work";
calendar.events = [
{ id: "1", calendarId: "work", title: "Review", start, end, allDay: false },
];
calendar.addEventListener("event-change", (e) => save(e.detail.id, e.detail.changes));
</script>
Properties
| Property | |
|---|---|
events | CalendarEvent[] to display |
calendars | { id, name, color }[], every event's calendarId must be in here |
activeCalendar | where new, duplicated and imported events go |
hiddenCalendars, lockedCalendars | ids; locked calendars are read-only |
userEmail | recognises the user's invitations |
locale | language and region of all generated text |
firstDayOfWeek | 0 = Sunday, null = from the region |
theme | dark, light, solarized, high-contrast, auto, or a custom name |
strings | partial overrides of the UI text |
minDate, maxDate | Date or null; limits the calendar to these days, e.g. a single week. Zoom is capped to the range and days outside it in the first and last week can't be used |
expandThreshold | row height in px from which rows get an all-day band and a time area (90) |
filter | only events whose title contains this text are shown, the weeks in between collapse |
layers | drawing layers |
selection | selected event ids |
Attribute: storage-key (where the view is remembered, default calendar-view).
Methods
goTo(date), goToday(), goMonth(), back(), forward(), setLayer(id, on), cancelInteraction(), appendSummary(id, chunk), finishSummary(id), failSummary(id), refreshTheme().
Events
All events are CustomEvents that bubble out of the shadow root.
| Event | detail |
|---|---|
event-create | { start, end, allDay, calendarId, duplicateOf? } |
event-change | { id, changes: { start?, end?, title?, description?, reminders?, response? } } |
events-delete | { ids }, all selected events on Delete/Backspace; confirming and checking permissions is up to the host |
calendar-import | { data, fileName, calendarId }, data is already checked to look like iCalendar |
selection-change | { ids } |
event-click | { id } |
range-select | { from, to, columns, minutes } |
refresh-request | {} |
summary-request / summary-cancel | { id, text } / { id } |
view-change | { date, zoomLevel, canBack, canForward }, for a host toolbar; date is in the month shown |
notify | { text }, feedback for the user like "3 events copied" or a failed import, to show in a toast or status bar |
Styling
Everything is themed through CSS variables on the element, and every piece of the UI is exposed as a ::part. The canvas reads the same variables, so overrides apply to the drawn parts too.
calendar-view {
--cal-accent: hotpink;
--cal-font: "Inter", sans-serif;
--cal-event-radius: 0.125rem;
}
calendar-view::part(weekdays) {
text-transform: none;
}
Changes to the theme attribute, the system colour scheme, and class/style/data-theme on <html> or the element are picked up automatically. Other host-side theme changes, like a stylesheet swap, need a call to refreshTheme().
The sidebar slot holds anything you want next to the calendar.
Layers
The calendar is drawn in layers. Custom layers can be added next to the default ones, and toggleable layers are switched with setLayer(id, on). Use layer.label(calendar.strings) to name them in a host menu.
import { defaultLayers, type Layer } from "@atrium-ui/calendar";
const holidays: Layer = {
id: "holidays",
toggleable: true,
label: () => "Holidays",
draw({ ctx, scene, theme, model }) {
/* draw inside the day columns */
},
};
calendar.layers = [...defaultLayers, holidays];
Accessibility
- All interactions have keyboard shortcuts:
Tjumps to today,Esccancels or clears the selection,Deleteremoves the selection,Ctrl/Cmd + Ccopies it as iCalendar andCtrl/Cmd + Shift + Rrequests a refresh. - The
high-contrasttheme is available for users who need stronger contrast, and all colours can be overridden through CSS variables. - The event panel controls are labelled, and toggles expose their state with
aria-pressed. All UI text can be translated throughstrings. - The calendar has no toolbar of its own. Give the host's navigation buttons accessible labels and reflect
canBack/canForwardfromview-changeasdisabled. - Touch input is supported: one finger scrolls, a long press then drag creates, moves or resizes, and two fingers zoom and pan.
- The time grid is drawn on a canvas. Offer an alternative list of upcoming events where users depend on a screen reader.
| Input | Action |
|---|---|
| Wheel, trackpad, touch | scroll; middle-mouse drag pans |
| Ctrl/Cmd + wheel, pinch | zoom around the pointer |
| Drag in the gutter, Ctrl/Cmd + Alt + vertical drag | zoom |
| Drag on empty space | create; Shift freezes the length, Esc cancels |
| Drag an event / its edges | move / resize; Alt or Cmd while dragging duplicates; dragging out of the browser exports an .ics |
| Ctrl/Cmd + drag on empty space | box select; add Shift to add to the selection |
| Alt held | draw over existing events |