Calendar
Displays a month grid to pick a single date or a date range, with day, month and year views, limits and keyboard navigation.
Component
Props
| Props | Default | Type |
|---|---|---|
modelValue | null | string | string[] | CalendarRange | null |
mode | CalendarMode.SINGLE | CalendarMode |
unit | CalendarUnit.DAY | CalendarUnit |
min | — | string |
max | — | string |
isDateDisabled | () => false | (date: string) => boolean |
locale | 'en-US' | string |
firstDayOfWeek | 0 | number |
color | ColorAccent.PRIMARY_BRAND | ColorAccent |
hasYearControls | false | boolean |
hasOutsideDays | true | boolean |
hasWeekNumbers | false | boolean |
isRounded | false | boolean |
isNavigationLocked | false | boolean |
isBoxed | true | boolean |
markers | {} | Record<string, CalendarMarker[]> |
maxMarkers | 3 | number |
dayPopoverTrigger | Trigger.MANUAL | Trigger |
hasDayPopover | — | (date: string) => boolean |
dayPopoverPosition | Position.BOTTOM | Position |
dayPopoverAlign | — | Align |
dayPopoverAutoFlip | true | boolean |
disabled | false | boolean |
readonly | false | boolean |
ariaLabel | — | string |
previousMonthAriaLabel | — | string |
nextMonthAriaLabel | — | string |
previousYearAriaLabel | — | string |
nextYearAriaLabel | — | string |
Slots
| Name | Description |
|---|---|
day-popover | Content of the panel of a day, shown according to `dayPopoverTrigger`. Receives `date` (the ISO date), `markers` (the markers of the day) and `close` (a function that closes the panel). |
Usage
All dates are ISO strings (YYYY-MM-DD), the same format the native date input uses. No time or time zone is involved.
modelValue
Sets the selected date. You can bind it with v-model. In MULTIPLE mode it is an array, and in RANGE mode it is a CalendarRange object (see mode). What each value is depends on the unit: an ISO date (2026-10-15), an ISO week (2026-W41) or a month (2026-10).
<template>
<Calendar v-model="date" />
</template>
<script setup lang="ts">
const date = ref('2026-10-15')
</script>
TypeScript Interface
export interface CalendarRange {
start: string | null
end: string | null
}
mode
Sets how many selections can be made via the CalendarMode enum. It combines with the unit, which sets what each selection covers. In MULTIPLE mode, clicking a day adds it to the array and clicking it again removes it; the array is kept sorted. In RANGE mode, the first click sets start, the second sets end, and a click before the start (or after a complete range) starts a new range. The days between the hovered day and the start are previewed while picking.
<template>
<Calendar v-model="range" :mode="CalendarMode.RANGE" />
<Calendar v-model="days" :mode="CalendarMode.MULTIPLE" />
</template>
<script setup lang="ts">
const range = ref({ start: '2026-10-10', end: '2026-10-14' })
const days = ref(['2026-10-10', '2026-10-15'])
</script>
Options
| Value | Description |
|---|---|
SINGLE | single |
MULTIPLE | multiple |
RANGE | range |
unit
Sets what each selection covers via the CalendarUnit enum: a day, an ISO week or a month. It is independent from the mode, so every combination works: one week, several weeks, a range of months...
The value of min and max can be given in any of the formats. In RANGE mode, the weeks or months at both ends are filled and the ones between are soft, and the hovered one previews the end.
<template>
<Calendar v-model="week" :unit="CalendarUnit.WEEK" :hasWeekNumbers="true" />
<Calendar v-model="weeks" :unit="CalendarUnit.WEEK" :mode="CalendarMode.RANGE" />
<Calendar v-model="months" :unit="CalendarUnit.MONTH" :mode="CalendarMode.MULTIPLE" />
</template>
<script setup lang="ts">
const week = ref('2026-W41')
const weeks = ref({ start: '2026-W41', end: '2026-W43' })
const months = ref(['2026-03', '2026-10'])
</script>
Options
| Value | Description |
|---|---|
DAY | day: values are ISO dates, such as `2026-10-15` |
WEEK | week: values are ISO weeks, such as `2026-W41` |
MONTH | month: values are months, such as `2026-10` |
min
Sets the earliest selectable date. Earlier days, months and years are disabled, and the previous buttons stop at this limit. It also accepts a month (2026-10) or a week (2026-W41), which mean the first day they cover.
<template>
<Calendar v-model="date" min="2026-10-10" />
</template>
max
Sets the latest selectable date. Later days, months and years are disabled, and the next buttons stop at this limit. It also accepts a month (2026-10) or a week (2026-W41), which mean the last day they cover.
<template>
<Calendar v-model="date" max="2026-10-20" />
</template>
isDateDisabled
Receives each day as an ISO string and returns true to disable it. Use it for weekends, holidays or booked days.
<template>
<Calendar v-model="date" :isDateDisabled="isWeekend" />
</template>
<script setup lang="ts">
const isWeekend = (iso: string) => {
const day = parseISODate(iso)!.getDay()
return day === 0 || day === 6
}
</script>
locale
Sets the BCP 47 locale used for month and weekday names. It defaults to a fixed value so server and client render the same markup.
<template>
<Calendar v-model="date" locale="es-ES" :firstDayOfWeek="1" />
</template>
firstDayOfWeek
Sets the first column of the week, from 0 (Sunday) to 6 (Saturday). It is ignored when the unit is WEEK, which always starts on Monday.
<template>
<Calendar v-model="date" :firstDayOfWeek="1" />
</template>
color
Sets the accent color of the selected days and the focus ring via the ColorAccent enum. Only PRIMARY_BRAND and SECONDARY_BRAND are supported.
<template>
<Calendar v-model="date" :color="ColorAccent.SECONDARY_BRAND" />
</template>
Options
| Value | Description |
|---|---|
PRIMARY_BRAND | primary-brand |
SECONDARY_BRAND | secondary-brand |
hasYearControls
Shows buttons in the header of the days view to jump to the previous or next year, keeping the same month. They respect min and max.
<template>
<Calendar v-model="date" :hasYearControls="true" />
</template>
hasOutsideDays
Shows the days of the adjacent months that fill the six-week grid. When false they are left empty. The grid always has six rows, so the height never changes between months.
<template>
<Calendar v-model="date" :hasOutsideDays="false" />
</template>
hasWeekNumbers
Shows the ISO week number of each row in a column before the days. The number is the one of the Thursday of the row, so it is right for Monday-first calendars (firstDayOfWeek of 1) and for the WEEK unit. The header text comes from useDSConfig().calendar.weekAbbreviationText().
<template>
<Calendar v-model="date" :hasWeekNumbers="true" :firstDayOfWeek="1" />
</template>
isRounded
Makes the days (and the month and year cells) fully round. In range mode only the two ends of the range are rounded, so the band stays continuous.
<template>
<Calendar v-model="date" :isRounded="true" />
</template>
isNavigationLocked
Locks the visible month. The previous and next buttons, the year controls and the month and year buttons in the header are disabled, so the user can only pick days of the month shown. Use it together with modelValue to present a fixed month, such as a booking window.
Keyboard navigation stays inside the month, and the days of the adjacent months are disabled because picking one would change the month. The value can still change from outside, which moves the view as usual.
<template>
<Calendar v-model="date" :isNavigationLocked="true" />
</template>
isBoxed
Adds the border, radius, background and padding. Turn it off to embed the calendar inside another surface, such as a card or a popover.
<template>
<Calendar v-model="date" :isBoxed="false" />
</template>
markers
Shows dots under the days of the calendar, such as events or tasks. It maps ISO dates to a list of markers. A marker can have a color, from the ColorAccent enum (PRIMARY_BRAND by default), and a label, which is added to the aria-label of the day so screen readers get it too. The dots follow the design system colors, and on a selected day they use the color of the text on filled backgrounds so they stay visible.
<template>
<Calendar v-model="date" :markers="events" />
</template>
<script setup lang="ts">
const events = {
'2026-10-15': [{ color: ColorAccent.DANGER, label: 'Deploy' }],
'2026-10-20': [{ label: 'Review' }, { color: ColorAccent.SUCCESS, label: 'Release' }],
}
</script>
TypeScript Interface
export interface CalendarMarker {
color?: ColorAccent
label?: string
}
maxMarkers
Sets how many dots are drawn under a day. The labels of all its markers are still part of the aria-label.
<template>
<Calendar v-model="date" :markers="events" :maxMarkers="2" />
</template>
dayPopoverTrigger
Sets when the panel of a day opens, via the Trigger enum. The content of the panel is the day-popover slot, which receives the date, its markers and a close function. By default the panel opens only for days with markers, and hasDayPopover changes that. It also closes with Escape, with a click outside, and when the visible month changes. Without the slot, or with MANUAL, no panel is shown.
<template>
<Calendar v-model="date" :markers="events" :dayPopoverTrigger="Trigger.HOVER">
<template #day-popover="{ date, markers, close }">
<p v-for="marker in markers" :key="marker.label">{{ marker.label }}</p>
</template>
</Calendar>
</template>
Options
| Value | Description |
|---|---|
CLICK | click: a click opens the panel (and a second click closes it). The click still selects the day. |
HOVER | hover: the panel opens while the pointer or the keyboard focus is on the day, and stays open while the pointer is over it. |
MANUAL | manual: the panel never opens. This is the default. |
By default the panel opens below the day, towards the inside of the calendar for the first and last columns, and moves to the other side when it does not fit in the window (see dayPopoverPosition, dayPopoverAlign and dayPopoverAutoFlip). It can still be clipped by a parent with overflow: hidden.
hasDayPopover
Receives each day as an ISO string and returns true when it has a panel. It replaces the default rule of opening only for days with markers, so you can show a panel on any day, such as an empty state with an add button.
<template>
<Calendar v-model="date" :dayPopoverTrigger="Trigger.CLICK" :hasDayPopover="isWorkday">
<template #day-popover="{ date }">
<ActionButton text="Add task" @click="addTask(date)" />
</template>
</Calendar>
</template>
dayPopoverPosition
Sets whether the panel of a day opens below or above it, via the Position enum. With dayPopoverAutoFlip, it goes to the other side when it does not fit.
<template>
<Calendar v-model="date" :markers="events" :dayPopoverTrigger="Trigger.HOVER" :dayPopoverPosition="Position.TOP">
<template #day-popover="{ date }">{{ date }}</template>
</Calendar v-model="date">
</template>
Options
| Value | Description |
|---|---|
BOTTOM | bottom |
TOP | top |
dayPopoverAlign
Sets how the panel is aligned with its day, via the Align enum. Without it, the panel is centered under the day, and the first two and last two columns align it inwards (LEFT and RIGHT) so it stays inside the calendar.
<template>
<Calendar v-model="date" :markers="events" :dayPopoverTrigger="Trigger.HOVER" :dayPopoverAlign="Align.LEFT">
<template #day-popover="{ date }">{{ date }}</template>
</Calendar v-model="date">
</template>
Options
| Value | Description |
|---|---|
LEFT | left: the left edge of the panel is aligned with the left edge of the day |
CENTER | center: the panel is centered on the day |
RIGHT | right: the right edge of the panel is aligned with the right edge of the day |
dayPopoverAutoFlip
Moves the panel when it does not fit in the window. It goes to the other side, above or below, when it would be cut off and there is more room there, and it aligns to the right or the left when it would overflow horizontally. The position is checked when the panel opens, and a position or align that you set is only the starting point. Set it to false to always keep the placement as given.
<template>
<Calendar v-model="date" :markers="events" :dayPopoverTrigger="Trigger.HOVER" :dayPopoverAutoFlip="false">
<template #day-popover="{ date }">{{ date }}</template>
</Calendar v-model="date">
</template>
disabled
Disables the whole calendar, including the navigation. Nothing can be focused or selected.
<template>
<Calendar v-model="date" :disabled="true" />
</template>
readonly
Keeps the selection fixed while still letting the user browse months, years and days. The grid is marked with aria-readonly.
<template>
<Calendar v-model="date" :readonly="true" />
</template>
ariaLabel
Sets the accessible name of the calendar.
<template>
<Calendar v-model="date" ariaLabel="Birth date" />
</template>
previousMonthAriaLabel
Sets the aria-label of the previous button in the days view. Falls back to useDSConfig().calendar.previousMonthText().
<template>
<Calendar v-model="date" previousMonthAriaLabel="Mes anterior" />
</template>
nextMonthAriaLabel
Sets the aria-label of the next button in the days view. Falls back to useDSConfig().calendar.nextMonthText().
<template>
<Calendar v-model="date" nextMonthAriaLabel="Mes siguiente" />
</template>
previousYearAriaLabel
Sets the aria-label of the previous button in the months and years views. Falls back to useDSConfig().calendar.previousYearText().
<template>
<Calendar v-model="date" previousYearAriaLabel="Año anterior" />
</template>
nextYearAriaLabel
Sets the aria-label of the next button in the months and years views. Falls back to useDSConfig().calendar.nextYearText().
<template>
<Calendar v-model="date" nextYearAriaLabel="Año siguiente" />
</template>
Accessibility
The calendar is a group with a grid of days. Each day is a button with its full date as aria-label, and disabled days use aria-disabled so they stay reachable by keyboard. Today is marked with aria-current="date". Set ariaLabel so the calendar has a name.
Only one day is in the tab order. Once the focus is on a day, these keys move it:
| Value | Description |
|---|---|
ArrowLeft / ArrowRight | Moves one day backward or forward. |
ArrowUp / ArrowDown | Moves one week backward or forward. |
Home / End | Moves to the first or last day of the month. |
PageUp / PageDown | Moves one month backward or forward. |
Shift + PageUp / PageDown | Moves one year backward or forward. |
Enter / Space | Selects the focused day. |
Days with markers have their labels in the aria-label. When a day has a panel, its button has aria-haspopup="dialog" and aria-expanded, and the panel is a dialog named after the date. With HOVER, keyboard focus on the day opens it too, and Escape closes it and returns the focus to the day.
Clicking the month in the header opens the months view, and clicking the year opens a page of years. Picking a month or a year goes straight back to the days. Navigation text comes from useDSConfig().calendar.
Emits
| Value | Description |
|---|---|
@day-click | Emitted with `{ date, key, isDisabled }` when a day is clicked, also for disabled days and when the calendar is readonly. `key` is what the click would select: the date, its ISO week or its month, depending on the `unit`. |
@day-hover | Emitted with `{ date, key, isDisabled }` when the pointer enters a day. |
@day-hover-end | Emitted when the pointer leaves the grid of days. |
@month-change | Emitted with `{ year, month, from, to }` when the visible month changes, by the buttons, the month and year pickers or the keyboard. `month` goes from 1 to 12, and `from` and `to` are the first and last day of the month as ISO dates, so you can load its events. |
@update:modelValue | Emitted with the new ISO date when a day is selected. It is an array in `MULTIPLE` mode and a `CalendarRange` in `RANGE` mode. Each value is a date, an ISO week or a month, depending on the [unit](#unit). |
Example
<template>
<Calendar :modelValue="date" @update:modelValue="handleSelect" />
</template>
<script setup lang="ts">
const date = ref<string | null>(null)
const handleSelect = (value: string) => {
date.value = value
}
</script>
Events
<template>
<Calendar v-model="date" :markers="events" @day-click="handleClick" @month-change="loadEvents" />
</template>
<script setup lang="ts">
const events = ref<Record<string, CalendarMarker[]>>({})
const handleClick = ({ date, isDisabled }: CalendarDayEvent) => {
if (!isDisabled) console.log('Day', date)
}
const loadEvents = async ({ from, to }: CalendarMonthEvent) => {
events.value = await fetchEvents(from, to)
}
</script>
TypeScript Interface
export interface CalendarDayEvent {
date: string
key: string
isDisabled: boolean
}
export interface CalendarMonthEvent {
year: number
month: number
from: string
to: string
}
Methods
| Value | Description |
|---|---|
focus() | Moves the focus to the selected day (or the last focused one) so keyboard navigation can start. |
<template>
<Calendar ref="calendar" v-model="date" />
<ActionButton text="Focus" @click="calendar?.focus()" />
</template>
<script setup lang="ts">
const calendar = ref()
</script>