UIPackage
Menu

Framework

Change language

Boilerplate repo

Calendar

calendarui

Single-month calendar grid for date selection (reka-ui + @internationalized/date). Use inline or in a custom popover. For form fields, use Date Picker. Supports min/max, disabled dates, multi-month, and locale.

Also available for React ->

When to use which

Decision Guide

Calendar is the always-visible month grid. Date Picker is the form field with popover. Range Calendar manages multi-month spans, and Event Calendar is the multi-view scheduling engine.

ComponentRole
Calendarcurrent

Embedded dashboard views or scheduling pages where the month grid stays continuously visible.

Grid Primitive
RangeCalendar

Contiguous multi-month booking ranges, check-in / check-out spans, and date windows.

Range Primitive
DatePicker

Forms, filter bars, and dialogs where space is tight and the calendar opens inside a popover.

Form Control
EventCalendar

Full calendar interfaces with month/week/day views, timed hourly intervals, and overlap collision lanes.

Scheduling Engine
  • Form / filter / booking field → Date Picker (view)
  • Always-visible month grid → Calendar
  • Date range / check-in selection → Range Calendar (view)
  • Multi-view schedule & timed events → Event Calendar (view)

Installation

$npx shadcn-vue@latest add https://uipkge.dev/r/vue/calendar.json
Named registry:npx shadcn-vue@latest add @uipkge/calendarInstalls to:app/components/ui/calendar/

Variants

Loading interactive previews…

Examples in Use

Full Block

Complete scheduling suite composing calendar date navigation, category color-coding, upcoming agenda side-rail, and dynamic modal workflows.

Explore full block
Loading interactive previews…

Props

NameType / ValuesDefaultRequired
classHTMLAttributes['class']; layout?: LayoutTypes; yearRange?: DateValue[]optional

Used by

Files installed (15)

  • app/components/ui/calendar/Calendar.vue7.5 kB
    <script lang="ts" setup>
    import type { CalendarRootEmits, CalendarRootProps, DateValue } from 'reka-ui'
    import type { HTMLAttributes } from 'vue'
    import type { LayoutTypes } from '.'
    import { getLocalTimeZone, today } from '@internationalized/date'
    import { createReusableTemplate, reactiveOmit } from '@vueuse/core'
    import { CalendarRoot, useDateFormatter, useForwardPropsEmits } from 'reka-ui'
    import { createYear, toDate } from 'reka-ui/date'
    import { computed, ref, toRaw } from 'vue'
    import { ChevronDown } from 'lucide-vue-next'
    import { cn } from '@/lib/utils'
    import {
      CalendarCell,
      CalendarCellTrigger,
      CalendarGrid,
      CalendarGridBody,
      CalendarGridHead,
      CalendarGridRow,
      CalendarHeadCell,
      CalendarHeader,
      CalendarHeading,
      CalendarNextButton,
      CalendarPrevButton,
      NativeSelect,
      NativeSelectOption,
    } from '.'
    
    const props = withDefaults(
      defineProps<CalendarRootProps & { class?: HTMLAttributes['class']; layout?: LayoutTypes; yearRange?: DateValue[] }>(),
      {
        modelValue: undefined,
        layout: undefined,
      },
    )
    const emits = defineEmits<CalendarRootEmits>()
    
    // Fix: Don't pass defaultValue or defaultPlaceholder to reka-ui to avoid .copy() errors
    // Only pass the props that reka-ui needs
    const delegatedProps = reactiveOmit(props, ['class', 'layout', 'placeholder', 'defaultValue', 'defaultPlaceholder'])
    
    const forwarded = useForwardPropsEmits(delegatedProps, emits)
    
    const formatter = useDateFormatter(props.locale ?? 'en')
    
    const yearRange = computed(() => {
      if (props.yearRange) return props.yearRange
      const base = toRaw(props.placeholder) ?? today(getLocalTimeZone())
      const start = props?.minValue ?? base.add({ years: -100 })
      const end = props?.maxValue ?? base.add({ years: 10 })
      const years: DateValue[] = []
      let current = start
      while (current.compare(end) <= 0) {
        years.push(current)
        current = current.add({ years: 1 })
      }
      return years
    })
    
    const [DefineMonthTemplate, ReuseMonthTemplate] = createReusableTemplate<{ date: DateValue }>()
    const [DefineYearTemplate, ReuseYearTemplate] = createReusableTemplate<{ date: DateValue }>()
    
    // Fix: Use simple ref for placeholder, not useVModel, to avoid .copy() error in reka-ui
    const internalPlaceholder = ref(props.placeholder ?? today(getLocalTimeZone()))
    
    function setMonth(e: Event) {
      const v = Number((e?.target as HTMLSelectElement | null)?.value)
      internalPlaceholder.value = (internalPlaceholder.value as DateValue).set({ month: v })
    }
    function setYear(e: Event) {
      const v = Number((e?.target as HTMLSelectElement | null)?.value)
      internalPlaceholder.value = (internalPlaceholder.value as DateValue).set({ year: v })
    }
    </script>
    
    <template>
      <DefineMonthTemplate v-slot="{ date }">
        <div class="relative inline-flex items-center">
          <select
            data-uipkge
            data-slot="calendar-month-select"
            class="border-input hover:bg-accent/60 text-foreground focus-visible:ring-ring h-8 cursor-pointer appearance-none rounded-md border bg-transparent pr-6 pl-2.5 text-xs font-medium transition-colors focus-visible:ring-1 focus-visible:outline-none"
            :value="date.month"
            @change="setMonth"
          >
            <option
              v-for="month in createYear({ dateObj: date })"
              :key="month.toString()"
              :value="month.month"
              :selected="date.month === month.month"
              class="bg-popover text-popover-foreground"
            >
              {{ formatter.custom(toDate(month), { month: 'short' }) }}
            </option>
          </select>
          <ChevronDown class="text-muted-foreground pointer-events-none absolute right-1.5 size-3.5 opacity-60" />
        </div>
      </DefineMonthTemplate>
    
      <DefineYearTemplate v-slot="{ date }">
        <div class="relative inline-flex items-center">
          <select
            data-uipkge
            data-slot="calendar-year-select"
            class="border-input hover:bg-accent/60 text-foreground focus-visible:ring-ring h-8 cursor-pointer appearance-none rounded-md border bg-transparent pr-6 pl-2.5 text-xs font-medium transition-colors focus-visible:ring-1 focus-visible:outline-none"
            :value="date.year"
            @change="setYear"
          >
            <option
              v-for="year in yearRange"
              :key="year.toString()"
              :value="year.year"
              :selected="date.year === year.year"
              class="bg-popover text-popover-foreground"
            >
              {{ formatter.custom(toDate(year), { year: 'numeric' }) }}
            </option>
          </select>
          <ChevronDown class="text-muted-foreground pointer-events-none absolute right-1.5 size-3.5 opacity-60" />
        </div>
      </DefineYearTemplate>
    
      <CalendarRoot
        v-slot="{ grid, weekDays, date }"
        v-bind="forwarded"
        :placeholder="internalPlaceholder as DateValue"
        data-uipkge
        data-slot="calendar"
        :class="cn('p-3', props.class)"
        @update:placeholder="
          (val) => {
            internalPlaceholder = val
          }
        "
      >
        <CalendarHeader class="pt-0">
          <nav class="absolute inset-x-0 top-0 flex items-center justify-between gap-1">
            <CalendarPrevButton>
              <slot name="calendar-prev-icon" />
            </CalendarPrevButton>
            <CalendarNextButton>
              <slot name="calendar-next-icon" />
            </CalendarNextButton>
          </nav>
    
          <slot name="calendar-heading" :date="date" :month="ReuseMonthTemplate" :year="ReuseYearTemplate">
            <template v-if="layout === 'month-and-year'">
              <div class="flex items-center justify-center gap-1">
                <ReuseMonthTemplate :date="date" />
                <ReuseYearTemplate :date="date" />
              </div>
            </template>
            <template v-else-if="layout === 'month-only'">
              <div class="flex items-center justify-center gap-1">
                <ReuseMonthTemplate :date="date" />
                {{ formatter.custom(toDate(date), { year: 'numeric' }) }}
              </div>
            </template>
            <template v-else-if="layout === 'year-only'">
              <div class="flex items-center justify-center gap-1">
                {{ formatter.custom(toDate(date), { month: 'short' }) }}
                <ReuseYearTemplate :date="date" />
              </div>
            </template>
            <template v-else>
              <CalendarHeading />
            </template>
          </slot>
        </CalendarHeader>
    
        <div class="mt-4 flex flex-col gap-y-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
          <CalendarGrid
            v-for="month in grid"
            :key="month.value.toString()"
            class="motion-safe:animate-[calendar-month-in_220ms_cubic-bezier(0.22,1,0.36,1)_both]"
          >
            <CalendarGridHead>
              <CalendarGridRow>
                <CalendarHeadCell v-for="day in weekDays" :key="day">
                  {{ day }}
                </CalendarHeadCell>
              </CalendarGridRow>
            </CalendarGridHead>
            <CalendarGridBody>
              <CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
                <CalendarCell v-for="weekDate in weekDates" :key="weekDate.toString()" :date="weekDate">
                  <CalendarCellTrigger :day="weekDate" :month="month.value">
                    <slot name="cell" :day="weekDate" :month="month.value">
                      {{ weekDate.day }}
                    </slot>
                  </CalendarCellTrigger>
                </CalendarCell>
              </CalendarGridRow>
            </CalendarGridBody>
          </CalendarGrid>
        </div>
      </CalendarRoot>
    </template>
    
    <style>
    @keyframes calendar-month-in {
      from {
        opacity: 0;
        transform: translateY(4px);
      }
      to {
        opacity: 1;
        transform: translateY(0);
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      [data-slot='calendar'] [class*='animate-\\[calendar-month'] {
        animation: none !important;
      }
    }
    </style>
    
  • app/components/ui/calendar/CalendarCell.vue0.7 kB
  • app/components/ui/calendar/CalendarCellTrigger.vue2.1 kB
  • app/components/ui/calendar/CalendarGrid.vue0.6 kB
  • app/components/ui/calendar/CalendarGridBody.vue0.3 kB
  • app/components/ui/calendar/CalendarGridHead.vue0.4 kB
  • app/components/ui/calendar/CalendarGridRow.vue0.6 kB
  • app/components/ui/calendar/CalendarHeadCell.vue0.7 kB
  • app/components/ui/calendar/CalendarHeader.vue0.7 kB
  • app/components/ui/calendar/CalendarHeading.vue0.8 kB
  • app/components/ui/calendar/CalendarNextButton.vue1 kB
  • app/components/ui/calendar/CalendarPrevButton.vue1 kB
  • app/components/ui/calendar/NativeSelect.vue4.7 kB
  • app/components/ui/calendar/NativeSelectOption.vue0.4 kB
  • app/components/ui/calendar/index.ts1 kB

Raw manifest:https://uipkge.dev/r/vue/calendar.json