Calendar
UiCalendar shows one or more months as a grid of days. For a field that opens one in a popup, use UiInputDate. Bind the selection with v-model. The model holds Date objects at midnight local time: a Date in single mode, a Date[] in multiple mode and { start, end } in range mode. No date library is involved; names and numbers come from Intl.
Pick a date
<script setup lang="ts">
import { ref } from 'vue'
const dueDate = ref<Date | null>(null)
</script>
<template>
<UiCalendar v-model="dueDate" aria-label="Due date" />
</template>
Picking a day keeps the time of the previous value, so a date that already has a time keeps it.
Pick a range
In range mode the first click sets the start and the second sets the end. Until the end is picked, the days between the start and the pointer (or the focused day) are highlighted. Clicking a day before the start begins a new range. months shows several months side by side:
<script setup lang="ts">
import type { IDateRange } from '@colorffy/ui'
const period = ref<IDateRange>({ start: null, end: null })
</script>
<template>
<UiCalendar v-model="period" mode="range" :months="2" aria-label="Report period" />
</template>
Picking the start emits { start, end: null }, so watch for a non-null end before fetching.
Pick several dates
multiple mode toggles each day on and off. The array is kept in date order:
<script setup lang="ts">
const officeDays = ref<Date[]>([])
</script>
<template>
<UiCalendar v-model="officeDays" mode="multiple" aria-label="Office days" />
</template>
Limit the dates
min and max disable the days outside them and stop the month buttons at their months. disabledDates disables any other day; it receives a copy of each day:
<script setup lang="ts">
const today = new Date()
const isWeekend = (date: Date) => date.getDay() === 0 || date.getDay() === 6
</script>
<template>
<UiCalendar
v-model="delivery"
:min="today"
:max="new Date(today.getFullYear(), today.getMonth() + 2, 0)"
:disabled-dates="isWeekend"
aria-label="Delivery date"
/>
</template>
Disabled days stay focusable, so keyboard users can move through them; they can't be picked.
Control the visible month
The calendar opens on the month of the selection, or of today, and stays there while you pick. Bind v-model:month to choose the first visible month or to follow navigation:
<template>
<UiCalendar v-model="date" v-model:month="month" />
<UiButton text="Today" variant="text" size="sm" @click="month = new Date()" />
</template>
Fill the width
fluid makes the calendar fill its container, as calendars in mobile apps do: each day takes a seventh of the width, up to 3.5rem, range strips run edge to edge, and several months stack instead of sitting side by side. It's what UiInputDate uses on phones.
<template>
<UiCalendar v-model="stay" mode="range" :months="2" fluid aria-label="Stay" />
</template>
The day size comes from container query units, so the calendar sizes itself to its parent, not the viewport.
Locale and week start
Month and weekday names follow locale. Without it, the calendar uses the page's lang, then the browser's language, once it's mounted; pass locale when you render on the server so both sides agree. The week starts on the locale's first day (Sunday for en-US, Monday for de-DE, Saturday for ar-EG); weekStart overrides it (0 = Sunday … 6 = Saturday). Inside dir="rtl", the arrows and the keyboard follow the reading direction.
<template>
<UiCalendar
v-model="date"
locale="es-SV"
aria-label="Fecha"
:labels="{
previousMonth: 'Mes anterior',
nextMonth: 'Mes siguiente',
rangeStart: 'Fecha de inicio {date}. Elige la fecha final.',
}"
/>
</template>
Custom day content
The #day slot replaces the number inside each day button. It receives date, selected, disabled, today and inRange. The slot renders inside a <button>, so use inline elements:
<template>
<UiCalendar v-model="date" aria-label="Deadlines">
<template #day="{ date: day }">
{{ day.getDate() }}
<span v-if="hasDeadline(day)" class="deadline-dot" aria-hidden="true" />
</template>
</UiCalendar>
</template>
<style scoped>
.deadline-dot {
inline-size: 4px;
block-size: 4px;
border-radius: 50%;
background: currentColor;
}
</style>
The day button is a grid, so the number and the dot stack.
With one month, the days of the neighboring months fill the first and last weeks; they can't be picked. Set showOutsideDays to false to leave those cells empty. With several months they are always empty. Every month has six rows, so the height doesn't change while you navigate.
Accessibility
- Each month is a
role="grid"table labelled by its title. The weekday headers carry the full day name inabbr. - Each day is a button named with the full date ("Sunday, October 4, 2026"). Its cell reports
aria-selected, today hasaria-current="date"and disabled days havearia-disabled. - Only one day is in the tab order: the last focused day, else the selection, else today.
- Changing month and picking a range start are announced through a polite live region.
| Key | Moves focus to |
|---|---|
← / → | Previous / next day |
↑ / ↓ | Same day in the previous / next week |
Home / End | First / last day of the week |
Page Up / Page Down | Same day in the previous / next month |
Shift + Page Up / Page Down | Same day in the previous / next year |
Enter / Space | Picks the focused day |
Moving past the visible months turns the page, and focus never leaves min/max.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | Date | Date[] | IDateRange | null | null | Selection. Bind with v-model. |
mode | 'single' | 'multiple' | 'range' | 'single' | One date, any number of dates, or a range. |
month | Date | null | null | First visible month. Bind with v-model:month. |
months | number | 1 | Months shown side by side. |
min / max | Date | null | null | First and last pickable days. |
disabledDates | ((date: Date) => boolean) | null | null | Returns true for days that can't be picked. |
locale | string | null | null | BCP 47 locale. Defaults to the page's lang, then the browser's language. |
weekStart | number | null | null | First day of the week, 0 (Sunday) to 6 (Saturday). Defaults to the locale's. |
showOutsideDays | boolean | true | Show the neighboring months' days in a single-month calendar. |
fluid | boolean | false | Fill the container's width, with days up to 3.5rem and stacked months. |
disabled | boolean | false | Disable every day and the month buttons. |
ariaLabel | string | 'Calendar' | Name of the calendar group. |
labels | Partial<ICalendarLabels> | null | null | Text for the month buttons and announcements. |
customClass | ClassValue | null | null | Classes for the root element. |
Labels (ICalendarLabels): previousMonth ('Previous month'), nextMonth ('Next month'), rangeStart ('Start date {date} selected. Pick an end date.', {date} is replaced with the full date).
Emits: update:modelValue(value), update:month(month), select(date) (the picked day).
Slots: day (ICalendarDaySlot).
CSS variables
Set these on :root, or on any wrapper to restyle only the calendars inside it. Days take their corners from the control shape and their hover and pressed backgrounds from the state layer.
:root {
--cffy-calendar-day-size: 2.5rem;
--cffy-calendar-day-radius: var(--cffy-radius-full);
--cffy-calendar-range-bg-color: var(--cffy-secondary-container);
}
| Variable | Default | Controls |
|---|---|---|
--cffy-calendar-color / --cffy-calendar-muted-color | --cffy-on-background / --cffy-muted-a10 | Day text; weekday headers, neighboring and disabled days |
--cffy-calendar-font-size | --cffy-fs-2xs | Day numbers |
--cffy-calendar-title-font-size / --cffy-calendar-title-font-weight | --cffy-fs-xs / --cffy-fw-700 | Month titles |
--cffy-calendar-gap | --cffy-space-24 | Space between months |
--cffy-calendar-day-size | --cffy-control-height-md (2.25rem) | Day and month button size |
--cffy-calendar-day-radius | --cffy-shape-control | Day and month button corners |
--cffy-calendar-selected-bg-color / --cffy-calendar-selected-color | --cffy-primary-a10 / --cffy-on-primary | Selected days and range ends |
--cffy-calendar-range-bg-color / --cffy-calendar-range-color | --cffy-primary-container / --cffy-on-primary-container | Days inside a range |
--cffy-calendar-today-color | --cffy-primary-a10 | Ring around today |
--cffy-calendar-easing / --cffy-calendar-duration | --cffy-ease-decelerate / --cffy-duration-150 | Hover and selection transitions |