Components

Calendar

An inline month grid for picking a date, several dates or a range, with keyboard navigation and locale-aware names.

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 in abbr.
  • Each day is a button named with the full date ("Sunday, October 4, 2026"). Its cell reports aria-selected, today has aria-current="date" and disabled days have aria-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.
KeyMoves focus to
← / →Previous / next day
↑ / ↓Same day in the previous / next week
Home / EndFirst / last day of the week
Page Up / Page DownSame day in the previous / next month
Shift + Page Up / Page DownSame day in the previous / next year
Enter / SpacePicks the focused day

Moving past the visible months turns the page, and focus never leaves min/max.

Props

PropTypeDefaultDescription
modelValueDate | Date[] | IDateRange | nullnullSelection. Bind with v-model.
mode'single' | 'multiple' | 'range''single'One date, any number of dates, or a range.
monthDate | nullnullFirst visible month. Bind with v-model:month.
monthsnumber1Months shown side by side.
min / maxDate | nullnullFirst and last pickable days.
disabledDates((date: Date) => boolean) | nullnullReturns true for days that can't be picked.
localestring | nullnullBCP 47 locale. Defaults to the page's lang, then the browser's language.
weekStartnumber | nullnullFirst day of the week, 0 (Sunday) to 6 (Saturday). Defaults to the locale's.
showOutsideDaysbooleantrueShow the neighboring months' days in a single-month calendar.
fluidbooleanfalseFill the container's width, with days up to 3.5rem and stacked months.
disabledbooleanfalseDisable every day and the month buttons.
ariaLabelstring'Calendar'Name of the calendar group.
labelsPartial<ICalendarLabels> | nullnullText for the month buttons and announcements.
customClassClassValue | nullnullClasses 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);
}
VariableDefaultControls
--cffy-calendar-color / --cffy-calendar-muted-color--cffy-on-background / --cffy-muted-a10Day text; weekday headers, neighboring and disabled days
--cffy-calendar-font-size--cffy-fs-2xsDay numbers
--cffy-calendar-title-font-size / --cffy-calendar-title-font-weight--cffy-fs-xs / --cffy-fw-700Month titles
--cffy-calendar-gap--cffy-space-24Space between months
--cffy-calendar-day-size--cffy-control-height-md (2.25rem)Day and month button size
--cffy-calendar-day-radius--cffy-shape-controlDay and month button corners
--cffy-calendar-selected-bg-color / --cffy-calendar-selected-color--cffy-primary-a10 / --cffy-on-primarySelected days and range ends
--cffy-calendar-range-bg-color / --cffy-calendar-range-color--cffy-primary-container / --cffy-on-primary-containerDays inside a range
--cffy-calendar-today-color--cffy-primary-a10Ring around today
--cffy-calendar-easing / --cffy-calendar-duration--cffy-ease-decelerate / --cffy-duration-150Hover and selection transitions
Copyright © 2026