Skip to content

FullCalendar — the cross-framework calendar & scheduler

FullCalendar is Rozie's data-bound port of FullCalendar — the vanilla-JS calendar/scheduler engine. FullCalendar already publishes four official wrappers (@fullcalendar/react, @fullcalendar/vue3, @fullcalendar/angular, @fullcalendar/svelte) — each one wraps the same Calendar engine, and Solid and Lit have none. Rozie ships the same <FullCalendar> (same props, events, two-way view binding, and imperative handle) in React, Vue, Svelte, Angular, Solid, and Lit.

This page is the show-and-tell: the API surface, per-framework quick starts, the imperative handle, the :options long-tail passthrough, the opt-in plugin-extension model, and the per-target recipe for the ten custom-content portal slots.

The full source for FullCalendar.rozie lives in the @rozie-ui/fullcalendar package.

The @rozie-ui/fullcalendar packages

FullCalendar ships as six pre-compiled, per-framework packages; install only the one for your framework. There is no build step and no Rozie toolchain to add:

PackageInstallREADME
@rozie-ui/fullcalendar-reactnpm i @rozie-ui/fullcalendar-reactreact/README
@rozie-ui/fullcalendar-vuenpm i @rozie-ui/fullcalendar-vuevue/README
@rozie-ui/fullcalendar-sveltenpm i @rozie-ui/fullcalendar-sveltesvelte/README
@rozie-ui/fullcalendar-angularnpm i @rozie-ui/fullcalendar-angularangular/README
@rozie-ui/fullcalendar-solidnpm i @rozie-ui/fullcalendar-solidsolid/README
@rozie-ui/fullcalendar-litnpm i @rozie-ui/fullcalendar-litlit/README

Each package carries the four @fullcalendar/* engine peers@fullcalendar/core, @fullcalendar/daygrid, @fullcalendar/timegrid, and @fullcalendar/interaction (all ^6.1) — plus its framework peer (react + react-dom, vue, svelte, @angular/core + @angular/common, solid-js, or lit). Install the engine peers alongside the framework package:

bash
npm i @rozie-ui/fullcalendar-react \
  @fullcalendar/core @fullcalendar/daygrid @fullcalendar/timegrid @fullcalendar/interaction

No manual stylesheet import is needed. FullCalendar v6 auto-injects its own CSS at runtime — there is no import 'fullcalendar/...css' line to add (unlike the date-picker port, which requires a vendor stylesheet import). The wrapper's own <style> block carries only its layout box; the global .fc-* calendar styling comes from the engine itself.

Quick start

The two-way value is view — the active view name string ('dayGridMonth', 'timeGridWeek', 'timeGridDay', …). Clicking the calendar's own toolbar writes the new view name back through the two-way path, and a consumer write switches the calendar's view. Events are passed via :events; @eventClick surfaces the structured payload.

React

tsx
import { useState } from 'react';
import { FullCalendar } from '@rozie-ui/fullcalendar-react';

export function Demo() {
  const [view, setView] = useState('dayGridMonth');
  const [events] = useState([{ id: '1', title: 'Kickoff', start: '2026-06-04' }]);
  return (
    <FullCalendar
      view={view}
      onViewChange={setView}
      events={events}
      onEventClick={(e) => console.log(e.event, e.jsEvent)}
    />
  );
}

Vue

vue
<script setup lang="ts">
import { ref } from 'vue';
import FullCalendar from '@rozie-ui/fullcalendar-vue';

const view = ref('dayGridMonth');
const events = ref([{ id: '1', title: 'Kickoff', start: '2026-06-04' }]);
</script>

<template>
  <FullCalendar v-model:view="view" :events="events" @eventClick="(e) => console.log(e.event)" />
</template>

Svelte

svelte
<script lang="ts">
  import FullCalendar from '@rozie-ui/fullcalendar-svelte';

  let view = $state('dayGridMonth');
  let events = $state([{ id: '1', title: 'Kickoff', start: '2026-06-04' }]);
</script>

<FullCalendar bind:view {events} oneventClick={(e) => console.log(e.event)} />

Angular

ts
import { Component } from '@angular/core';
import { FullCalendar } from '@rozie-ui/fullcalendar-angular';

@Component({
  selector: 'app-demo',
  standalone: true,
  imports: [FullCalendar],
  template: `<FullCalendar [(view)]="view" [events]="events" (eventClick)="onEventClick($event)" />`,
})
export class DemoComponent {
  view = 'dayGridMonth';
  events = [{ id: '1', title: 'Kickoff', start: '2026-06-04' }];
  onEventClick(e: { event: unknown; jsEvent: unknown }) {
    console.log(e.event);
  }
}

Solid

tsx
import { createSignal } from 'solid-js';
import { FullCalendar } from '@rozie-ui/fullcalendar-solid';

export function Demo() {
  const [view, setView] = createSignal('dayGridMonth');
  const [events] = createSignal([{ id: '1', title: 'Kickoff', start: '2026-06-04' }]);
  return (
    <FullCalendar
      view={view()}
      onViewChange={setView}
      events={events()}
      onEventClick={(e) => console.log(e.event)}
    />
  );
}

Lit

ts
import '@rozie-ui/fullcalendar-lit';

// <rozie-full-calendar> is a custom element. Bind `view`/`events` as
// properties and listen for the `view-change` / `event-click` events.
const el = document.querySelector('rozie-full-calendar');
el.view = 'dayGridMonth';
el.events = [{ id: '1', title: 'Kickoff', start: '2026-06-04' }];
el.addEventListener('view-change', (e) => { el.view = e.detail; });
el.addEventListener('event-click', (e) => {
  console.log(e.detail.event, e.detail.jsEvent);
});

API

Props

NameTypeDefaultTwo-way (model)Description
eventsArray[]The event objects rendered on the calendar. Each event is normalized: a missing title falls back to Event <id>, and a missing color inherits defaultColor. Runtime-updatable — changing the array reconciles the live calendar via removeAllEvents + addEvent.
viewString"dayGridMonth"The two-way active view name ('dayGridMonth', 'timeGridWeek', 'timeGridDay', …). The calendar's own toolbar writes back through the two-way path; a consumer write calls changeView.
weekendsBooleantrueShow Saturday/Sunday columns. Runtime-updatable via setOption.
editableBooleantrueAllow events to be dragged and resized. Runtime-updatable.
selectableBooleantrueAllow date/time-range selection by click-drag. Runtime-updatable.
heightNumber480Calendar height in pixels. Runtime-updatable.
defaultColorString"#3b82f6"Fallback event color stamped onto events that omit their own color.
localeString"en"FullCalendar locale code. Runtime-updatable. An object locale is an untyped runtime escape hatch — pass it through setOption via the handle if needed.
firstDayNumber0First day of the week (0 = Sunday … 1 = Monday). Runtime-updatable.
slotDurationString"00:30:00"Time-grid slot length in HH:mm:ss. Runtime-updatable.
nowIndicatorBooleanfalseRender the current-time indicator line in time-grid views. Runtime-updatable.
headerToolbarObject{…}The toolbar layout ({ left, center, right }). A consumer-passed object fully replaces the built-in default. Runtime-updatable.
optionsObject{}Long-tail passthrough — an arbitrary bag of FullCalendar options/callbacks the curated surface doesn't special-case (businessHours, dayMaxEvents, *DidMount hooks, locale objects, …). Spread first into the engine config so the curated props/events/slots win on key collision — the curated surface stays primary; :options only fills gaps. Runtime-updatable per key via setOption (no key-removal reset — a removed key keeps its last applied value until remount; use getApi() for full imperative control).

Events

EventDescription
eventClickAn event was clicked. Payload: { event: { id, title, start, end }, jsEvent }.
dateClickA date/cell was clicked. Payload: { date, dateStr, allDay }.
eventDropAn event was dragged to a new date. Payload: { event: { id, title, start, end }, delta }.
selectA date/time range was selected. Payload: { start, end, startStr, endStr, allDay }.
eventResizeAn event was resized. Payload: { event: { id, title, start, end }, startDelta, endDelta }.
datesSetThe visible date range changed (navigation or view switch). Payload: { start, end, view }.
eventMouseEnterThe pointer entered a calendar event. Payload: { event: { id, title, start, end }, jsEvent } (mirrors eventClick).
eventMouseLeaveThe pointer left a calendar event. Payload: { event: { id, title, start, end }, jsEvent } (mirrors eventMouseEnter).
unselectA previously selected date/time range was cleared. Payload: { jsEvent }.
loadingThe calendar began or finished loading events (e.g. from an event source). Payload: { isLoading } boolean.
eventsSetThe set of rendered events changed. Payload: { events: [{ id, title, start, end }, …] } — the normalized current event set, for persistence/sync consumers.

Imperative handle

Beyond props/events, the component exposes imperative methods declared once in the Rozie source via $expose. Grab a handle with your framework's native ref mechanism (React useRef / Vue template ref / Svelte bind:this / Angular viewChild / Solid callback ref / the Lit custom element itself) and call them directly:

MethodDescription
getApiReturn the underlying FullCalendar Calendar instance for direct API access. Returns the raw instance (null before mount).
changeViewSwitch the active view — changeView(viewName, dateOrRange?).
addEventAdd an event — addEvent(eventInput, source?).
removeEventRemove an event by id — removeEvent(id).
todayNavigate to today.
prevNavigate to the previous date range.
nextNavigate to the next date range.
gotoDateNavigate to a specific date — gotoDate(date).
getDateReturn the calendar's current anchor date (null before mount). The view model carries only the view type and datesSet only the visible range; this reads the current navigation date on demand.
getEventsReturn the current event objects synchronously ([] before mount). The pull-based twin of the push-only eventsSet event.
scrollToTimeScroll a time-grid view to a given time: scrollToTime(duration).
updateSizeRecompute the calendar layout after its container resizes.
prevYearNavigate to the previous year (mirrors prev).
nextYearNavigate to the next year (mirrors next).
selectRangeSelect a date/time range: selectRange(start, end?) (the Calendar API's select). Named selectRange because select is an emitted event.
clearSelectionClear the current date/time selection (the Calendar API's unselect). Named clearSelection because unselect is an emitted event.

React example:

tsx
import { useRef } from 'react';
import { FullCalendar, type FullCalendarHandle } from '@rozie-ui/fullcalendar-react';

const cal = useRef<FullCalendarHandle>(null);
// <FullCalendar ref={cal} ... />
cal.current?.next();
const api = cal.current?.getApi();

The selection verbs are named selectRange / clearSelection rather than bare select / unselect: this component emits select and unselect events, and an exposed method may not share a name with an emitted event (the same collision rule behind Flatpickr's openPicker / closePicker). Every other verb name is clear of the event and prop names as-is.

Slots

The wrapper surfaces ten of FullCalendar's *Content render hooks as portal slots — one authoring surface each that the per-target compiler routes through the framework's native imperative-render API (React/Solid render prop, Vue scoped slot, Svelte snippet, Angular content-child <ng-template>, Lit property bridge). Each slot is guarded in the wrapper: fill it and your fragment renders; leave it unfilled and FullCalendar's default rendering stands. Every slot receives one scope param, arg — FullCalendar's render argument for that hook.

SlotFullCalendar optionRendersDemo-verified
eventeventContentPer-event cell content (arg.event.title, arg.event.start, …)
dayCelldayCellContentDay-grid cell content (arg.date, arg.dayNumberText, …)
dayHeaderdayHeaderContentColumn-header content (arg.text, arg.date, …)
slotLabelslotLabelContentTime-grid axis slot labels (arg.text, arg.date, …)
weekNumberweekNumberContentWeek-number cell content (arg.num, arg.text, …)
nowIndicatorContentnowIndicatorContentCurrent-time indicator content (arg.isAxis, arg.date, …)
moreLinkmoreLinkContent"+N more" link content (arg.num, arg.text, …)
allDayContentallDayContentTime-grid all-day axis label content (arg.text, …)
slotLaneContentslotLaneContentTime-grid time-slot lane content (arg.date, arg.time, …)
noEventsContentnoEventsContentList-view "no events to display" content (arg.text, …) — inert until you engage @fullcalendar/list via :options.plugins and show an empty list view

All ten share the identical per-target authoring shape shown below — the only thing that changes is the slot name and the arg payload (per FullCalendar's hook for that surface). The three pixel-baselined slots (event, dayCell, dayHeader) are wired into the VR matrix; the seven long-tail slots (slotLabel, weekNumber, nowIndicatorContent, moreLink, allDayContent, slotLaneContent, noEventsContent) use the same recipe with no extra ceremony. The all-slots behavioral spec mounts all ten — including noEventsContent via a consumer-supplied list plugin, which transitively proves the plugin merge.

The current-time-indicator slot is named nowIndicatorContent (after FullCalendar's nowIndicatorContent option) so it does not clash with the boolean nowIndicator prop — a slot whose name equals a declared prop name is a hard compile error in Rozie (Svelte 5 unifies snippets and props into one $props namespace). Fill #nowIndicatorContent to customize the indicator content, and set the nowIndicator prop to true to actually enable it.

Custom event content

FullCalendar's eventContent option lets you replace the default per-cell title text with your own markup. The wrapper surfaces it as the event portal slot — a single authoring surface that the per-target compiler routes through each framework's native imperative-render API. Consumers fill it the same way they fill any other named slot. The slot receives one scope param, arg (FullCalendar's event-render argument; arg.event.title, arg.event.start, etc.).

Portal slots unlock the "foreign-engine cell rendering" pattern: FullCalendar owns the cell <div>, but the consumer's framework-native fragment is mounted inside it and disposed when the cell is torn down. See the portal-slot primitive for the underlying mechanism. (Note: portal slots are not reactive after mount in v1 — FullCalendar re-invokes eventContent when the event data changes, which is how the engine works anyway.)

React (render prop):

tsx
<FullCalendar
  view={view}
  events={events}
  renderEvent={({ arg }) => <span className="fc-event-title">{arg.event.title}</span>}
/>

Solid (render prop):

tsx
<FullCalendar
  view={view()}
  events={events()}
  event={({ arg }) => <span class="fc-event-title">{arg.event.title}</span>}
/>

Vue (scoped slot):

vue
<FullCalendar v-model:view="view" :events="events">
  <template #event="{ arg }">
    <span class="fc-event-title">{{ arg.event.title }}</span>
  </template>
</FullCalendar>

Svelte (snippet):

svelte
<FullCalendar bind:view {events}>
  {#snippet event({ arg })}
    <span class="fc-event-title">{arg.event.title}</span>
  {/snippet}
</FullCalendar>

Angular (content child <ng-template>):

html
<FullCalendar [(view)]="view" [events]="events">
  <ng-template #event let-arg="arg">
    <span class="fc-event-title">{{ arg.event.title }}</span>
  </ng-template>
</FullCalendar>

Lit (slot bridge — pass the render callback as a property):

ts
const el = document.querySelector('rozie-full-calendar');
el.event = ({ arg }) => html`<span class="fc-event-title">${arg.event.title}</span>`;

On every target the wrapper's $portals.event(node, { arg }) closure mounts the consumer's fragment into the engine-owned cell container and returns a dispose handle the engine calls on cell teardown.

Custom day-cell content

The dayCell portal slot (FullCalendar's dayCellContent) replaces a day-grid cell's default content. Same authoring shape as event — only the slot name and arg payload differ (arg.date, arg.dayNumberText, …):

React / Solid (render prop — renderDayCell on React, dayCell on Solid):

tsx
// React
<FullCalendar view={view} events={events}
  renderDayCell={({ arg }) => <span className="my-day">{arg.dayNumberText}</span>} />

// Solid
<FullCalendar view={view()} events={events()}
  dayCell={({ arg }) => <span class="my-day">{arg.dayNumberText}</span>} />

Vue (scoped slot):

vue
<FullCalendar v-model:view="view" :events="events">
  <template #dayCell="{ arg }">
    <span class="my-day">{{ arg.dayNumberText }}</span>
  </template>
</FullCalendar>

Svelte (snippet):

svelte
<FullCalendar bind:view {events}>
  {#snippet dayCell({ arg })}
    <span class="my-day">{arg.dayNumberText}</span>
  {/snippet}
</FullCalendar>

Angular (content child <ng-template>):

html
<FullCalendar [(view)]="view" [events]="events">
  <ng-template #dayCell let-arg="arg">
    <span class="my-day">{{ arg.dayNumberText }}</span>
  </ng-template>
</FullCalendar>

Lit (property bridge):

ts
el.dayCell = ({ arg }) => html`<span class="my-day">${arg.dayNumberText}</span>`;

Custom day-header content

The dayHeader portal slot (FullCalendar's dayHeaderContent) replaces a column header's default text. Identical recipe — substitute dayHeader for the slot name and read arg.text / arg.date:

vue
<!-- Vue -->
<FullCalendar v-model:view="view" :events="events">
  <template #dayHeader="{ arg }">
    <strong class="my-header">{{ arg.text }}</strong>
  </template>
</FullCalendar>
tsx
// React
<FullCalendar view={view} events={events}
  renderDayHeader={({ arg }) => <strong className="my-header">{arg.text}</strong>} />

The Solid (dayHeader={…}), Svelte ({#snippet dayHeader(…)}), Angular (<ng-template #dayHeader>), and Lit (el.dayHeader = …) forms follow the exact shapes shown for event/dayCell above.

The long-tail slots

slotLabel, weekNumber, nowIndicatorContent, moreLink, allDayContent, slotLaneContent, and noEventsContent use the same shared recipe — fill the like-named slot and read its arg. Each maps one-to-one to a FullCalendar *Content option:

SlotOptionTypical arg fields
slotLabelslotLabelContentarg.text, arg.date
weekNumberweekNumberContentarg.num, arg.text
nowIndicatorContentnowIndicatorContentarg.isAxis, arg.date
moreLinkmoreLinkContentarg.num, arg.text
allDayContentallDayContentarg.text
slotLaneContentslotLaneContentarg.date, arg.time
noEventsContentnoEventsContentarg.text

allDayContent and slotLaneContent render in time-grid views (timeGridWeek / timeGridDay) — the all-day axis label and the per-slot lane content respectively. Both work with the bundled @fullcalendar/timegrid plugin, no extra peer required. noEventsContent is pre-declared but inert with the bundled plugin set: it surfaces only in a list view (listWeek / listDay / listMonth) showing zero events, and list views exist only once you add the free @fullcalendar/list plugin — see Adding plugins.

For example, a custom week-number badge in Vue:

vue
<FullCalendar v-model:view="view" :events="events">
  <template #weekNumber="{ arg }">
    <span class="wk">W{{ arg.num }}</span>
  </template>
</FullCalendar>

Adding plugins

The wrapper ships batteries-included, zero-config: three FullCalendar plugins are baked in and always on — @fullcalendar/daygrid, @fullcalendar/timegrid, and @fullcalendar/interaction. Those cover month/week/day grids plus drag-select-resize out of the box, so the common case needs no plugin wiring at all.

When you need more, the wrapper is consumer-extensible: import any FullCalendar plugin and pass it through the :options.plugins passthrough. The wrapper merges your plugins with the baked-in defaults rather than replacing them (FullCalendar dedupes by identity, so re-passing a default is harmless). There is no per-plugin wrapper code and no bundle cost for plugins you don't engage — the extra plugin lives in your dependency tree, not the wrapper's.

The worked example is the list view + its noEventsContent slot. Add the free @fullcalendar/list plugin, pass it via :options.plugins, switch to a listWeek/listDay/listMonth view, and the noEventsContent portal slot becomes live (it renders when the list is empty):

bash
npm i @fullcalendar/list
vue
<script setup lang="ts">
import { ref } from 'vue';
import FullCalendar from '@rozie-ui/fullcalendar-vue';
import listPlugin from '@fullcalendar/list';

const view = ref('listWeek');
const events = ref([]); // empty → noEventsContent fires
</script>

<template>
  <FullCalendar
    v-model:view="view"
    :events="events"
    :options="{ plugins: [listPlugin] }"
  >
    <template #noEventsContent="{ arg }">
      <span class="empty">Nothing scheduled — enjoy your day.</span>
    </template>
  </FullCalendar>
</template>

The same opt-in mechanism engages every other FullCalendar plugin uniformly across all six targets — @fullcalendar/rrule (recurring events; recurring fields like rrule already pass through :events untouched), @fullcalendar/google-calendar, @fullcalendar/luxon3 / time-zone plugins, and the bootstrap/theme plugins. The premium scheduler plugins (@fullcalendar/resource-timeline, @fullcalendar/resource-timegrid, …) work the same way — pass the plugin via :options.plugins and additionally supply your schedulerLicenseKey through :options (:options="{ plugins: [resourceTimelinePlugin], schedulerLicenseKey: '…' }").

If you'd rather drive a one-off option imperatively, the :options passthrough also forwards arbitrary non-plugin options, and getApi() returns the raw Calendar instance for full control.

Recipes

Two-way view binding

The view prop is the only two-way value. Both the calendar's own toolbar buttons (dayGridMonth / timeGridWeek / timeGridDay) AND a consumer write update the bound state, with a round-trip guard so a programmatic changeView does not echo back:

vue
<script setup lang="ts">
import { ref } from 'vue';
import FullCalendar from '@rozie-ui/fullcalendar-vue';
const view = ref('dayGridMonth');
</script>

<template>
  <button @click="view = 'timeGridWeek'">Week</button>
  <FullCalendar v-model:view="view" :events="events" />
  <p>Current view: {{ view }}</p>
</template>

Reconciling events at runtime

Changing the :events array reconciles the live calendar without remounting — the wrapper runs FullCalendar's supported removeAllEvents + addEvent loop, normalizing each event (title/color fallbacks) on the way in. Just bind a reactive array and push/replace it:

vue
<FullCalendar :events="events" v-model:view="view" />
<!-- events.value = [...events.value, { id: 'new', title: 'Sync', start: '2026-06-10' }] -->

weekends, editable, selectable, height, locale, firstDay, slotDuration, nowIndicator, and headerToolbar are likewise runtime-updatable — each is wired to FullCalendar's setOption path, so changing the prop reconciles the live calendar with no re-key.

Driving navigation from the handle

The imperative verbs cover the navigation/mutation surface that props alone can't express. Grab the handle and call next()/prev()/today()/prevYear()/nextYear()/gotoDate()/changeView(), or reach the raw engine via getApi():

tsx
const cal = useRef<FullCalendarHandle>(null);
// <FullCalendar ref={cal} ... />
<button onClick={() => cal.current?.next()}>Next</button>
<button onClick={() => cal.current?.today()}>Today</button>

Gotchas

Round-trip-guarded view sync

The two-way view prop is guarded against the cross-framework "infinite update loop" bug class: a programmatic changeView sets a suppressViewSync flag so the engine's viewDidMount/datesSet callback does not write the same value back into $model.view. A user toolbar click flows up normally.

getApi returns the raw instance

getApi() returns the underlying Calendar instance directly (it is not guard-nulled away) so you can call any FullCalendar API the wrapper doesn't surface. It is null before mount and after destroy — callers handle the pre-mount null.

Custom event content is not reactive after mount

Per the v1 portal-slot constraint, the event slot re-renders only when FullCalendar re-invokes eventContent (i.e. when the event data changes). This matches the engine's own behavior — it is not a limitation specific to the wrapper.

Cross-references

Pre-1.0 — APIs may change between minor versions.