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
eun-schedulerLive testing
Properties
Calendar <eun-calendar>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| event-variant | 'default' | 'transparent' | 'ghost' | 'default' | The visual variant 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 runtime's own |
| 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 | '+{count} more' | The template for the month view's overflow affordance, with the placeholder replaced by the hidden event count |
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 |
Events
| Name | Type | Description |
|---|---|---|
| eunperiodchange | PeriodChangeEvent | Fired whenever the visible period changes, from a swipe, a drill-down, or an external view or displayedDate assignment |
| 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. 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 |
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
12 months stacked 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>
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>
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.
<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 variant
event-variant 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-variant has
no effect there.
<eun-calendar view="day" event-variant="transparent"></eun-calendar>
<eun-calendar view="day" event-variant="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>
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.
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.
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>
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 own header row and hour
gutter each independently
position: sticky, so day labels and hour labels both stay in view.