Skip to content

Calendar

Choose a day or date range from a month view, using the mouse or keyboard.

October 2026
SunMonTueWedThuFriSat
Sat, Oct 24, 2026
<script setup lang="ts">import { ref, watch } from 'vue'import type { DateSpan } from 'grani'import { UiCard, UiCalendar, UiButton, useToday } from 'grani' // Days around today, in the app's zone. Today is the server's day until the page is up, so the// first render matches the prerendered one; then it is the person's day, and the examples move to it.const { now } = useToday() const shift = (iso: string, n: number, years = 0) => {  const d = new Date(`${iso}T00:00:00Z`)  d.setUTCFullYear(d.getUTCFullYear() + years)  d.setUTCDate(d.getUTCDate() + n)  return d.toISOString().slice(0, 10)} const fromToday = (n: number, years = 0) => shift(now.value, n, years) const day = ref<string | null>(null) const bounded = ref<string | null>(null) // Inside the window, or chosen before the window closed on it (a saved day, a filter): out there// it keeps its ring beyond `max`, and the arrows walk back in.const where = ref<'inside' | 'outside'>('inside') watch(where, (w) => (bounded.value = fromToday(w === 'inside' ? 2 : 16))) const withEntries = ref<string | null>(null) const meeting = ref<string | null>(null) const checkIn = ref<string | null>(null) const checkOut = ref<string | null>(null) // The calendar picks the span: the first press starts it, the second ends it, and only a whole// span reaches the model.const span = ref<DateSpan>({ from: null, to: null }) // The chosen days stand around today: set again when today moves (the page is up, midnight passes).watch(  now,  () => {    day.value = fromToday(-3)    bounded.value = fromToday(where.value === 'inside' ? 2 : 16)    withEntries.value = fromToday(0)    meeting.value = fromToday(5)    checkIn.value = fromToday(7)    checkOut.value = fromToday(10)    span.value = { from: fromToday(-2), to: fromToday(9) }  },  { immediate: true },) // The value read as a person reads it; the ISO day beside it is what the model holds. The card// is as wide as the calendar (`w-min`): the footer's words give way, they never widen it.const at = (iso: string) => new Date(`${iso}T00:00:00Z`) const long = (iso: string | null) =>  iso    ? new Intl.DateTimeFormat('en', {        weekday: 'short',        day: 'numeric',        month: 'short',        year: 'numeric',        timeZone: 'UTC',      }).format(at(iso))    : 'Nothing chosen'</script> <template>  <UiCard class="w-min">    <UiCalendar v-model="day" />    <template #footer>      <span class="me-auto text-sm" :class="day ? 'text-fg' : 'text-fg-muted'">{{        long(day)      }}</span>      <UiButton v-if="day" variant="ghost" size="xs" @click="day = null">Clear</UiButton>    </template>  </UiCard></template>

Also called date grid, month view, inline calendar.

Usage

<UiCalendar />

With GraniResolver in the Vite config, a template needs no import: set it up once.

API

Name · typeDefaultDescription
Value
modelValuestring | DateSpan | nullThe chosen day as YYYY-MM-DD, or with range the span { from, to } (v-model); null for none and for an open end.
Behavior
minstringThe first day that can be picked, inclusive.
maxstringThe last day that can be picked, inclusive.
marked(string | CalendarMark)[]Days with a dot under the number: a day, or { day, tone } for a colored dot; a day listed twice has two dots, two at most. grani.calendar.marked says what they mean (the app's translate words it).
unavailablestring[]Days that cannot be picked though they fall within min and max (booked, closed): struck through.
rangebooleanPick a span of days, two presses in either order, instead of one day.
Form and access
ariaLabelstringA name for the whole calendar where two stand on one page ("Check-in"); the grid keeps the month as its name.

Anything else lands on the root element: native attributes, aria-*, data-testid.

When to use another component

When you needTake
A day in a form fieldDate picker, which wraps this
A period in a toolbarDate range
A time of dayTime picker beside it