Eunomia v1.1.0
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
hide-now-linebooleanfalseHides 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-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
localestring—The locale used throughout. Defaults to the one set with setDefaultLocale, English otherwise
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—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 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
visibleRangeDateRange—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

NameTypeDescription
eunperiodchangePeriodChangeEventFired 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
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, 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-heightSets the height of month view cells
--scheduler-swipe-durationSets the duration of the mobile swipe slide animation
--scheduler-accent-colorSets the color marking today, the selected day and the focus ring. Defaults to the theme's primary color
--scheduler-focus-outline-colorSets the keyboard focus outline color, falling back to --scheduler-accent-color
--scheduler-accent-text-colorSets the text color shown on the accent color, such as today's number in month view
--scheduler-now-line-colorSets 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.

Today
<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-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 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.