Keyboard shortcut: Ctrl + K
Get started

Calendar

An agenda to read and rearrange: day, week, month and year views, with events that can be dragged and stretched. Opening one for editing stays with you.

Usage

vue

5 – 11 January 2026

Mon5
Tue6
Wed7
Thu8
Fri9
Sat10
Sun11
Press Enter to open this event. Press Space to take hold of it, then the arrow keys to move it and Shift with the arrow keys to change when it ends.
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { VCalendar, type CalendarEvent } from 'vectis-ui'

/*
 * This page is prerendered, so the clock cannot be read while the markup is being built:
 * the build date would be written into the HTML and the browser would disagree with it.
 * The schedule below therefore starts on a fixed week and moves onto the current one once
 * the page is in the browser, which is what the theme does here for the same reason.
 */
const REFERENCE_MONDAY = '2026-01-05'

const anchor = ref(REFERENCE_MONDAY)
const events = ref<CalendarEvent[]>(scheduleFor(REFERENCE_MONDAY))

onMounted(() => {
  anchor.value = mondayOf(new Date())
  events.value = scheduleFor(anchor.value)
})

/** A date as the local ISO day the calendar reads, never through UTC. */
function isoOf(date: Date): string {
  const two = (part: number) => String(part).padStart(2, '0')
  return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`
}

/** The Monday of the week a date falls in. */
function mondayOf(date: Date): string {
  const monday = new Date(date)
  monday.setDate(date.getDate() - ((date.getDay() + 6) % 7))
  return isoOf(monday)
}

/** The same three appointments, on whichever week starts on the Monday given. */
function scheduleFor(monday: string): CalendarEvent[] {
  const day = (offset: number) => {
    // The time keeps the parse local: a bare `YYYY-MM-DD` is read as UTC.
    const date = new Date(`${monday}T00:00:00`)
    date.setDate(date.getDate() + offset)
    return isoOf(date)
  }

  return [
    {
      id: 'standup',
      title: 'Standup',
      start: day(0),
      end: day(0),
      startTime: '09:00',
      endTime: '09:15',
    },
    {
      id: 'review',
      title: 'Design review',
      start: day(1),
      end: day(1),
      startTime: '14:00',
      endTime: '15:30',
      description: 'With Anna and Ravi',
    },
    {
      id: 'lunch',
      title: 'Team lunch',
      start: day(3),
      end: day(3),
      startTime: '12:30',
      endTime: '13:30',
    },
  ]
}
</script>

<template>
  <VCalendar v-model:date="anchor" v-model:events="events" label="Team schedule" class="calendar" />
</template>

<style scoped>
/* A calendar has no height of its own, and collapses without one. */
.calendar {
  block-size: 34rem;
}
</style>

Examples

Month

The month view draws each day as a square holding its events as chips. monthEventLimit caps how many are shown before the rest are counted.

vue

June 2026

Press Enter to open this event. Press Space to take hold of it, then the arrow keys to move it and Shift with the arrow keys to change when it ends.
<script setup lang="ts">
import { ref } from 'vue'
import { VCalendar, type CalendarEvent } from 'vectis-ui'

const events = ref<CalendarEvent[]>([
  {
    id: 'standup',
    title: 'Standup',
    start: '2026-06-08',
    end: '2026-06-08',
    startTime: '09:00',
    endTime: '09:15',
  },
  {
    id: 'quarterly',
    title: 'Quarterly plan',
    start: '2026-06-10',
    end: '2026-06-10',
    startTime: '08:00',
    endTime: '08:30',
  },
  {
    id: 'lunch',
    title: 'Team lunch',
    start: '2026-06-10',
    end: '2026-06-10',
    startTime: '12:30',
    endTime: '13:30',
  },
  {
    id: 'one-to-one',
    title: 'One to one',
    start: '2026-06-10',
    end: '2026-06-10',
    startTime: '15:00',
    endTime: '15:45',
  },
  {
    id: 'interview',
    title: 'Interview',
    start: '2026-06-10',
    end: '2026-06-10',
    startTime: '17:00',
    endTime: '18:00',
  },
  {
    id: 'workshop',
    title: 'Workshop',
    start: '2026-06-11',
    end: '2026-06-11',
    startTime: '09:30',
    endTime: '12:00',
  },
  {
    id: 'retro',
    title: 'Retrospective',
    start: '2026-06-19',
    end: '2026-06-19',
    startTime: '16:00',
    endTime: '17:00',
  },
  {
    id: 'release',
    title: 'Release window',
    start: '2026-06-16',
    end: '2026-06-18',
    startTime: '00:00',
    endTime: '23:59',
  },
])
</script>

<template>
  <VCalendar
    v-model:events="events"
    view="month"
    :views="['month', 'week', 'day']"
    date="2026-06-10"
    label="Month"
    class="calendar"
  />
</template>

<style scoped>
.calendar {
  block-size: 38rem;
}
</style>

Year

The year view shows twelve small months for orientation. The busy days are ringed and choosing a month opens it.

vue

2026

Press Enter to open this event. Press Space to take hold of it, then the arrow keys to move it and Shift with the arrow keys to change when it ends.
<script setup lang="ts">
import { ref } from 'vue'
import { VCalendar, type CalendarEvent } from 'vectis-ui'

/* A year is read for its shape rather than its detail, so the events below are spread over
   it: a few single days, and the two stretches that make a run of days busy at once. */
const events = ref<CalendarEvent[]>([
  {
    id: 'kickoff',
    title: 'Kickoff',
    start: '2026-01-13',
    end: '2026-01-13',
    startTime: '10:00',
    endTime: '11:00',
  },
  {
    id: 'audit',
    title: 'Accessibility audit',
    start: '2026-03-02',
    end: '2026-03-06',
    startTime: '00:00',
    endTime: '23:59',
  },
  {
    id: 'review',
    title: 'Design review',
    start: '2026-04-21',
    end: '2026-04-21',
    startTime: '14:00',
    endTime: '15:30',
  },
  {
    id: 'quarterly',
    title: 'Quarterly plan',
    start: '2026-06-10',
    end: '2026-06-10',
    startTime: '08:00',
    endTime: '09:00',
  },
  {
    id: 'summer',
    title: 'Summer break',
    start: '2026-08-03',
    end: '2026-08-14',
    startTime: '00:00',
    endTime: '23:59',
  },
  {
    id: 'conference',
    title: 'Conference',
    start: '2026-09-22',
    end: '2026-09-24',
    startTime: '00:00',
    endTime: '23:59',
  },
  {
    id: 'retro',
    title: 'Retrospective',
    start: '2026-11-05',
    end: '2026-11-05',
    startTime: '16:00',
    endTime: '17:00',
  },
])
</script>

<template>
  <VCalendar
    v-model:events="events"
    view="year"
    :views="['year', 'month', 'week']"
    date="2026-06-10"
    label="Year"
    class="calendar"
  />
</template>

<style scoped>
.calendar {
  block-size: 38rem;
}
</style>

Custom view

The custom view leaves the length to you: customDays says how many days it shows, and how far Previous and Next step.

vue

10 – 14 June 2026

Wed10
Thu11
Fri12
Sat13
Sun14
Press Enter to open this event. Press Space to take hold of it, then the arrow keys to move it and Shift with the arrow keys to change when it ends.
<script setup lang="ts">
import { ref } from 'vue'
import { VCalendar, type CalendarEvent } from 'vectis-ui'

/* The span below runs from Wednesday to Sunday, which is what shows what the custom view
   is for: a week would have stopped at the Sunday boundary and started again on Monday. */
const events = ref<CalendarEvent[]>([
  {
    id: 'standup',
    title: 'Standup',
    start: '2026-06-10',
    end: '2026-06-10',
    startTime: '09:00',
    endTime: '09:15',
  },
  {
    id: 'review',
    title: 'Design review',
    start: '2026-06-10',
    end: '2026-06-10',
    startTime: '14:00',
    endTime: '15:30',
  },
  {
    id: 'workshop',
    title: 'Workshop',
    start: '2026-06-11',
    end: '2026-06-11',
    startTime: '09:30',
    endTime: '12:00',
  },
  {
    id: 'retro',
    title: 'Retrospective',
    start: '2026-06-12',
    end: '2026-06-12',
    startTime: '16:00',
    endTime: '17:00',
  },
  {
    id: 'conference',
    title: 'Conference',
    start: '2026-06-13',
    end: '2026-06-13',
    startTime: '10:00',
    endTime: '16:00',
  },
  {
    id: 'handover',
    title: 'Handover',
    start: '2026-06-14',
    end: '2026-06-14',
    startTime: '11:00',
    endTime: '12:00',
  },
])
</script>

<template>
  <VCalendar
    v-model:events="events"
    view="custom"
    :custom-days="5"
    :views="['day', 'custom', 'week']"
    :day-start="8"
    :day-end="18"
    date="2026-06-10"
    label="Custom span"
    class="calendar"
  />
</template>

<style scoped>
.calendar {
  block-size: 34rem;
}
</style>

Which days are shown

weekdays decides which days appear at all, as numbers from 0 for Sunday, its first entry being the day a week starts on. Without it, firstDayOfWeek sets that first day over the locale. dayStart and dayEnd crop the hours shown in the time grids.

vue

8 – 12 June 2026

Mon8
Tue9
Wed10
Thu11
Fri12
Press Enter to open this event. Press Space to take hold of it, then the arrow keys to move it and Shift with the arrow keys to change when it ends.
<script setup lang="ts">
import { ref } from 'vue'
import { VCalendar, type CalendarEvent } from 'vectis-ui'

const events = ref<CalendarEvent[]>([
  {
    id: 'standup',
    title: 'Standup',
    start: '2026-06-08',
    end: '2026-06-08',
    startTime: '09:00',
    endTime: '09:15',
  },
  {
    id: 'review',
    title: 'Design review',
    start: '2026-06-09',
    end: '2026-06-09',
    startTime: '10:00',
    endTime: '11:30',
  },
  {
    id: 'lunch',
    title: 'Team lunch',
    start: '2026-06-10',
    end: '2026-06-10',
    startTime: '12:30',
    endTime: '13:30',
  },
  {
    id: 'workshop',
    title: 'Workshop',
    start: '2026-06-11',
    end: '2026-06-11',
    startTime: '09:30',
    endTime: '12:00',
  },
  {
    id: 'retro',
    title: 'Retrospective',
    start: '2026-06-12',
    end: '2026-06-12',
    startTime: '16:00',
    endTime: '17:00',
  },
])
</script>

<template>
  <!-- Monday to Friday, eight in the morning to seven in the evening. Every view is on the
       menu, since the weekend is missing from all five and not only from the columns. -->
  <VCalendar
    v-model:events="events"
    :weekdays="[1, 2, 3, 4, 5]"
    :day-start="8"
    :day-end="19"
    :custom-days="3"
    :views="['day', 'custom', 'week', 'month', 'year']"
    date="2026-06-10"
    label="Working week"
    class="calendar"
  />
</template>

<style scoped>
.calendar {
  block-size: 34rem;
}
</style>

All-day events

allDay puts an event in the band above the grid, where an event lasting 24 hours or more already goes. One running past midnight for less than that stays in the grid, as a card in each of its two days.

vue

8 – 14 June 2026

Mon8
Tue9
Wed10
Thu11
Fri12
Sat13
Sun14
All day
Press Enter to open this event. Press Space to take hold of it, then the arrow keys to move it and Shift with the arrow keys to change when it ends.
<script setup lang="ts">
import { ref } from 'vue'
import { VCalendar, type CalendarEvent } from 'vectis-ui'

const events = ref<CalendarEvent[]>([
  // Marked `allDay`, so it goes to the band above the grid although it sits on one day.
  {
    id: 'holiday',
    title: 'Bank holiday',
    start: '2026-06-08',
    end: '2026-06-08',
    startTime: '00:00',
    endTime: '23:59',
    allDay: true,
  },
  // These two run from one day to the next, which puts them in the band on their own.
  {
    id: 'workshop',
    title: 'Workshop',
    start: '2026-06-09',
    end: '2026-06-11',
    startTime: '00:00',
    endTime: '23:59',
  },
  {
    id: 'release',
    title: 'Release window',
    start: '2026-06-10',
    end: '2026-06-12',
    startTime: '00:00',
    endTime: '23:59',
  },
  {
    id: 'review',
    title: 'Design review',
    start: '2026-06-10',
    end: '2026-06-10',
    startTime: '10:00',
    endTime: '11:30',
  },
  {
    id: 'retro',
    title: 'Retrospective',
    start: '2026-06-11',
    end: '2026-06-11',
    startTime: '16:00',
    endTime: '17:00',
  },
])
</script>

<template>
  <VCalendar
    v-model:events="events"
    :day-start="8"
    :day-end="18"
    date="2026-06-10"
    label="All-day events"
    class="calendar"
  />
</template>

<style scoped>
.calendar {
  block-size: 34rem;
}
</style>

Overlapping events

Events happening at once share the width of their day, grouped into clusters so a crowded morning does not narrow a lone afternoon meeting.

vue

Wednesday, 10 June 2026

Wed10
Press Enter to open this event. Press Space to take hold of it, then the arrow keys to move it and Shift with the arrow keys to change when it ends.
<script setup lang="ts">
import { ref } from 'vue'
import { VCalendar, type CalendarEvent } from 'vectis-ui'

const DAY = '2026-06-10'

/* Four events crowd the morning and one stands alone after it, which is what shows the
   clustering: the lunch keeps the full width the morning has to share. */
const events = ref<CalendarEvent[]>([
  {
    id: 'standup',
    title: 'Standup',
    start: DAY,
    end: DAY,
    startTime: '09:00',
    endTime: '10:00',
  },
  {
    id: 'review',
    title: 'Design review',
    start: DAY,
    end: DAY,
    startTime: '09:30',
    endTime: '11:00',
  },
  {
    id: 'interview',
    title: 'Interview',
    start: DAY,
    end: DAY,
    startTime: '10:00',
    endTime: '10:30',
  },
  {
    id: 'workshop',
    title: 'Workshop',
    start: DAY,
    end: DAY,
    startTime: '09:00',
    endTime: '09:30',
  },
  {
    id: 'lunch',
    title: 'Team lunch',
    start: DAY,
    end: DAY,
    startTime: '12:30',
    endTime: '13:30',
  },
])
</script>

<template>
  <VCalendar
    v-model:events="events"
    view="day"
    :views="['day', 'week']"
    :day-start="8"
    :day-end="18"
    :date="DAY"
    label="Overlapping events"
    class="calendar"
  />
</template>

<style scoped>
.calendar {
  block-size: 34rem;
}
</style>

Colours

An event with no color takes a hue derived from its id. One naming a colour of its own uses it for its leading edge and a wash of its face.

vue

Wednesday, 10 June 2026

Wed10
Press Enter to open this event. Press Space to take hold of it, then the arrow keys to move it and Shift with the arrow keys to change when it ends.
<script setup lang="ts">
import { ref } from 'vue'
import { VCalendar, type CalendarEvent } from 'vectis-ui'

const DAY = '2026-06-10'

/* The first six name no colour, so each takes the hue its id derives. The last two name
   one of their own, in two of the notations any CSS colour may be written in. */
const events = ref<CalendarEvent[]>([
  { id: 'hue-1', title: 'Standup', start: DAY, end: DAY, startTime: '08:00', endTime: '09:00' },
  {
    id: 'hue-2',
    title: 'Design review',
    start: DAY,
    end: DAY,
    startTime: '09:00',
    endTime: '10:00',
  },
  { id: 'hue-3', title: 'Workshop', start: DAY, end: DAY, startTime: '10:00', endTime: '11:00' },
  {
    id: 'hue-4',
    title: 'Retrospective',
    start: DAY,
    end: DAY,
    startTime: '11:00',
    endTime: '12:00',
  },
  { id: 'hue-5', title: 'Team lunch', start: DAY, end: DAY, startTime: '12:00', endTime: '13:00' },
  { id: 'hue-6', title: 'Interview', start: DAY, end: DAY, startTime: '13:00', endTime: '14:00' },
  {
    id: 'own-1',
    title: 'One to one',
    start: DAY,
    end: DAY,
    startTime: '14:00',
    endTime: '15:00',
    color: '#7c3aed',
  },
  {
    id: 'own-2',
    title: 'Release window',
    start: DAY,
    end: DAY,
    startTime: '16:00',
    endTime: '18:00',
    color: 'oklch(0.65 0.15 200)',
  },
])
</script>

<template>
  <VCalendar
    v-model:events="events"
    view="day"
    :views="['day', 'week']"
    :day-start="8"
    :day-end="20"
    :date="DAY"
    label="Event colours"
    class="calendar"
  />
</template>

<style scoped>
.calendar {
  block-size: 34rem;
}
</style>

Custom event content

The #event slot replaces what a card shows and receives the event, its formatted timeText, the layout it is drawn in, whether it continues before or after the day, and whether it is being dragged or held by the keyboard.

vue

Wednesday, 10 June 2026

Wed10
Press Enter to open this event. Press Space to take hold of it, then the arrow keys to move it and Shift with the arrow keys to change when it ends.
<script setup lang="ts">
import { ref } from 'vue'
import { VCalendar, VIcon, type CalendarEvent } from 'vectis-ui'
import { schedule as scheduleIcon } from 'vectis-ui/icons'

/* An interface of your own extending the contract: the extra fields reach the slot typed,
   rather than as something to cast there. */
interface Booking extends CalendarEvent {
  room: string
  attendees: number
}

const DAY = '2026-06-10'

const events = ref<Booking[]>([
  {
    id: 'review',
    title: 'Design review',
    start: DAY,
    end: DAY,
    startTime: '10:00',
    endTime: '11:30',
    room: 'B2',
    attendees: 6,
  },
  {
    id: 'workshop',
    title: 'Workshop',
    start: DAY,
    end: DAY,
    startTime: '14:00',
    endTime: '16:00',
    room: 'A1',
    attendees: 12,
  },
])
</script>

<template>
  <VCalendar
    v-model:events="events"
    view="day"
    :views="['day', 'week']"
    :day-start="8"
    :day-end="18"
    :date="DAY"
    label="Custom event content"
    class="calendar"
  >
    <template #event="{ event, timeText }">
      <span class="title">{{ event.title }}</span>
      <span class="line">
        <VIcon :name="scheduleIcon" :size="14" aria-hidden="true" />
        {{ timeText }}
      </span>
      <span class="line">Room {{ event.room }}, {{ event.attendees }} people</span>
    </template>
  </VCalendar>
</template>

<style scoped>
.calendar {
  block-size: 34rem;
}

.title {
  font-weight: var(--vectis-font-weight-semibold);
}

.line {
  display: flex;
  align-items: center;
  gap: var(--vectis-space-1);
  font-size: var(--vectis-text-caption-size);
}
</style>

Creating and editing events

The calendar reports what the reader did and leaves the list to you, so creating and editing come down to three events and one dialog. Drawing out a slot with creatable fires event-create with both ends, clicking an empty cell fires cell-activate with a start only, and a button in the #actions slot opens a blank form. Clicking a card fires event-activate, which opens the same form filled in. Saving writes a new array to v-model:events, the same model dragging and resizing already write to.

vue

8 – 14 June 2026

Mon8
Tue9
Wed10
Thu11
Fri12
Sat13
Sun14
Press Enter to open this event. Press Space to take hold of it, then the arrow keys to move it and Shift with the arrow keys to change when it ends.
<script setup lang="ts">
import { computed, reactive, ref, useId } from 'vue'
import {
  VButton,
  VCalendar,
  VDateInput,
  VDialog,
  VInput,
  VSwitch,
  VTextarea,
  VTimeInput,
  type CalendarCell,
  type CalendarEvent,
  type CalendarEventId,
  type CalendarEventTimes,
  type CalendarView,
} from 'vectis-ui'

const view = ref<CalendarView>('week')
const anchor = ref('2026-06-10')

const events = ref<CalendarEvent[]>([
  {
    id: 'standup',
    title: 'Standup',
    start: '2026-06-08',
    end: '2026-06-08',
    startTime: '09:00',
    endTime: '09:15',
  },
  {
    id: 'review',
    title: 'Design review',
    start: '2026-06-10',
    end: '2026-06-10',
    startTime: '14:00',
    endTime: '15:30',
    description: 'With Anna and Ravi',
  },
])

let nextId = 1

/** What the form edits: a copy, so Cancel leaves the calendar as it was. */
interface Draft {
  title: string
  allDay: boolean
  start: string | null
  startTime: string | null
  end: string | null
  endTime: string | null
  description: string
}

const open = ref(false)
const formId = useId()
/** The event being edited, or null while a new one is being written. */
const editingId = ref<CalendarEventId | null>(null)
const draft = reactive<Draft>(emptyDraft())

function emptyDraft(): Draft {
  return {
    title: '',
    allDay: false,
    start: null,
    startTime: null,
    end: null,
    endTime: null,
    description: '',
  }
}

/** A local date and time moved by some minutes, crossing midnight when it has to. */
function addMinutes(date: string, time: string, minutes: number): { date: string; time: string } {
  const moved = new Date(`${date}T${time}:00`)
  moved.setMinutes(moved.getMinutes() + minutes)
  const two = (part: number) => String(part).padStart(2, '0')
  return {
    date: `${moved.getFullYear()}-${two(moved.getMonth() + 1)}-${two(moved.getDate())}`,
    time: `${two(moved.getHours())}:${two(moved.getMinutes())}`,
  }
}

function openNew(times: CalendarEventTimes) {
  Object.assign(draft, emptyDraft(), times)
  editingId.value = null
  open.value = true
}

/* A drawn-out slot already has both ends. */
function onEventCreate(times: CalendarEventTimes) {
  openNew(times)
}

/* A clicked cell only has a start, so the event is given an hour. A day of the month view
   reports the hour the grid starts at, midnight here, which is a poor default for a meeting. */
function onCellActivate(cell: CalendarCell) {
  const startTime = view.value === 'month' ? '09:00' : cell.time
  const end = addMinutes(cell.date, startTime, 60)
  openNew({ start: cell.date, startTime, end: end.date, endTime: end.time })
}

/* The toolbar button has neither, and starts on the day the calendar is showing. */
function onNewClick() {
  openNew({ start: anchor.value, startTime: '09:00', end: anchor.value, endTime: '10:00' })
}

function onEventActivate(event: CalendarEvent) {
  Object.assign(draft, {
    title: event.title,
    allDay: event.allDay ?? false,
    start: event.start,
    startTime: event.startTime,
    end: event.end,
    endTime: event.endTime,
    description: event.description ?? '',
  })
  editingId.value = event.id
  open.value = true
}

/* ISO dates and 24-hour times compare correctly as strings, so no Date is needed here. */
const endsBeforeStart = computed(() => {
  if (!draft.start || !draft.end) return false
  if (draft.allDay) return draft.end < draft.start
  if (!draft.startTime || !draft.endTime) return false
  return `${draft.end} ${draft.endTime}` <= `${draft.start} ${draft.startTime}`
})

function save() {
  // `required` has the browser refuse empty fields before this runs. What it cannot know is
  // whether the end comes after the start, or that a title made of spaces is no title.
  const title = draft.title.trim()
  // The contract always wants times: an all-day event spans the whole of its days.
  const startTime = draft.allDay ? '00:00' : draft.startTime
  const endTime = draft.allDay ? '23:59' : draft.endTime
  if (!title || !draft.start || !draft.end || !startTime || !endTime) return
  if (endsBeforeStart.value) return

  const saved: CalendarEvent = {
    id: editingId.value ?? `event-${nextId++}`,
    title,
    start: draft.start,
    end: draft.end,
    startTime,
    endTime,
    allDay: draft.allDay || undefined,
    description: draft.description.trim() || undefined,
  }

  // A new array rather than a push: the calendar is handed a list and never mutates it.
  events.value =
    editingId.value === null
      ? [...events.value, saved]
      : events.value.map((event) => (event.id === saved.id ? saved : event))
  open.value = false
}

function remove() {
  events.value = events.value.filter((event) => event.id !== editingId.value)
  open.value = false
}
</script>

<template>
  <VCalendar
    v-model:view="view"
    v-model:date="anchor"
    v-model:events="events"
    creatable
    :views="['day', 'week', 'month']"
    :day-start="7"
    :day-end="20"
    label="Editable schedule"
    class="calendar"
    @event-create="onEventCreate"
    @cell-activate="onCellActivate"
    @event-activate="onEventActivate"
  >
    <template #actions>
      <VButton size="sm" @click="onNewClick">New event</VButton>
    </template>
  </VCalendar>

  <!-- Closing the dialog hands the focus back to whatever opened it: the card, the cell or
       the button. The form lives in the body and its buttons in the footer, so the submit
       button reaches it through the `form` attribute. -->
  <VDialog
    v-model:open="open"
    :title="editingId === null ? 'New event' : 'Edit event'"
    width="32rem"
  >
    <form :id="formId" class="form" @submit.prevent="save">
      <VInput v-model="draft.title" label="Title" required />

      <VSwitch v-model="draft.allDay" label="All day" class="all-day" />

      <div class="row">
        <VDateInput v-model="draft.start" label="Start date" show-picker required />
        <VTimeInput
          v-if="!draft.allDay"
          v-model="draft.startTime"
          label="Start time"
          :minute-step="15"
          required
        />
      </div>

      <div class="row">
        <VDateInput
          v-model="draft.end"
          label="End date"
          show-picker
          required
          :invalid="endsBeforeStart"
          :hint="endsBeforeStart ? 'The event has to end after it starts.' : undefined"
        />
        <VTimeInput
          v-if="!draft.allDay"
          v-model="draft.endTime"
          label="End time"
          :minute-step="15"
          required
          :invalid="endsBeforeStart"
        />
      </div>

      <VTextarea v-model="draft.description" label="Description" :rows="3" />
    </form>

    <template #footer>
      <VButton
        v-if="editingId !== null"
        class="delete"
        variant="ghost"
        tone="danger"
        @click="remove"
      >
        Delete
      </VButton>
      <VButton variant="ghost" tone="neutral" @click="open = false">Cancel</VButton>
      <VButton type="submit" :form="formId">Save</VButton>
    </template>
  </VDialog>
</template>

<style scoped>
.calendar {
  block-size: 34rem;
}

.form {
  display: grid;
  gap: var(--vectis-space-4);
}

/* A grid item stretches by default, which would push the label away from its switch. */
.all-day {
  justify-self: start;
}

.row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
  gap: var(--vectis-space-3);
  align-items: start;
}

/* Pushed to the other end of the footer, away from the button that saves. */
.delete {
  margin-inline-end: auto;
}
</style>

API

Props

PropTypeDefault
viewsCalendarView[]['day', '4days', 'week']
Which views the menu offers, in the order it lists them. Narrowing it is how a calendar that only ever shows weeks stops offering anything else.
customDaysnumber4
How many days the custom view shows, and how far Previous and Next step in it.
weekdaysnumber[]none
Which weekdays are on show, as numbers from 0 for Sunday. The order matters as well: the first entry is the day a week starts on, and it wins over firstDayOfWeek. Left out, the seven days starting on firstDayOfWeek.
firstDayOfWeeknumbernone
The day a week starts on, from 0 for Sunday, when weekdays is not given. Left out, the locale decides.
localestringnone
The language the days, months and times are written in. It falls back to the global one.
formatCalendarFormat'12h' | '24h'none
Whether times are shown on a 12- or a 24-hour clock. It follows the locale.
dayStartnumber0
The hour the grid starts at, from 0.
dayEndnumber24
The hour it ends at, up to 24.
slotDurationnumber15
The step everything snaps to, in minutes: how far a nudge moves an event, and the unit a slot is drawn out in.
scrollTimestring'08:00'
Where the grid is scrolled to when it first appears, so the working day is in view.
hideCurrentTimebooleanfalse
Leaves out the line drawn across today's column at the time it is now, and the dot on its leading edge. Left in, it ticks once a minute while the calendar is on screen.
monthEventLimitnumber3
How many events a day of the month view shows before it starts counting the rest.
readonlybooleanfalse
Stops events being moved and stretched, by dragging them and with the keyboard. They stay readable and clickable, and nothing else.
disabledbooleanfalse
Freezes the whole calendar: nothing can be moved, created or opened, and no other period can be reached. The cards leave the tab order while the grid keeps its own, so the agenda can still be read. That is what separates it from readonly, which stops the editing alone.
creatablebooleanfalse
Lets an empty stretch of a time grid be drawn out with the pointer, up or down from the slot pressed. On release its times are reported through event-create and nothing is added to events: putting the event on the calendar is yours to do. A click, or Enter on a focused cell, reports cell-activate with or without this.
edgeStepDelaynumber800
How long a dragged event has to rest against the side of the calendar before the view turns to the previous or next period, in milliseconds. Zero turns that off. The wait is the point of it: paging the instant the pointer touched the edge would make the last day of a week impossible to aim at.
noEdgeScrollbooleanfalse
Stops dragging near the top or bottom of a time grid from scrolling it.
labelstringnone
What the calendar is called, for anyone who cannot see it.
v-model:viewCalendarView'day' | '4days' | 'week' | 'month' | 'year' | 'custom''week'
Which span the calendar is showing. It opens on the week.
v-model:datestringtoday
The day the view is anchored on, as an ISO string. It opens on today.
v-model:eventsE[][]
What is on the calendar. It is a model rather than a plain prop because dragging and resizing write back to it: the calendar rearranges what it is given and hands the new list back, never mutating the one it received.

Events

EventType
event-activate[event: E]
A card was clicked or activated, the cue to open an editor of your own.
cell-activate[cell: CalendarCell]
An empty part of the grid was activated, at this day and this time. A day of the month view has no hour of its own, and reports the one the time grids start at.
event-move[event: E, previous: CalendarEventTimes]
An event was dragged or nudged somewhere else. It carries the event as it now stands and where it came from, so undoing it needs no copy of your own.
event-resize[event: E, previous: CalendarEventTimes]
An event's end was dragged or nudged, in the same two parts.
event-create[times: CalendarEventTimes]
An empty stretch of a day was drawn out, and these are its times. Nothing has been added to the list: this is the cue to make the event, in your own model or through your own form.

Slots

SlotType
actions{}
Extra controls in the toolbar, between the range and the view menu.
eventCalendarEventSlotProps<E>
The content of one event's card, replacing the title and times.
day-header{ iso: string; weekday: string; dayText: string; today: boolean; }
The head of one day column, replacing the weekday and the number.
all-day-label{}
The label beside the band of all-day events.

Types

The types the tables above name, written as the library declares them. The ones carrying export can be imported from vectis-ui to type your own code; the others are the shape of what a slot hands out.

export interface CalendarCell {
  date: string
  time: string
}
export interface CalendarEvent {
  id: CalendarEventId
  title: string
  start: string
  end: string
  startTime: string
  endTime: string
  description?: string
  color?: string
  timezone?: string
  allDay?: boolean
}
export type CalendarEventId = string | number
export type CalendarEventLayout = 'block' | 'chip'
export interface CalendarEventSlotProps<E extends CalendarEvent = CalendarEvent> {
  event: E
  layout: CalendarEventLayout
  timeText: string
  continuesBefore: boolean
  continuesAfter: boolean
  dragging: boolean
  grabbed: boolean
}
export interface CalendarEventTimes {
  start: string
  end: string
  startTime: string
  endTime: string
}
export type CalendarView = 'day' | '4days' | 'week' | 'month' | 'year' | 'custom'

CSS variables

TokenValue
--vectis-control-size-calendar-hour4rem
--vectis-control-size-calendar-gutter4.5rem
--vectis-control-size-calendar-tick0.375rem
--vectis-control-size-calendar-edge3rem
--vectis-control-size-calendar-day-min5rem
--vectis-control-size-calendar-handle0.5rem
--vectis-control-size-calendar-allday-lane1.5rem
--vectis-control-size-calendar-allday-max7rem
--vectis-control-size-calendar-now-dot0.625rem
--vectis-control-size-calendar-month-cell8.5rem
--vectis-control-size-calendar-year-cell1.5rem