Skip to content

<CoarMonthView> — Month View Preview

6×7 grid showing the full calendar month plus leading / trailing days for context. Multi-day events render as continuous bars across the rows they touch; single-day events render as pills inside cells. Cells with overflow scroll internally; per-cell expansion via the kebab menu replaces the older "+ N more" popover.

html
<CoarMonthView :builder="builder" />

Standalone usage

ts
import { ref } from 'vue';
import { Temporal } from '@js-temporal/polyfill';
import {
  CoarMonthView,
  useMonthView,
  type CalendarEvent,
} from '@cocoar/vue-calendar';

const events = ref<CalendarEvent[]>([
  {
    id: 'devconf',
    start: Temporal.PlainDate.from('2026-04-13'),
    end:   Temporal.PlainDate.from('2026-04-16'),
  },
  {
    id: 'standup',
    start: Temporal.ZonedDateTime.from('2026-04-15T09:00:00[UTC]'),
    end:   Temporal.ZonedDateTime.from('2026-04-15T09:30:00[UTC]'),
  },
]);
const date = ref('2026-04-15');

const { builder, api } = useMonthView();
builder
  .events(events)
  .date(date)
  .timezone('UTC')
  .maxEventsPerCell(5)
  .eventRenderer((ctx) => {
    if (ctx.layout?.kind === 'monthPill') return h(MyPill, { event: ctx.event, pill: ctx.layout.layout });
    if (ctx.layout?.kind === 'monthBar')  return h(MyBar,  { event: ctx.event, bar:  ctx.layout.layout });
    return undefined; // fall through to lib default for other variants
  });
html
<CoarMonthView :builder="builder" />
Sun
Mon
Tue
Wed
Thu
Fri
Sat
29
30
31
1
2
3
4
Easter break
5
6
Standup
7
Standup
8
Standup
9
Standup
10
Standup
11
Easter break
12
13
Standup
14
Standup
15
Standup
Design review
Pair: calendar
Lunch with Anna
1:1 with Bernhard
16
Standup
17
Standup
18
DevConf — Vienna
Sven — OOO
19
20
21
22
23
24
25
Team offsite
26
27
28
29
30
1
2
Team offsite
Quarterly review
3
4
5
6
7
8
9
vue
<template>
  <div style="height: 640px; border: 1px solid var(--coar-border-neutral-tertiary); border-radius: var(--coar-radius-xs); overflow: hidden;">
    <CoarMonthView :builder="builder" />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import {
  CoarMonthView,
  useMonthView,
  Temporal,
  type CalendarEvent,
} from '@cocoar/vue-calendar';

const date = ref(Temporal.PlainDate.from('2026-04-15'));

const pd = (iso: string) => Temporal.PlainDate.from(iso);
const zdt = (iso: string, tz = 'Europe/Vienna') =>
  Temporal.ZonedDateTime.from(`${iso}[${tz}]`);

const events = ref<CalendarEvent[]>([
  // Multi-day bars across week boundaries.
  {
    id: 'devconf',
    start: pd('2026-04-13'),
    end: pd('2026-04-16'),
    meta: { title: 'DevConf — Vienna', color: '#7c3aed' },
  },
  {
    id: 'sven-ooo',
    start: pd('2026-04-15'),
    end: pd('2026-04-18'),
    meta: { title: 'Sven — OOO', color: '#9ca3af' },
  },
  {
    id: 'team-offsite',
    start: pd('2026-04-22'),
    end: pd('2026-04-28'),
    meta: { title: 'Team offsite', color: '#0891b2' },
  },
  {
    id: 'easter',
    start: pd('2026-04-03'),
    end: pd('2026-04-07'),
    meta: { title: 'Easter break', color: '#84cc16' },
  },
  // Daily standups Mon–Fri.
  ...['2026-04-06', '2026-04-07', '2026-04-08', '2026-04-09', '2026-04-10'].map(
    (d): CalendarEvent => ({
      id: `standup-${d}`,
      start: zdt(`${d}T09:00:00`),
      end: zdt(`${d}T09:30:00`),
      meta: { title: 'Standup', color: '#10b981' },
    }),
  ),
  ...['2026-04-13', '2026-04-14', '2026-04-15', '2026-04-16', '2026-04-17'].map(
    (d): CalendarEvent => ({
      id: `standup-w2-${d}`,
      start: zdt(`${d}T09:00:00`),
      end: zdt(`${d}T09:30:00`),
      meta: { title: 'Standup', color: '#06b6d4' },
    }),
  ),
  // Wed busy day — extra pills to trigger the per-cell scroll + kebab.
  {
    id: 'wed-design',
    start: zdt('2026-04-15T11:00:00'),
    end: zdt('2026-04-15T12:30:00'),
    meta: { title: 'Design review', color: '#8b5cf6' },
  },
  {
    id: 'wed-pair',
    start: zdt('2026-04-15T11:30:00'),
    end: zdt('2026-04-15T13:00:00'),
    meta: { title: 'Pair: calendar', color: '#f59e0b' },
  },
  {
    id: 'wed-lunch',
    start: zdt('2026-04-15T12:00:00'),
    end: zdt('2026-04-15T13:00:00'),
    meta: { title: 'Lunch with Anna', color: '#ef4444' },
  },
  {
    id: 'wed-1on1',
    start: zdt('2026-04-15T15:00:00'),
    end: zdt('2026-04-15T15:45:00'),
    meta: { title: '1:1 with Bernhard', color: '#3b82f6' },
  },
  // Quarterly review crossing into May.
  {
    id: 'quarter-review',
    start: pd('2026-04-29'),
    end: pd('2026-05-02'),
    meta: { title: 'Quarterly review', color: '#2563eb' },
  },
]);

const { builder } = useMonthView();
builder
  .events(events)
  .date(date)
  .timezone('Europe/Vienna')
  .onEventDrop(({ event, next }) => {
    const idx = events.value.findIndex((e) => e.id === event.id);
    if (idx < 0) return;
    events.value = [
      ...events.value.slice(0, idx),
      { ...event, start: next.start, ...(next.end ? { end: next.end } : {}) },
      ...events.value.slice(idx + 1),
    ];
  });
</script>

Custom pills and bars

The month view exposes two slots — #pill for single-day events and #multiDayBar for multi-day events. Both receive { event, pill } / { event, bar } so you can branch on the layout payload (lane, col-span, clipping flags) when needed.

Custom #pill + #multiDayBar slots — pills get a leading time chip; bars get a trailing day-count chip.

Sun
Mon
Tue
Wed
Thu
Fri
Sat
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
9:00 AMStandup
14
15
11:00 AMDesign review
12:00 PMLunch
16
17
3:00 PMClient demo
18
DevConf — Vienna3d
Sven — OOO3d
19
20
21
22
23
24
25
Team offsite4d
26
27
28
29
30
1
2
Team offsite2d
3
4
5
6
7
8
9
vue
<template>
  <div>
    <p class="hint">
      Custom <code>#pill</code> + <code>#multiDayBar</code> slots —
      pills get a leading time chip; bars get a trailing day-count chip.
    </p>
    <div style="height: 600px; border: 1px solid var(--coar-border-neutral-tertiary); border-radius: var(--coar-radius-xs); overflow: hidden;">
      <CoarMonthView :builder="builder">
        <template #pill="{ event }">
          <span class="pill">
            <span class="pill__time">{{ formatTime(event) }}</span>
            <span class="pill__title">{{ title(event) }}</span>
          </span>
        </template>
        <template #multiDayBar="{ event, bar }">
          <span class="bar">
            <span class="bar__title">{{ title(event) }}</span>
            <span class="bar__chip">{{ bar.endCol - bar.startCol + 1 }}d</span>
          </span>
        </template>
      </CoarMonthView>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import {
  CoarMonthView,
  useMonthView,
  Temporal,
  isTimedEvent,
  type CalendarEvent,
} from '@cocoar/vue-calendar';

const date = ref(Temporal.PlainDate.from('2026-04-15'));

const pd = (iso: string) => Temporal.PlainDate.from(iso);
const zdt = (iso: string, tz = 'Europe/Vienna') =>
  Temporal.ZonedDateTime.from(`${iso}[${tz}]`);

const events = ref<CalendarEvent[]>([
  {
    id: 'devconf',
    start: pd('2026-04-13'),
    end: pd('2026-04-16'),
    meta: { title: 'DevConf — Vienna', color: '#7c3aed' },
  },
  {
    id: 'sven-ooo',
    start: pd('2026-04-15'),
    end: pd('2026-04-18'),
    meta: { title: 'Sven — OOO', color: '#9ca3af' },
  },
  {
    id: 'team-offsite',
    start: pd('2026-04-22'),
    end: pd('2026-04-28'),
    meta: { title: 'Team offsite', color: '#0891b2' },
  },
  {
    id: 'standup-mon',
    start: zdt('2026-04-13T09:00:00'),
    end: zdt('2026-04-13T09:30:00'),
    meta: { title: 'Standup', color: '#10b981' },
  },
  {
    id: 'review',
    start: zdt('2026-04-15T11:00:00'),
    end: zdt('2026-04-15T12:00:00'),
    meta: { title: 'Design review', color: '#8b5cf6' },
  },
  {
    id: 'lunch',
    start: zdt('2026-04-15T12:00:00'),
    end: zdt('2026-04-15T13:00:00'),
    meta: { title: 'Lunch', color: '#ef4444' },
  },
  {
    id: 'demo',
    start: zdt('2026-04-17T15:00:00'),
    end: zdt('2026-04-17T16:30:00'),
    meta: { title: 'Client demo', color: '#dc2626' },
  },
]);

const { builder } = useMonthView();
builder
  .events(events)
  .date(date)
  .timezone('Europe/Vienna')
  .onEventDrop(({ event, next }) => {
    const idx = events.value.findIndex((e) => e.id === event.id);
    if (idx < 0) return;
    events.value = [
      ...events.value.slice(0, idx),
      { ...event, start: next.start, ...(next.end ? { end: next.end } : {}) },
      ...events.value.slice(idx + 1),
    ];
  });

function title(event: CalendarEvent): string {
  return (event.meta as { title?: string } | undefined)?.title ?? event.id;
}

const timeFmt = new Intl.DateTimeFormat('en-US', {
  hour: 'numeric',
  minute: '2-digit',
  timeZone: 'Europe/Vienna',
});
function formatTime(event: CalendarEvent): string {
  // All-day events (PlainDate start) have no clock time to show.
  if (!isTimedEvent(event)) return '';
  // ZonedDateTime → epoch ms via toInstant() so we can hand it to Intl.
  return timeFmt
    .format(new Date(event.start.toInstant().epochMilliseconds))
    .replace(' ', ' ');
}
</script>

<style scoped>
.hint {
  margin: 0 0 12px;
  font-size: 13px;
  color: var(--coar-text-subtle, #6b7280);
}
.hint code {
  font-family: var(--coar-font-family-mono, monospace);
  font-size: 12px;
  background: var(--coar-background-neutral-tertiary, #f3f4f6);
  padding: 1px 5px;
  border-radius: 3px;
}
.pill {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  width: 100%;
  overflow: hidden;
}
.pill__time {
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  color: var(--coar-text-base, #1a1c1f);
  white-space: nowrap;
}
.pill__title {
  flex: 1 1 auto;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--coar-text-base, #1a1c1f);
}
.bar {
  display: inline-flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  gap: 6px;
}
.bar__title {
  flex: 1 1 auto;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-weight: 600;
  color: var(--coar-text-base, #1a1c1f);
}
.bar__chip {
  flex: 0 0 auto;
  font-size: 10px;
  font-weight: 700;
  color: var(--coar-text-base, #1a1c1f);
  background: rgba(0, 0, 0, 0.12);
  padding: 0 6px;
  border-radius: 999px;
}
</style>

Inside <CoarCalendar>

<CoarCalendar> and <CoarMonthView> consume the SAME CalendarBuilder instance — there's no sub-builder forking. Set month-specific config (e.g. maxEventsPerCell) directly on the composer's builder:

ts
const { builder } = useCalendar();
builder.maxEventsPerCell(5);

When the active view is month, the same builder feeds the embedded <CoarMonthView>. When it's week or day, the same builder feeds <CoarTimeGrid>. View-specific settings simply have no effect outside their view.

useMonthView<TMeta>()

ts
function useMonthView<TMeta>(): {
  builder: CalendarBuilder<TMeta>;
  api: CalendarApi<TMeta>;
};

Returns a fresh standalone builder + its imperative api. The builder type is the same CalendarBuilder used by <CoarCalendar>useMonthView() is just a thin shorthand that pre-sets view: 'month'.

Builder setters

Full reference: see the composer's API reference. Highlights that matter for the month view:

SetterArgumentDefaultNotes
firstDayOfWeek(d)0..6 | undefinedlocale-aware0 = Sunday, 1 = Monday, …
maxEventsPerCell(n)MaybeRefOrGetter<number>3Pill cap hint. The library never truncates — pills always reach the DOM — but the collapsed-cell height reserves space for ~n pills before the cell starts to scroll.
eventRenderer(r)EventRenderer<TMeta>Universal renderer. Branch on ctx.layout?.kind === 'monthPill' | 'monthBar' for variant-specific rendering — see the example above.
dayHeaderRenderer(r)DayHeaderRendererWeekday-strip header (Mon / Tue / ...).

Per-cell expansion

Each cell has a kebab trigger (top-right of the day-number row, hover-reveal on desktop, always visible on touch). Clicking it opens a context menu with Show more events / Show fewer events, which expands or collapses the entire row (single-row mode — opening one collapses any other previously-expanded row). Right-click / long-press on the cell body opens the same menu at the pointer.

The collapsed cell uses a height that fits ~maxEventsPerCell pills + the multi-day-bar lane area; expanded rows grow to a fixed maximum so all overflowing pills are reachable via scroll.

Drag and drop

  • Pills — drag a single-day pill to another cell to shift its date. The library reflows the source cell as if the event were already gone, and renders a dashed-outline ghost pill at the target.
  • Bars — drag a multi-day bar's body to shift the whole bar; drag the left or right edge handle to resize one side. Resize handles only appear on non-clipped edges (no point resizing from off-month).
  • Keyboard — Tab to focus an event, Arrow keys to move ±1 day (Up / Down jump a full week-row). Shift + Arrow on an all-day event grows / shrinks the end side.
  • canDrop — the universal drop validator on the builder; returning false paints a red dashed "invalid" ghost and silently swallows the drop on release.

Imperative API

ts
interface CalendarApi<TMeta> {
  goTo(iso: string): void;
  goToToday(): void;
  next(): void;                  // ±1 month
  prev(): void;
  getVisibleRange(): ViewWindow | null;
  getVisibleEvents(): CalendarEvent<TMeta>[];
  refresh(): void;
  refreshRange(start: string, end: string): void;
  readonly loading: Readonly<Ref<boolean>>;
  readonly visibleRange: Readonly<Ref<ViewWindow | null>>;
  readonly gridReady: Readonly<Ref<boolean>>;
}

scrollToTime / scrollToDate are not on the month API — neither has a vertical-scroll surface in this view.

<CoarMonthView> props + slots

PropTypeDescription
builderCalendarBuilderRequired. From useMonthView() (or share the one from useCalendar()).
SlotScopePurpose
pill{ event, pill }Single-day pill renderer. pill is the MonthCellPill (event + visual order in the cell).
multiDayBar{ event, bar }Multi-day bar renderer. bar is the MonthMultiDayBar (lane / startCol / endCol / clipping flags).

Released under the Apache-2.0 License.