Calendar
Calendar is the calendar grid that
This is an independent piece of the
When to use
Reach for calendar directly whenever the full scheduler's own header and
editing surfaces, such as its drawer or modal and its built-in view
switcher, aren't the composition you want, but its calendar grid is: the
actual day, week, work-week, month, and year rendering, overlap layout,
all-day row, and mobile swipe and day strip. Drive it top-down like any
other controlled component, and listen for its click events to open
whatever editing surface fits your own interface, and for its period
change event to know the now-visible date range, handy for loading events
for that range on demand, or building your own period label without
re-deriving the date math yourself. See
If you just need the full scheduler's own header and editing chrome as-is,
use
Install & usage
npm install @eunomia/elements
import "@eunomia/elements/calendar.js";
<eun-calendar id="calendar" view="week"></eun-calendar>
<script>
const calendar = document.querySelector("#calendar");
calendar.events = [
{
id: "1",
title: "Design review",
color: "purple",
start: new Date(2026, 2, 16, 11, 0),
end: new Date(2026, 2, 16, 12, 0),
},
];
calendar.addEventListener("euneventclick", (event) => {
// event.event is the clicked EunomiaSchedulerEvent, event.anchor the chip
});
calendar.addEventListener("eunslotclick", (event) => {
// event.start is the clicked slot's start time, already snapped to the grid
});
</script>
events/displayedDate are attribute: false properties, so set them
from JavaScript, not as HTML attributes. view/displayedDate are
otherwise self-managed (swipe, month/year drill-down) but always
overridable from outside, the same "controlled, overridable" flow every
scheduler piece shares.
Alternatives
| You want… | Prefers |
|---|---|
| The full scheduler experience, with its own header and editing chrome already wired up | eun-scheduler |
Live testing
Properties
Calendar <eun-calendar>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| hide-now-line | boolean | false | Hides the line marking the current time in day, week and work week views |
| event-appearance | 'default' | 'transparent' | 'ghost' | 'default' | The appearance applied to day, week, and work week event chips. Month view is unaffected, always picking ghost, or default for multi-day bars, itself |
| view | 'day' | 'week' | 'work-week' | 'month' | 'year' | 'week' | The active calendar view |
| week-starts-on | 0 | 1 | 1 | Which weekday a week starts on |
| day-start-hour | number | 0 | The first visible hour row in day, week, and work week |
| day-end-hour | number | 24 | The last visible hour row, exclusive, in day, week, and work week |
| slot-duration | number | 30 | The grid's row granularity, in minutes |
| locale | string | — | The locale used throughout. Defaults to the one set with setDefaultLocale, English otherwise |
| readonly | boolean | false | Disables the slot click event, removing the create-here affordance, and event-chip dragging. View only |
| no-animation | boolean | false | Disables the mobile swipe slide animation |
| more-events-label | string | — | The template for the month view's overflow affordance, with the placeholder replaced by the hidden event count. Defaults to the localized string for the current locale (English: "+{count} more") |
Import the exact TypeScript type behind any property above, see
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| events | Array<EunomiaSchedulerEvent> | [] | The events to display. Never mutated internally |
| displayedDate | Date | new Date() | The anchor date for the active view. Defaults to today, self-managed on swipe, always overridable from outside |
| visibleRange | DateRange | — | The range currently visible for view and displayedDate, the same one `eunperiodchange` reports. In `month` view, it covers the whole 6-week grid, leading and trailing days included. |
| resolvedLocale | — | This instance's own `locale` when set, the shared default locale otherwise. Used for `Intl` formatting, so dates and numbers follow the same locale as dictionary. | |
| dictionary | — | This instance's locale-resolved string dictionary — see the class doc for the resolution order. |
Events
| Name | Type | Description |
|---|---|---|
| eunperiodchange | PeriodChangeEvent | Fired whenever the visible period changes, from a swipe, a drill-down, a navigation method, or an external view or displayedDate assignment. Not fired for the initial render, read visibleRange instead |
| euneventclick | EventClickEvent | Fired when an event chip is clicked |
| eunslotclick | SlotClickEvent | Fired when an empty hour-grid slot is clicked |
| euneventupdate | EventUpdateEvent | Fired when a day, week, or work week event chip is dragged to a new time or day and dropped |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --scheduler-slot-height | Sets the pixel height per slotDuration row in day, week, and work week |
| --scheduler-gutter-width | Sets the width of the hour-label gutter |
| --scheduler-body-max-height | Sets the maximum height, with scroll, of the day, week, and work week grid body and the year view, and of the month view when set (uncapped by default). Below a small viewport, the grid also fills its own host's height first, still capped at this same value |
| --scheduler-month-cell-height | Sets the height of month view cells |
| --scheduler-swipe-duration | Sets the duration of the mobile swipe slide animation |
| --scheduler-accent-color | Sets the color marking today, the selected day and the focus ring. Defaults to the theme's primary color |
| --scheduler-focus-outline-color | Sets the keyboard focus outline color, falling back to --scheduler-accent-color |
| --scheduler-accent-text-color | Sets the text color shown on the accent color, such as today's number in month view |
| --scheduler-now-line-color | Sets the color of the line marking the current time. Defaults to the critical color |
Views
Day view
A single-column hour grid, the finest-grained view: every event for one day, laid out by time.
<eun-calendar view="day"></eun-calendar>
Week view
Seven columns, Monday-to-Sunday by default (see "Week starts on" below), with overlapping events laid out side by side and an all-day row above the grid for anything that doesn't belong inside an hour slot.
<eun-calendar view="week"></eun-calendar>
Work week view
Every property and behavior week has (overlap layout, all-day row,
drag-to-move, mobile day strip), just with Saturday and Sunday removed
from the grid entirely, rather than shown disabled.
<eun-calendar view="work-week"></eun-calendar>
Month view
A full month of context at once. Each week shows as many events as fit
its own cell height before collapsing the rest behind a "+N more" button
(see "Month view overflow" further down for that specifically); clicking
a day or its number drills into day view for that date.
<eun-calendar view="month"></eun-calendar>
Year view
The 12 months as mini-month grids, several per row on a wide calendar.
On a narrow one, they stack in a single scrollable column sharing a
sticky weekday header, so scrolling down moves from month to month. A
plain dot marks
each day with at least one event rather than the event chip itself, since
individual events would be illegible at this scale (see "Aria attributes
and rules" in the Accessibility tab). Clicking a day drills into day
view, and clicking a month's label drills into month view for it.
<eun-calendar view="year"></eun-calendar>
Configuration
Week starts on
week-starts-on picks which weekday a week (and so week/work-week's
own columns, and month's own grid) begins on: 1 for Monday (the
default), or 0 for Sunday.
<eun-calendar view="month" week-starts-on="1"></eun-calendar>
<!-- Monday first -->
<eun-calendar view="month" week-starts-on="0"></eun-calendar>
<!-- Sunday first -->
Visible hours and slot duration
day-start-hour/day-end-hour bound which hours the day/week/work-week
grid shows at all (the default is a full day, 0 through 24), and
slot-duration sets the grid's row granularity in minutes, which also
sets the snap increment for creating an event by clicking a blank slot and
for dragging an existing one to a new time. A narrower business-hours
window with finer 15-minute slots below, instead of the default full day
at 30-minute granularity:
<eun-calendar
view="day"
day-start-hour="8"
day-end-hour="18"
slot-duration="15"
></eun-calendar>
Current time line
In day, week and work week views, a line marks the current time on
today's column. --scheduler-now-line-color changes its color, and
hide-now-line hides it.
<eun-calendar hide-now-line></eun-calendar>
eun-calendar {
--scheduler-now-line-color: var(--eun-color-primary-500);
}
Mobile day-selector strip
Below a 768px viewport, week and work-week swap their multi-column
grid for a day-selector strip listing the period's own days above a
single-day grid beneath it, the same iPhone Calendar-style layout
eun-scheduler uses. Selecting a day only changes which day's grid is
shown, never the visible period itself, so swiping still moves a whole
week (or work week) at a time.
Events
All-day and multi-day events
An event with allDay set, or whose end simply falls on a later
calendar day than its start, is never placed inside the timed hour grid.
It renders instead in a dedicated all-day row directly beneath the day
header, spanning every day it covers, in day, week, and work-week alike.
Several such events on overlapping days stack into as many rows as
actually needed.
A timed event's end is exclusive: an event from the 29th at 00:00 to
the 31st at 00:00 covers the 29th and 30th, in every view, and one ending
at the next midnight stays in its day's column. An all-day event's end
names its last day instead, which it covers.
<eun-calendar id="calendar" view="week"></eun-calendar>
<script>
const calendar = document.querySelector("#calendar");
calendar.events = [
{
id: "1",
title: "Company offsite",
color: "green",
allDay: true,
start: new Date(2026, 2, 16),
end: new Date(2026, 2, 16),
},
{
id: "2",
title: "Conference",
color: "purple",
start: new Date(2026, 2, 17),
end: new Date(2026, 2, 19), // spans three days: never placed in the timed grid
},
];
</script>
Event appearance
event-appearance sets the visual weight of every timed event chip in day,
week, and work-week: default (solid tint, the default), transparent,
or ghost (outline only). month view picks its own per event instead
(ghost normally, default for a multi-day bar), and event-appearance has
no effect there.
<eun-calendar view="day" event-appearance="transparent"></eun-calendar>
<eun-calendar view="day" event-appearance="ghost"></eun-calendar>
Month view overflow
Once a day has more events than fit month's current cell height, the
rest collapse behind a "+N more" button instead of shrinking or
overflowing the cell, sized live off the cell's own rendered height, so a
taller --scheduler-month-cell-height (or a larger base font size) always
shows as many events as genuinely fit first. Clicking it drills into day
view for that date, same as clicking the day number itself.
more-events-label customizes its text, with {count} replaced by the
hidden count.
<eun-calendar view="month" more-events-label="{count} hidden"></eun-calendar>
Interaction
Navigating
eun-calendar has no header of its own, so your own buttons call
previous(), next() and today(), which move the displayed period by
one unit of the current view. visibleRange gives the range shown right
now, and eunperiodchange reports every change after the first render,
along with the displayedDate it is anchored on. In month view, the range
covers the whole grid, leading and trailing days included, so use
displayedDate to label the month.
<header>
<button type="button" id="previous" aria-label="Previous month">‹</button>
<button type="button" id="today">Today</button>
<button type="button" id="next" aria-label="Next month">›</button>
<strong id="label"></strong>
</header>
<eun-calendar id="calendar" view="month"></eun-calendar>
<script>
const calendar = document.querySelector("#calendar");
const label = document.querySelector("#label");
const renderLabel = (date) => {
label.textContent = date.toLocaleDateString(undefined, {
month: "long",
year: "numeric",
});
};
renderLabel(calendar.displayedDate);
calendar.addEventListener("eunperiodchange", (event) => {
renderLabel(event.displayedDate);
});
document.querySelector("#previous").onclick = () => calendar.previous();
document.querySelector("#today").onclick = () => calendar.today();
document.querySelector("#next").onclick = () => calendar.next();
</script>
Listening for intent
eun-calendar never opens an editor itself: an empty slot click only
reports the snapped start time, and an event click only reports the event
and the chip it came from. Log both below (open your console).
<eun-calendar id="calendar" view="day"></eun-calendar>
<script>
const calendar = document.querySelector("#calendar");
calendar.addEventListener("euneventclick", (event) => {
console.log(event.event, event.anchor);
});
calendar.addEventListener("eunslotclick", (event) => {
console.log(event.start);
});
</script>
Moving an event by drag
A timed event chip in day/week/work-week can also be dragged (mouse
or pen only) to a different time and/or day, not just clicked. Press and
drag it, and a floating ghost follows the pointer, its own time label
live-updating to whatever slot it would land in. Dropping it then
dispatches EventUpdateEvent (euneventupdate) with the moved event,
in the same start-preserving-duration shape a form save would produce,
with every other field (title, color, guests, ...) carried over
unchanged. Nothing is mutated internally either way: reassign events
yourself in response, the same "controlled component" contract every
other piece of this page follows.
Drag the event below to a different day or time. The label underneath and the console (open yours) both update from the dispatched event.
Latest position:
<eun-calendar id="calendar" view="week"></eun-calendar>
<script>
const calendar = document.querySelector("#calendar");
calendar.addEventListener("euneventupdate", (event) => {
// event.event : the moved event, start/end already updated ; every
// other field (title, color, guests, ...) carried over unchanged.
saveToBackend(event.event); // your own API call
events = events.map((existing) =>
existing.id === event.event.id ? event.event : existing,
);
calendar.events = events;
});
</script>
Escape cancels a drag mid-gesture, same as dropping it back over its own original slot: nothing is dispatched either way, and the chip settles back exactly where it started.
Readonly
Set readonly to disable the "create here" affordance: empty slots no
longer dispatch eunslotclick, while event chips stay clickable, still
dispatching euneventclick (a read-only consultation view is still a
useful listener). Dragging to move an event is disabled the same way.
<eun-calendar view="day" readonly></eun-calendar>
Custom colors
The custom properties that change colors, borders, shadows and corners, at a glance. The API tab lists every property of the component.
| Variable | Sets |
|---|---|
--scheduler-accent-color |
Sets the color marking today, the selected day and the focus ring, the theme's primary color by default |
--scheduler-focus-outline-color |
Sets the keyboard focus outline color, falling back to --scheduler-accent-color |
--scheduler-accent-text-color |
Sets the text color shown on the accent color, such as today's number in month view |
--scheduler-now-line-color |
Sets the color of the line marking the current time. Defaults to the critical color |
Server-side rendering
The grid is drawn inside the calendar's shadow root, from properties a
script sets such as events and displayedDate, so a server-rendered
calendar takes no space until the element upgrades, then pushes the
content below it. Link scheduler-ssr.css in the page's <head> to keep its room from the
first paint:
<link
rel="stylesheet"
href="node_modules/@eunomia/elements/dist/ssr/scheduler-ssr.css"
/>
A bundler that resolves package exports can reach it as
@eunomia/elements/scheduler-ssr.css. The same stylesheet covers
eun-calendar, eun-scheduler, eun-scheduler-event and
eun-scheduler-event-editor.
It reserves the default height of the view set by view, on desktop and
on mobile, and follows --scheduler-body-max-height. The grid and the
events show with the upgrade. With day-start-hour or day-end-hour,
the height depends on the hours shown, and nothing is reserved.
Keyboard interactions
| Key | Action |
|---|---|
Tab / Shift+Tab |
Moves focus through the active view's cells/chips |
ArrowLeft/ArrowRight/ArrowUp/ArrowDown (month view) |
Moves the roving-tabindex focus by day/week |
Home / End (month view) |
Jumps focus to the start/end of the focused week |
PageUp / PageDown (month view) |
Changes the displayed month (Shift for a year instead) |
Enter / Space (an event chip) |
Dispatches euneventclick for that event |
Enter / Space (a month/year day) |
Drills into day view for that date |
Month view day cells follow the same roving-tabindex pattern
eun-date-picker's own day grid uses: ArrowLeft/ArrowRight/
ArrowUp/ArrowDown move focus by day/week, Home/End jump to the
focused day's week bounds, PageUp/PageDown (Shift for a year instead
of a month) change the displayed month, and Enter/Space activate the
focused cell (a native <button>, no custom handling needed for those
two). Moving focus past the currently displayed month's grid updates
displayedDate to bring the new month into view, same as
eun-date-picker's own PageUp/PageDown. Year view does not follow
this pattern, see below.
Deliberately bounded: empty-slot creation in day/week/work-week
Creating an event by clicking a blank spot on the hour grid is pointer/
touch-only, since there's no per-slot keyboard focus target. A full
roving-tabindex 2D grid over every slot across up to 7 columns would mean
dozens of tab stops for one view, while event chips themselves remain
fully keyboard-focusable/operable (native <button>s), since there are
far fewer of them than there are slots. Give consumers a keyboard-reachable
"New event" affordance of their own (see eun-scheduler's own header) for
full keyboard parity.
Dragging a chip to move it (see "Moving an event by drag" on the Overview
tab) is bounded the same way, and mouse/pen only even among pointer
types: a touch press on a chip is left to scroll/swipe instead of
starting a drag, the same restriction the mobile swipe-to-navigate gesture
enforces the other way around. A keyboard user reschedules an event
through the editor instead: open it (Enter/Space on the chip, or
eun-scheduler's own popover → Edit), change the start/end fields, and
save, which dispatches the exact same EventUpdateEvent a drag would
have, just through a fully keyboard-operable form. Escape still cancels
an in-progress mouse drag mid-gesture, for whoever started one and
changed their mind, but that's a pointer-drag affordance, not a keyboard
entry point of its own.
Year view is similarly a coarse, primarily mouse/touch-driven overview (as
in most calendar apps) rather than a roving-tabindex surface: its day
cells are still real, individually reachable <button>s, just without the
arrow-key roving month/day grids get.
Aria attributes and rules
- Month/year view day cells expose a full
aria-label(weekday, day, month, year) rather than just the bare day number. eun-scheduler-eventrenders as a native<button>with anaria-labelcombining the title and time (and end time, when known); see its ownAccessibility tab .- Below the mobile breakpoint,
week/work-week's day-selector strip is arole="tablist"ofrole="tab"buttons witharia-selected, switching which day's grid is shown beneath it. - The day/week/work-week grid scrolls both horizontally and vertically
within itself on narrow viewports, with its header and all-day rows
and its hour gutter each independently
position: sticky, so day labels, all-day events and hour labels all stay in view.