Calendar
Vue date-timeSingle-month calendar grid for date selection, built on reka-ui’s Calendar primitive. Pair it with a Popover or use it inline. Supports min/max bounds, disabled dates, and locale formatting.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/calendar.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/calendar.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/calendar.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/calendar.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/calendar
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
class | HTMLAttributes['class']; layout?: LayoutTypes; yearRange?: DateValue[] | — | optional |
Dependencies
Used by
Files (15)
-
app/components/ui/calendar/Calendar.vue 6.7 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 { 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', 'modelValue', '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="**:data-[slot=native-select-icon]:right-1"> <div class="relative"> <div class="pointer-events-none absolute inset-0 flex h-full items-center pl-2 text-sm"> {{ formatter.custom(toDate(date), { month: 'short' }) }} </div> <NativeSelect class="relative h-8 pr-6 pl-2 text-xs text-transparent" @change="setMonth"> <NativeSelectOption v-for="month in createYear({ dateObj: date })" :key="month.toString()" :value="month.month" :selected="date.month === month.month" > {{ formatter.custom(toDate(month), { month: 'short' }) }} </NativeSelectOption> </NativeSelect> </div> </div> </DefineMonthTemplate> <DefineYearTemplate v-slot="{ date }"> <div class="**:data-[slot=native-select-icon]:right-1"> <div class="relative"> <div class="pointer-events-none absolute inset-0 flex h-full items-center pl-2 text-sm"> {{ formatter.custom(toDate(date), { year: 'numeric' }) }} </div> <NativeSelect class="relative h-8 pr-6 pl-2 text-xs text-transparent" @change="setYear"> <NativeSelectOption v-for="year in yearRange" :key="year.toString()" :value="year.year" :selected="date.year === year.year" > {{ formatter.custom(toDate(year), { year: 'numeric' }) }} </NativeSelectOption> </NativeSelect> </div> </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()"> <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> -
app/components/ui/calendar/CalendarCell.vue 0.8 kB
<script lang="ts" setup> import type { CalendarCellProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { reactiveOmit } from '@vueuse/core' import { CalendarCell, useForwardProps } from 'reka-ui' import { cn } from '@/lib/utils' const props = defineProps<CalendarCellProps & { class?: HTMLAttributes['class'] }>() const delegatedProps = reactiveOmit(props, 'class') const forwardedProps = useForwardProps(delegatedProps) </script> <template> <CalendarCell data-uipkge data-slot="calendar-cell" :class=" cn( '[&:has([data-selected])]:bg-accent relative flex-1 p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md', props.class, ) " v-bind="forwardedProps" > <slot /> </CalendarCell> </template> -
app/components/ui/calendar/CalendarCellTrigger.vue 1.6 kB
<script lang="ts" setup> import type { CalendarCellTriggerProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { reactiveOmit } from '@vueuse/core' import { CalendarCellTrigger, useForwardProps } from 'reka-ui' import { cn } from '@/lib/utils' import { buttonVariants } from '@/components/ui/button' const props = withDefaults(defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes['class'] }>(), { as: 'button', }) const delegatedProps = reactiveOmit(props, 'class') const forwardedProps = useForwardProps(delegatedProps) </script> <template> <CalendarCellTrigger data-uipkge data-slot="calendar-cell-trigger" :class=" cn( buttonVariants({ variant: 'ghost' }), 'size-9 cursor-pointer p-0 font-normal aria-selected:opacity-100', '[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground', // Selected 'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground data-[selected]:opacity-100', // Disabled 'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50', // Unavailable 'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through', // Outside months 'data-[outside-view]:text-muted-foreground', props.class, ) " v-bind="forwardedProps" > <slot /> </CalendarCellTrigger> </template> -
app/components/ui/calendar/CalendarGrid.vue 0.7 kB
<script lang="ts" setup> import type { CalendarGridProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { reactiveOmit } from '@vueuse/core' import { CalendarGrid, useForwardProps } from 'reka-ui' import { cn } from '@/lib/utils' const props = defineProps<CalendarGridProps & { class?: HTMLAttributes['class'] }>() const delegatedProps = reactiveOmit(props, 'class') const forwardedProps = useForwardProps(delegatedProps) </script> <template> <CalendarGrid data-uipkge data-slot="calendar-grid" :class="cn('w-full border-collapse space-x-1', props.class)" v-bind="forwardedProps" > <slot /> </CalendarGrid> </template> -
app/components/ui/calendar/CalendarGridBody.vue 0.3 kB
<script lang="ts" setup> import type { CalendarGridBodyProps } from 'reka-ui' import { CalendarGridBody } from 'reka-ui' const props = defineProps<CalendarGridBodyProps>() </script> <template> <CalendarGridBody data-uipkge data-slot="calendar-grid-body" v-bind="props"> <slot /> </CalendarGridBody> </template> -
app/components/ui/calendar/CalendarGridHead.vue 0.4 kB
<script lang="ts" setup> import type { CalendarGridHeadProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { CalendarGridHead } from 'reka-ui' const props = defineProps<CalendarGridHeadProps & { class?: HTMLAttributes['class'] }>() </script> <template> <CalendarGridHead data-uipkge data-slot="calendar-grid-head" v-bind="props"> <slot /> </CalendarGridHead> </template> -
app/components/ui/calendar/CalendarGridRow.vue 0.6 kB
<script lang="ts" setup> import type { CalendarGridRowProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { reactiveOmit } from '@vueuse/core' import { CalendarGridRow, useForwardProps } from 'reka-ui' import { cn } from '@/lib/utils' const props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes['class'] }>() const delegatedProps = reactiveOmit(props, 'class') const forwardedProps = useForwardProps(delegatedProps) </script> <template> <CalendarGridRow data-uipkge data-slot="calendar-grid-row" :class="cn('flex', props.class)" v-bind="forwardedProps"> <slot /> </CalendarGridRow> </template> -
app/components/ui/calendar/CalendarHeadCell.vue 0.7 kB
<script lang="ts" setup> import type { CalendarHeadCellProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { reactiveOmit } from '@vueuse/core' import { CalendarHeadCell, useForwardProps } from 'reka-ui' import { cn } from '@/lib/utils' const props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes['class'] }>() const delegatedProps = reactiveOmit(props, 'class') const forwardedProps = useForwardProps(delegatedProps) </script> <template> <CalendarHeadCell data-uipkge data-slot="calendar-head-cell" :class="cn('text-muted-foreground flex-1 rounded-md text-xs font-normal', props.class)" v-bind="forwardedProps" > <slot /> </CalendarHeadCell> </template> -
app/components/ui/calendar/CalendarHeader.vue 0.7 kB
<script lang="ts" setup> import type { CalendarHeaderProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { reactiveOmit } from '@vueuse/core' import { CalendarHeader, useForwardProps } from 'reka-ui' import { cn } from '@/lib/utils' const props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes['class'] }>() const delegatedProps = reactiveOmit(props, 'class') const forwardedProps = useForwardProps(delegatedProps) </script> <template> <CalendarHeader data-uipkge data-slot="calendar-header" :class="cn('relative flex w-full items-center justify-center px-8 pt-1', props.class)" v-bind="forwardedProps" > <slot /> </CalendarHeader> </template> -
app/components/ui/calendar/CalendarHeading.vue 0.8 kB
<script lang="ts" setup> import type { CalendarHeadingProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { reactiveOmit } from '@vueuse/core' import { CalendarHeading, useForwardProps } from 'reka-ui' import { cn } from '@/lib/utils' const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes['class'] }>() defineSlots<{ default: (props: { headingValue: string }) => any }>() const delegatedProps = reactiveOmit(props, 'class') const forwardedProps = useForwardProps(delegatedProps) </script> <template> <CalendarHeading v-slot="{ headingValue }" data-uipkge data-slot="calendar-heading" :class="cn('text-sm font-medium', props.class)" v-bind="forwardedProps" > <slot :heading-value> {{ headingValue }} </slot> </CalendarHeading> </template> -
app/components/ui/calendar/CalendarNextButton.vue 1 kB
<script lang="ts" setup> import type { CalendarNextProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { reactiveOmit } from '@vueuse/core' import { ChevronRight } from 'lucide-vue-next' import { CalendarNext, useForwardProps } from 'reka-ui' import { cn } from '@/lib/utils' import { buttonVariants } from '@/components/ui/button' const props = defineProps<CalendarNextProps & { class?: HTMLAttributes['class'] }>() const delegatedProps = reactiveOmit(props, 'class') const forwardedProps = useForwardProps(delegatedProps) </script> <template> <CalendarNext data-uipkge data-slot="calendar-next-button" :class=" cn( buttonVariants({ variant: 'outline' }), 'size-9 bg-transparent p-0 opacity-70 hover:opacity-100 focus-visible:opacity-100', props.class, ) " v-bind="forwardedProps" > <slot> <ChevronRight class="size-4" aria-hidden="true" /> </slot> </CalendarNext> </template> -
app/components/ui/calendar/CalendarPrevButton.vue 1 kB
<script lang="ts" setup> import type { CalendarPrevProps } from 'reka-ui' import type { HTMLAttributes } from 'vue' import { reactiveOmit } from '@vueuse/core' import { ChevronLeft } from 'lucide-vue-next' import { CalendarPrev, useForwardProps } from 'reka-ui' import { cn } from '@/lib/utils' import { buttonVariants } from '@/components/ui/button' const props = defineProps<CalendarPrevProps & { class?: HTMLAttributes['class'] }>() const delegatedProps = reactiveOmit(props, 'class') const forwardedProps = useForwardProps(delegatedProps) </script> <template> <CalendarPrev data-uipkge data-slot="calendar-prev-button" :class=" cn( buttonVariants({ variant: 'outline' }), 'size-9 bg-transparent p-0 opacity-70 hover:opacity-100 focus-visible:opacity-100', props.class, ) " v-bind="forwardedProps" > <slot> <ChevronLeft class="size-4" aria-hidden="true" /> </slot> </CalendarPrev> </template> -
app/components/ui/calendar/NativeSelect.vue 4.8 kB
<script setup lang="ts"> import type { AcceptableValue } from 'reka-ui' import { computed } from 'vue' import type { HTMLAttributes } from 'vue' import { reactiveOmit } from '@vueuse/core' import { ChevronDownIcon } from 'lucide-vue-next' import { cn } from '@/lib/utils' defineOptions({ inheritAttrs: false, }) interface Props { modelValue?: AcceptableValue | AcceptableValue[] class?: HTMLAttributes['class'] label?: string placeholder?: string disabled?: boolean readonly?: boolean error?: boolean errorMessages?: string | string[] successMessages?: string | string[] hint?: string variant?: 'outlined' | 'filled' | 'solo' | 'underlined' density?: 'compact' | 'default' | 'comfortable' color?: 'primary' | 'secondary' | 'error' | 'success' | 'warning' | 'info' hideDetails?: boolean bgColor?: string } const props = withDefaults(defineProps<Props>(), { modelValue: undefined, label: '', placeholder: '', disabled: false, readonly: false, error: false, errorMessages: () => [], successMessages: () => [], hint: '', variant: 'outlined', density: 'default', color: 'primary', hideDetails: false, }) const emit = defineEmits<{ 'update:modelValue': AcceptableValue }>() const modelValue = computed({ get() { return props.modelValue }, set(value) { emit('update:modelValue', value) }, }) const delegatedProps = reactiveOmit( props, 'class', 'label', 'placeholder', 'disabled', 'readonly', 'error', 'errorMessages', 'successMessages', 'hint', 'variant', 'density', 'color', 'hideDetails', 'bgColor', 'modelValue', ) const hasError = computed( () => props.error || (Array.isArray(props.errorMessages) ? props.errorMessages.length > 0 : !!props.errorMessages), ) const hasSuccess = computed( () => !hasError.value && (Array.isArray(props.successMessages) ? props.successMessages.length > 0 : !!props.successMessages), ) const densityClasses = { compact: 'h-8 text-xs', default: 'h-9 text-sm', comfortable: 'h-10 text-base', } const variantClasses = { outlined: 'border bg-transparent', filled: 'border-b-2 bg-muted/30 border-transparent', solo: 'border bg-card shadow-md', underlined: 'border-b bg-transparent rounded-none border-x-0 border-t-0', } const stateClasses = computed(() => { if (hasError.value) return 'border-destructive focus-visible:ring-destructive/20' if (hasSuccess.value) return 'border-[var(--success)] focus-visible:border-[var(--success)]' return 'border-input focus-visible:border-ring focus-visible:ring-ring/20 focus-visible:ring-[3px]' }) </script> <template> <div class="relative w-full" :class="densityClasses[density]"> <!-- Label --> <label v-if="label" class="mb-1 block text-sm font-medium" :class="{ 'text-destructive': hasError }"> {{ label }} </label> <!-- Select Wrapper --> <div class="group/native-select relative w-full" :class="props.class"> <select v-bind="{ ...$attrs, ...delegatedProps }" v-model="modelValue" data-uipkge data-slot="native-select" :disabled="disabled" :readonly="readonly" :class=" cn( 'border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50', variantClasses[variant], stateClasses, densityClasses[density], 'w-full min-w-0 appearance-none rounded-md border bg-transparent px-3 pr-9 shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50', bgColor, ) " > <slot /> </select> <ChevronDownIcon class="text-muted-foreground pointer-events-none absolute top-1/2 right-3.5 size-4 -translate-y-1/2 opacity-50 select-none" :class="{ 'top-1/2': density === 'default' || density === 'compact' || density === 'comfortable', }" aria-hidden="true" data-uipkge data-slot="native-select-icon" /> </div> <!-- Details: hint, error, success --> <div v-if="!hideDetails" class="mt-1 text-xs" :class="{ 'text-destructive': hasError, 'text-[var(--success)]': hasSuccess, 'text-muted-foreground': !hasError && !hasSuccess, }" > <template v-if="hasError"> <span v-if="typeof errorMessages === 'string'">{{ errorMessages }}</span> <span v-else>{{ errorMessages[0] }}</span> </template> <template v-else-if="hasSuccess"> <span v-if="typeof successMessages === 'string'">{{ successMessages }}</span> <span v-else>{{ successMessages[0] }}</span> </template> <template v-else>{{ hint }}</template> </div> </div> </template> -
app/components/ui/calendar/NativeSelectOption.vue 0.4 kB
<!-- @fallthroughAttributes true --> <!-- @strictTemplates true --> <script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' const props = defineProps<{ class?: HTMLAttributes['class'] }>() const dataAttrs = { 'data-slot': 'native-select-option' } </script> <template> <option v-bind="dataAttrs" :class="cn('bg-popover text-popover-foreground', props.class)"> <slot /> </option> </template> -
app/components/ui/calendar/index.ts 1 kB
export { default as Calendar } from './Calendar.vue' export { default as CalendarCell } from './CalendarCell.vue' export { default as CalendarCellTrigger } from './CalendarCellTrigger.vue' export { default as CalendarGrid } from './CalendarGrid.vue' export { default as CalendarGridBody } from './CalendarGridBody.vue' export { default as CalendarGridHead } from './CalendarGridHead.vue' export { default as CalendarGridRow } from './CalendarGridRow.vue' export { default as CalendarHeadCell } from './CalendarHeadCell.vue' export { default as CalendarHeader } from './CalendarHeader.vue' export { default as CalendarHeading } from './CalendarHeading.vue' export { default as CalendarNextButton } from './CalendarNextButton.vue' export { default as CalendarPrevButton } from './CalendarPrevButton.vue' export { default as NativeSelect } from './NativeSelect.vue' export { default as NativeSelectOption } from './NativeSelectOption.vue' export type LayoutTypes = 'month-and-year' | 'month-only' | 'year-only' | undefined
Raw manifest: https://uipkge.dev/r/vue/calendar.json