Calendar
Choose a day or date range from a month view, using the mouse or keyboard.
October 2026
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
<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 · type | Default | Description |
|---|---|---|
| Value | ||
| modelValuestring | DateSpan | null | The chosen day as YYYY-MM-DD, or with range the span { from, to } (v-model); null for none and for an open end. | |
| Behavior | ||
| minstring | The first day that can be picked, inclusive. | |
| maxstring | The 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. | |
| rangeboolean | Pick a span of days, two presses in either order, instead of one day. | |
| Form and access | ||
| ariaLabelstring | A 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 need | Take |
|---|---|
| A day in a form field | Date picker, which wraps this |
| A period in a toolbar | Date range |
| A time of day | Time picker beside it |