ComponentStandalone

Event calendar

Month, week and day views of a set of events — draggable, resizable, keyboard-operable.

Installation

import { WrCalendarEventTemplate, WrEventCalendar } from 'ngwr/event-calendar';

// WrCalendarEventTemplate is the <ng-template wrCalendarEvent> directive in the
// chip-template example below — the selector is wrCalendarEvent, the class is
// not, and imports: [] takes the class.
@Component({ imports: [WrEventCalendar, WrCalendarEventTemplate] })
export class MyComponent {}

Register a date adapter

Every date calculation and every label goes through WrDateAdapter, the same one the calendar and the date pickers use. Register one at bootstrap or the calendar has no way to name a month.

import { provideWrDateAdapter } from 'ngwr/date';
import { provideWrDateFnsAdapter } from 'ngwr/date/adapters/fns';

bootstrapApplication(App, {
  providers: [provideWrDateFnsAdapter()],
});

Views

One component covers all three views. view and date are two-way bindable, so the built-in header is a convenience rather than the only way to navigate — bind them and drive the calendar from your own toolbar, or set hideHeader.

<wr-event-calendar [events]="events()" [(view)]="view" [(date)]="anchor" />

September 2026

Sun
Mon
Tue
Wed
Thu
Fri
Sat

Events

end is exclusive: an event running 09:00–10:00 does not overlap one that starts at 10:00, and a single all-day event ends at midnight the next day. That one rule makes adjacency and overlap the same comparison in every view. color takes any intent, and editable: false pins an individual event while the rest of the calendar stays draggable.

protected readonly events = signal<readonly WrCalendarEvent[]>([
  { id: 1, title: 'Design review', start: at(0, 10), end: at(0, 11, 30), color: 'primary' },
  { id: 5, title: 'Conference', start: at(2, 0), end: at(5, 0), allDay: true, color: 'secondary' },
  { id: 7, title: 'Frozen — no deploys', start: at(6, 0), end: at(7, 0), allDay: true, editable: false },
]);

Drag to move and resize

events is an input and the calendar never writes to it. A drag emits eventChange with where the event *would* land and stops there — apply it to your own state and the new position renders on the next pass. So ignoring the output cancels the drag, an optimistic update is a single update, and a server rejection needs no rollback path inside the component. Drag a chip below, or focus one and hold Alt with the arrow keys.

<wr-event-calendar
  editable
  [events]="events()"
  [(view)]="view"
  [(date)]="anchor"
  (eventChange)="apply($event)"
/>
protected apply(change: WrCalendarEventChange): void {
  this.events.update(events =>
    events.map(event =>
      event.id === change.event.id ? { ...event, start: change.start, end: change.end } : event
    )
  );
}

Sep 20, 2026 – Sep 26, 2026

Sun20
Mon21
Tue22
Wed23
Thu24
Fri25
Sat26
All day
08:00 AM
09:00 AM
10:00 AM
11:00 AM
12:00 PM
01:00 PM
02:00 PM
03:00 PM
04:00 PM
05:00 PM
06:00 PM

Last output: —

The time grid

dayStartHour and dayEndHour trim the visible band — a calendar that opens on 3am dead space wastes most of its height. slotMinutes sets both the row height and the drag snap, so a 15-minute grid drags in 15-minute steps.

<wr-event-calendar
  view="week"
  [slotMinutes]="15"
  [dayStartHour]="9"
  [dayEndHour]="14"
  [events]="events()"
/>

Sep 20, 2026 – Sep 26, 2026

Sun20
Mon21
Tue22
Wed23
Thu24
Fri25
Sat26
All day
09:00 AM
10:00 AM
11:00 AM
12:00 PM
01:00 PM

Creating from empty space

Clicking anywhere that is not a chip emits slotClick with the slot the user pointed at — a whole day in month view and the all-day band, the pointed-at row otherwise. From the keyboard it is Enter on an empty cell.

<wr-event-calendar [events]="events()" (slotClick)="compose($event)" />

Custom chip contents

wrCalendarEvent replaces what a chip renders. Position, sizing and drag stay with the calendar, so a custom template cannot break the geometry.

<wr-event-calendar [events]="events()">
  <ng-template wrCalendarEvent let-event>
    <strong>{{ event.title }}</strong>
    <small>{{ event.data.room }}</small>
  </ng-template>
</wr-event-calendar>

Keyboard

The grid is a single tab stop with a roving cursor, so tabbing through a month does not mean tabbing through 42 cells. A chip is its own stop INSIDE that cell, which is why the two tables below are separate: the same arrow key means something different depending on which of the two has focus.

The grid. Focus is on a cell — the cursor cell, not a chip inside it.
KeyDoes
ArrowLeftArrowRightPrevious / next day, mirrored under dir="rtl". Stops at the edges of the view.
ArrowUpArrowDown One week in month view, one slotMinutes row in week and day view. Holds position at the first and last row rather than wrapping sideways.
HomeEndFirst / last day of the current week row.
EnterSpace On an empty cell, emits slotClick with the slot pointed at. On a cell holding events, focuses the first chip instead.
A chip. Focus is on the event itself.
KeyDoes
EscapeBack to the cell the chip lives in.
Alt + arrows MOVES the event — a day sideways, and a week (month view) or one slotMinutes step (week and day view) vertically. Emits eventChange with kind: 'move': the keyboard twin of the drag, and like the drag it changes nothing until you apply it.
Shift + Alt + arrows RESIZES it, moving the end alone — kind: 'resize'. A resize that would end at or before the start is refused rather than inverting the event. This exists because the pointer's resize grab area is aria-hidden and unfocusable on purpose; without it, lengthening an event would be a mouse-only operation.

Both chip gestures need editable on the calendar, and are refused for an individual event carrying editable: false — the same pair of gates the pointer path checks, so the keyboard and the mouse allow exactly the same set of edits.

API

NameDescriptionTypeDefault
eventsEverything to render. Never mutated — see eventChange.readonly WrCalendarEvent[][]
viewWhich span is shown. Two-way bindable.WrCalendarView'month'
dateAny date inside the shown span. Two-way bindable.Datenew Date()
viewsWhich buttons the view switcher offers. Empty hides it.readonly WrCalendarView[]['month', 'week', 'day']
editableAllow dragging chips to move and resize them.booleanfalse
slotMinutesMinutes per row in the time views — also the drag snap.number30
dayStartHourFirst hour the time views show.number0
dayEndHourFirst hour they do NOT show, exclusive.number24
maxLanesLanes a month cell shows before collapsing the rest into “+N more”.number3
hideHeaderHide the built-in header — supply your own navigation instead.booleanfalse
(eventClick)A chip was activated.WrCalendarEvent
(slotClick)Empty space was activated — the slot the user pointed at.WrCalendarSlot
(eventChange)A drag or an Alt + arrow finished. Apply it yourself; nothing moves until you do.WrCalendarEventChange
ariaLabelAccessible name of the grid; overridable for a page with several.string | nullnull

CSS variables

Custom properties ngwr/event-calendar publishes. Each default below is declared on the component's own selector, so a :root override is shadowed by it — set them on that selector, on a wrapper you scope yourself, or inline on the element. Unlike the BEM class names, these are the supported way to restyle the component.

VariableDefaultDeclared on
--wr-event-calendar-band-height1.25rem.wr-event-calendar
--wr-event-calendar-chip-gap0.25rem.wr-event-calendar
--wr-event-calendar-chip-inset0.125rem.wr-event-calendar
--wr-event-calendar-chip-radiuscalc(var(--wr-border-radius-sm) / 2).wr-event-calendar
--wr-event-calendar-cols7.wr-event-calendar
--wr-event-calendar-fillvar(--wr-color-fill).wr-event-calendar
--wr-event-calendar-gutter3.75rem.wr-event-calendar
--wr-event-calendar-linevar(--wr-color-outline).wr-event-calendar
--wr-event-calendar-slot-height1.5rem.wr-event-calendar