Eunomia v1.0.0-beta.2
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

Calendar

Calendar is the calendar grid that Scheduler renders internally: day, week, work-week, month, and year rendering, touch swipe-to-navigate, and a mobile day-selector strip for week and work-week, all with no built-in opinion on editing. Clicking an event or an empty slot only reports that it happened, and what happens next, such as a read-only popover, an inline editor, or a side panel, is entirely up to whoever's listening. A timed event in day, week, or work-week can also be dragged with a mouse or pen to a different time or day, and the same hands-off rule applies there too: dropping it only reports the moved event, never changing the calendar's own data itself (see "Moving an event by drag" further down).

This is an independent piece of the Scheduler feature, the primitive that building your own scheduler composes directly, alongside the event editor. It renders the compact event chip internally for every event shown on the grid, in every view except year (which shows a plain dot per busy day instead).

Overview API Examples Accessibility

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 Scheduler: Building your own scheduler for a complete example.

If you just need the full scheduler's own header and editing chrome as-is, use Scheduler directly instead. It renders calendar internally and wires all of this up for you.

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

NameTypeDefaultDescription
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-on0 | 11Which weekday a week starts on
day-start-hournumber0The first visible hour row in day, week, and work week
day-end-hournumber24The last visible hour row, exclusive, in day, week, and work week
slot-durationnumber30The grid's row granularity, in minutes
localestringThe locale used throughout. Defaults to the runtime's own
readonlybooleanfalseDisables the slot click event, removing the create-here affordance, and event-chip dragging. View only
no-animationbooleanfalseDisables the mobile swipe slide animation
more-events-labelstring'+{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 Types.

Properties

JS-only — no matching HTML attribute, set these from a script or a template binding.

NameTypeDefaultDescription
eventsArray<EunomiaSchedulerEvent>[]The events to display. Never mutated internally
displayedDateDatenew Date()The anchor date for the active view. Defaults to today, self-managed on swipe, always overridable from outside

Events

NameTypeDescription
eunperiodchangePeriodChangeEventFired whenever the visible period changes, from a swipe, a drill-down, or an external view or displayedDate assignment
euneventclickEventClickEventFired when an event chip is clicked
eunslotclickSlotClickEventFired when an empty hour-grid slot is clicked
euneventupdateEventUpdateEventFired 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 Events.

CSS custom properties

NameDescription
--scheduler-slot-heightSets the pixel height per slotDuration row in day, week, and work week
--scheduler-gutter-widthSets the width of the hour-label gutter
--scheduler-body-max-heightSets 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-heightSets the height of month view cells
--scheduler-swipe-durationSets 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-event renders as a native <button> with an aria-label combining the title and time (and end time, when known); see its own Accessibility tab.
  • Below the mobile breakpoint, week/work-week's day-selector strip is a role="tablist" of role="tab" buttons with aria-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.