Calendar

A calendar component for selecting single dates, multiple dates or date ranges.

Usage

Use the v-model directive to control the selected date.

30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
1
2
3
4
5
6
7
8
9
10
11
12
Event date, February 2022
<script setup lang="ts">
import { CalendarDate } from '@internationalized/date'

const value = shallowRef(new CalendarDate(2022, 2, 3))
</script>

<template>
  <PCalendar v-model="value" />
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
import { CalendarDate } from '@internationalized/date'

const value = shallowRef(new CalendarDate(2022, 2, 3))
</script>

<template>
  <PCalendar v-model="value" />
</template>

Use the default-value prop to set the initial value when you do not need to control its state.

30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
1
2
3
4
5
6
7
8
9
10
11
12
Event date, February 2022
<script setup lang="ts">
import { CalendarDate } from '@internationalized/date'

const defaultValue = shallowRef(new CalendarDate(2022, 2, 6))
</script>

<template>
  <PCalendar :default-value="defaultValue" />
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
import { CalendarDate } from '@internationalized/date'

const defaultValue = shallowRef(new CalendarDate(2022, 2, 6))
</script>

<template>
  <PCalendar :default-value="defaultValue" />
</template>
This component uses the @internationalized/date package for locale-aware formatting. The date format is determined by the locale prop of the App component.
This component uses the @internationalized/date package for locale-aware formatting. The date format is determined by the locale prop of the App component.

Type

Use the type prop to change what the calendar selects. Defaults to date.

When using date, click the heading to switch from the day view to a month then year view for quick navigation, then drill back down to pick a date.

Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Sep
Oct
Nov
Dec
Month picker, 2022
<script setup lang="ts">
import { CalendarDate } from '@internationalized/date'

const value = shallowRef(new CalendarDate(2022, 2, 1))
</script>

<template>
  <PCalendar type="month" v-model="value" />
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
import { CalendarDate } from '@internationalized/date'

const value = shallowRef(new CalendarDate(2022, 2, 1))
</script>

<template>
  <PCalendar type="month" v-model="value" />
</template>

Use type="year" to render a standalone year picker.

2020 - 2031
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
Year picker, 2020 - 2031
<script setup lang="ts">
import { CalendarDate } from '@internationalized/date'

const value = shallowRef(new CalendarDate(2022, 1, 1))
</script>

<template>
  <PCalendar type="year" v-model="value" />
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
import { CalendarDate } from '@internationalized/date'

const value = shallowRef(new CalendarDate(2022, 1, 1))
</script>

<template>
  <PCalendar type="year" v-model="value" />
</template>

Multiple

Use the multiple prop to allow multiple selections.

30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
1
2
3
4
5
6
7
8
9
10
11
12
Event date, February 2022
<script setup lang="ts">
import { CalendarDate } from '@internationalized/date'

const value = shallowRef([
  new CalendarDate(2022, 2, 4),
  new CalendarDate(2022, 2, 6),
  new CalendarDate(2022, 2, 8)
])
</script>

<template>
  <PCalendar multiple v-model="value" />
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
import { CalendarDate } from '@internationalized/date'

const value = shallowRef([
  new CalendarDate(2022, 2, 4),
  new CalendarDate(2022, 2, 6),
  new CalendarDate(2022, 2, 8)
])
</script>

<template>
  <PCalendar multiple v-model="value" />
</template>

Range

Use the range prop to select a range of dates.

Event date, February 2022
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
1
2
3
4
5
6
7
8
9
10
11
12
<script setup lang="ts">
import { CalendarDate } from '@internationalized/date'

const value = shallowRef({
  start: new CalendarDate(2022, 2, 3),
  end: new CalendarDate(2022, 2, 20)
})
</script>

<template>
  <PCalendar range v-model="value" />
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
import { CalendarDate } from '@internationalized/date'

const value = shallowRef({
  start: new CalendarDate(2022, 2, 3),
  end: new CalendarDate(2022, 2, 20)
})
</script>

<template>
  <PCalendar range v-model="value" />
</template>

The range prop also works with type="month" and type="year", letting you select a range of months or years.

Month picker, 2022
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Sep
Oct
Nov
Dec
<script setup lang="ts">
import { CalendarDate } from '@internationalized/date'

const value = shallowRef({ start: new CalendarDate(2022, 2, 1), end: new CalendarDate(2022, 6, 1) })
</script>

<template>
  <PCalendar type="month" range v-model="value" />
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
import { CalendarDate } from '@internationalized/date'

const value = shallowRef({ start: new CalendarDate(2022, 2, 1), end: new CalendarDate(2022, 6, 1) })
</script>

<template>
  <PCalendar type="month" range v-model="value" />
</template>

Number Of Months

Use the numberOfMonths prop to change the number of months in the calendar.

27
28
29
30
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
6
7
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
1
2
3
4
5
6
7
8
9
10
11
12
29
30
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
6
7
8
9
Event date, October - December 2026
<template>
  <PCalendar :number-of-months="3" />
</template>

Month Controls

Use the month-controls prop to show the month controls. Defaults to true.

27
28
29
30
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
6
7
Event date, October 2026
<template>
  <PCalendar />
</template>

Use the prev-month and next-month props to override the month buttons.

27
28
29
30
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
6
7
Event date, October 2026
<template>
  <PCalendar
    :prev-month="{
      color: 'primary',
      variant: 'soft'
    }"
    :next-month="{
      color: 'primary',
      variant: 'soft'
    }"
  />
</template>

Year Controls

Use the year-controls prop to show the year controls. Defaults to true.

27
28
29
30
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
6
7
Event date, October 2026
<template>
  <PCalendar />
</template>

Use the prev-year and next-year props to override the year buttons.

27
28
29
30
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
6
7
Event date, October 2026
<template>
  <PCalendar
    :prev-year="{
      color: 'primary',
      variant: 'soft'
    }"
    :next-year="{
      color: 'primary',
      variant: 'soft'
    }"
  />
</template>

View Control

Use the view-control prop to make the heading a button that switches between the day, month and year views. Defaults to true.

October 2026
27
28
29
30
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
6
7
Event date, October 2026
<template>
  <PCalendar />
</template>

Set the view-control prop to an object to override the heading button.

27
28
29
30
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
6
7
Event date, October 2026
<template>
  <PCalendar
    :view-control="{
      color: 'primary',
      variant: 'soft'
    }"
  />
</template>

Years Per Page

Use the years-per-page prop to change the number of years displayed in the year view. Defaults to 12.

2020 - 2039
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
Year picker, 2020 - 2039
<template>
  <PCalendar type="year" :years-per-page="20" />
</template>

Fixed Weeks

Use the fixed-weeks prop to display the calendar with fixed weeks.

27
28
29
30
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Event date, October 2026
<template>
  <PCalendar />
</template>

Week Numbers

Use the week-numbers prop to display week numbers in the calendar.

40
27
28
29
30
1
2
3
41
4
5
6
7
8
9
10
42
11
12
13
14
15
16
17
43
18
19
20
21
22
23
24
44
25
26
27
28
29
30
31
45
1
2
3
4
5
6
7
Event date, October 2026
<template>
  <PCalendar week-numbers fixed-weeks />
</template>

Color

Use the color prop to change the color of the calendar.

Event date, February 2022
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
1
2
3
4
5
6
7
8
9
10
11
12
<template>
  <PCalendar color="neutral" />
</template>

Variant

Use the variant prop to change the variant of the calendar.

Event date, February 2022
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
1
2
3
4
5
6
7
8
9
10
11
12
<template>
  <PCalendar variant="subtle" />
</template>

Size

Use the size prop to change the size of the calendar.

27
28
29
30
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
6
7
Event date, October 2026
<template>
  <PCalendar size="xl" />
</template>

Disabled

Use the disabled prop to disable the calendar.

27
28
29
30
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
6
7
Event date, October 2026
<template>
  <PCalendar disabled />
</template>

Examples

With chip events

Use the Chip component to add events to specific days.

26
27
28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
Event date, January 2022
<script setup lang="ts">
import { CalendarDate } from '@internationalized/date';

const modelValue = shallowRef(new CalendarDate(2022, 1, 10));

function getColorByDate(date: Date) {
  const isWeekend = date.getDay() % 6 == 0;
  const isDayMeeting = date.getDay() % 3 == 0;

  if (isWeekend) {
    return undefined;
  }

  if (isDayMeeting) {
    return 'error';
  }

  return 'success';
}
</script>

<template>
  <PCalendar v-model="modelValue">
    <template #day="{ day }">
      <PChip
        :show="!!getColorByDate(day.toDate('UTC'))"
        :color="getColorByDate(day.toDate('UTC'))"
        size="2xs"
      >
        {{ day.day }}
      </PChip>
    </template>
  </PCalendar>
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
import { CalendarDate } from '@internationalized/date';

const modelValue = shallowRef(new CalendarDate(2022, 1, 10));

function getColorByDate(date: Date) {
  const isWeekend = date.getDay() % 6 == 0;
  const isDayMeeting = date.getDay() % 3 == 0;

  if (isWeekend) {
    return undefined;
  }

  if (isDayMeeting) {
    return 'error';
  }

  return 'success';
}
</script>

<template>
  <PCalendar v-model="modelValue">
    <template #day="{ day }">
      <PChip
        :show="!!getColorByDate(day.toDate('UTC'))"
        :color="getColorByDate(day.toDate('UTC'))"
        size="2xs"
      >
        {{ day.day }}
      </PChip>
    </template>
  </PCalendar>
</template>

With disabled dates

Use the is-date-disabled prop with a function to mark specific dates as disabled. When using type="month" or type="year", use the is-month-disabled or is-year-disabled prop instead.

Event date, January 2022
26
27
28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
<script setup lang="ts">
import type { DateValue } from '@internationalized/date';
import { CalendarDate } from '@internationalized/date';

const modelValue = shallowRef({
  start: new CalendarDate(2022, 1, 1),
  end: new CalendarDate(2022, 1, 9),
});

function isDateDisabled(date: DateValue) {
  return date.day >= 10 && date.day <= 16;
}
</script>

<template>
  <PCalendar
    v-model="modelValue"
    :is-date-disabled="isDateDisabled"
    range
  />
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
import type { DateValue } from '@internationalized/date';
import { CalendarDate } from '@internationalized/date';

const modelValue = shallowRef({
  start: new CalendarDate(2022, 1, 1),
  end: new CalendarDate(2022, 1, 9),
});

function isDateDisabled(date: DateValue) {
  return date.day >= 10 && date.day <= 16;
}
</script>

<template>
  <PCalendar
    v-model="modelValue"
    :is-date-disabled="isDateDisabled"
    range
  />
</template>

With unavailable dates

Use the is-date-unavailable prop with a function to mark specific dates as unavailable. When using type="month" or type="year", use the is-month-unavailable or is-year-unavailable prop instead.

Event date, January 2022
26
27
28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
<script setup lang="ts">
import type { DateValue } from '@internationalized/date';
import { CalendarDate } from '@internationalized/date';

const modelValue = shallowRef({
  start: new CalendarDate(2022, 1, 1),
  end: new CalendarDate(2022, 1, 9),
});

function isDateUnavailable(date: DateValue) {
  return date.day >= 10 && date.day <= 16;
}
</script>

<template>
  <PCalendar
    v-model="modelValue"
    :is-date-unavailable="isDateUnavailable"
    range
  />
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
import type { DateValue } from '@internationalized/date';
import { CalendarDate } from '@internationalized/date';

const modelValue = shallowRef({
  start: new CalendarDate(2022, 1, 1),
  end: new CalendarDate(2022, 1, 9),
});

function isDateUnavailable(date: DateValue) {
  return date.day >= 10 && date.day <= 16;
}
</script>

<template>
  <PCalendar
    v-model="modelValue"
    :is-date-unavailable="isDateUnavailable"
    range
  />
</template>

With min/max dates

Use the min-value and max-value props to limit the dates.

27
28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
1
2
3
4
5
6
7
Event date, September 2023
<script setup lang="ts">
import { CalendarDate } from '@internationalized/date';

const modelValue = shallowRef(new CalendarDate(2023, 9, 10));
const minDate = new CalendarDate(2023, 9, 1);
const maxDate = new CalendarDate(2023, 9, 30);
</script>

<template>
  <PCalendar
    v-model="modelValue"
    :min-value="minDate"
    :max-value="maxDate"
  />
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
import { CalendarDate } from '@internationalized/date';

const modelValue = shallowRef(new CalendarDate(2023, 9, 10));
const minDate = new CalendarDate(2023, 9, 1);
const maxDate = new CalendarDate(2023, 9, 30);
</script>

<template>
  <PCalendar
    v-model="modelValue"
    :min-value="minDate"
    :max-value="maxDate"
  />
</template>

With other calendar systems

You can use other calenders from @internationalized/date to implement a different calendar system.

24
25
26
27
28
29
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
1
2
3
4
5
6
Event date, Tishri 5781
<script lang="ts" setup>
import { CalendarDate, HebrewCalendar } from '@internationalized/date';

const hebrewDate = shallowRef(new CalendarDate(new HebrewCalendar(), 5781, 1, 1));
</script>

<template>
  <PCalendar v-model="hebrewDate" />
</template>
You can check all the available calendars on @internationalized/date docs.

With external controls

You can control the calendar with external controls by manipulating the date passed in the v-model.

30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
1
2
3
4
5
6
7
8
9
10
Event date, April 2025
<script setup lang="ts">
import { CalendarDate } from '@internationalized/date';

const date = shallowRef(new CalendarDate(2025, 4, 2));
</script>

<template>
  <div class="flex flex-col gap-4">
    <PCalendar
      v-model="date"
      :month-controls="false"
      :year-controls="false"
    />

    <div class="flex gap-4 justify-between">
      <PButton
        color="neutral"
        variant="outline"
        @click="date = date.subtract({ months: 1 })"
      >
        Prev
      </PButton>

      <PButton
        color="neutral"
        variant="outline"
        @click="date = date.add({ months: 1 })"
      >
        Next
      </PButton>
    </div>
  </div>
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
import { CalendarDate } from '@internationalized/date';

const date = shallowRef(new CalendarDate(2025, 4, 2));
</script>

<template>
  <div class="flex flex-col gap-4">
    <PCalendar
      v-model="date"
      :month-controls="false"
      :year-controls="false"
    />

    <div class="flex gap-4 justify-between">
      <PButton
        color="neutral"
        variant="outline"
        @click="date = date.subtract({ months: 1 })"
      >
        Prev
      </PButton>

      <PButton
        color="neutral"
        variant="outline"
        @click="date = date.add({ months: 1 })"
      >
        Next
      </PButton>
    </div>
  </div>
</template>

With today's date

Use the today function from @internationalized/date with getLocalTimeZone to set the value to the current date.

27
28
29
30
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
6
7
Event date, October 2026
<script setup lang="ts">
import { getLocalTimeZone, today } from '@internationalized/date';

const date = shallowRef(today(getLocalTimeZone()));
</script>

<template>
  <div class="flex flex-col gap-4">
    <PCalendar v-model="date" />

    <PButton
      color="neutral"
      variant="outline"
      class="justify-center"
      @click="date = today(getLocalTimeZone())"
    >
      Today
    </PButton>
  </div>
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
import { getLocalTimeZone, today } from '@internationalized/date';

const date = shallowRef(today(getLocalTimeZone()));
</script>

<template>
  <div class="flex flex-col gap-4">
    <PCalendar v-model="date" />

    <PButton
      color="neutral"
      variant="outline"
      class="justify-center"
      @click="date = today(getLocalTimeZone())"
    >
      Today
    </PButton>
  </div>
</template>

As a date picker

Use a Button and a Popover component to create a date picker.

<script setup lang="ts">
import { CalendarDate, DateFormatter, getLocalTimeZone } from '@internationalized/date';

const df = new DateFormatter('en-US', {
  dateStyle: 'medium',
});

const modelValue = shallowRef(new CalendarDate(2022, 1, 10));
</script>

<template>
  <PPopover>
    <PButton
      color="neutral"
      variant="subtle"
      icon="i-lucide-calendar"
    >
      {{ modelValue ? df.format(modelValue.toDate(getLocalTimeZone())) : 'Select a date' }}
    </PButton>

    <template #content>
      <PCalendar
        v-model="modelValue"
        class="p-2"
      />
    </template>
  </PPopover>
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
import { CalendarDate, DateFormatter, getLocalTimeZone } from '@internationalized/date';

const df = new DateFormatter('en-US', {
  dateStyle: 'medium',
});

const modelValue = shallowRef(new CalendarDate(2022, 1, 10));
</script>

<template>
  <PPopover>
    <PButton
      color="neutral"
      variant="subtle"
      icon="i-lucide-calendar"
    >
      {{ modelValue ? df.format(modelValue.toDate(getLocalTimeZone())) : 'Select a date' }}
    </PButton>

    <template #content>
      <PCalendar
        v-model="modelValue"
        class="p-2"
      />
    </template>
  </PPopover>
</template>

As a date range picker

Use a Button and a Popover component to create a date range picker with preset ranges.

<script setup lang="ts">
import { DateFormatter, getLocalTimeZone, today } from '@internationalized/date';
import { useBreakpoints } from '@vueuse/core';

const df = new DateFormatter('en-US', { dateStyle: 'medium' });
const tz = getLocalTimeZone();
const breakpoints = useBreakpoints({ 'sm': 640, 'md': 768, 'lg': 1024, 'xl': 1280, '2xl': 1536 });
const isDesktop = breakpoints.greaterOrEqual('sm');

const ranges = [
  { label: 'Last 7 days', days: 7 },
  { label: 'Last 14 days', days: 14 },
  { label: 'Last 30 days', days: 30 },
  { label: 'Last 3 months', months: 3 },
  { label: 'Last 6 months', months: 6 },
  { label: 'Last year', years: 1 },
];

const initialEnd = today(tz);
const modelValue = shallowRef({
  start: initialEnd.subtract({ days: 14 }),
  end: initialEnd,
});

const label = computed(() => {
  const { start, end } = modelValue.value;
  if (!start) {
    return 'Pick a date';
  }
  if (!end) {
    return df.format(start.toDate(tz));
  }
  return `${df.format(start.toDate(tz))} - ${df.format(end.toDate(tz))}`;
});

function computeStart(range: typeof ranges[number]) {
  const end = today(tz);
  return { start: end.subtract({ days: range.days, months: range.months, years: range.years }), end };
}

function isRangeSelected(range: typeof ranges[number]) {
  if (!modelValue.value?.start || !modelValue.value?.end) {
    return false;
  }
  const { start, end } = computeStart(range);
  return modelValue.value.start.compare(start) === 0 && modelValue.value.end.compare(end) === 0;
}

function selectRange(range: typeof ranges[number]) {
  modelValue.value = computeStart(range);
}
</script>

<template>
  <PPopover :content="{ align: 'center' }">
    <PButton
      color="neutral"
      variant="subtle"
      icon="i-lucide-calendar"
    >
      {{ label }}
    </PButton>

    <template #content>
      <div class="divide---ui-color-border flex items-stretch divide-(x)">
        <div class="py-2 flex-col hidden justify-center sm:flex">
          <PButton
            v-for="(range, index) in ranges"
            :key="index"
            :label="range.label"
            color="neutral"
            variant="ghost"
            class="px-4 rounded-none"
            :class="[isRangeSelected(range) ? 'bg-background-elevated' : 'hover:bg-background-elevated/50']"
            truncate
            @click="selectRange(range)"
          />
        </div>

        <PCalendar
          v-model="modelValue"
          class="p-2"
          :number-of-months="isDesktop ? 2 : 1"
          range
        />
      </div>
    </template>
  </PPopover>
</template>
<script setup lang="ts">
import { shallowRef, computed } from 'vue'
import { DateFormatter, getLocalTimeZone, today } from '@internationalized/date';
import { useBreakpoints } from '@vueuse/core';

const df = new DateFormatter('en-US', { dateStyle: 'medium' });
const tz = getLocalTimeZone();
const breakpoints = useBreakpoints({ 'sm': 640, 'md': 768, 'lg': 1024, 'xl': 1280, '2xl': 1536 });
const isDesktop = breakpoints.greaterOrEqual('sm');

const ranges = [
  { label: 'Last 7 days', days: 7 },
  { label: 'Last 14 days', days: 14 },
  { label: 'Last 30 days', days: 30 },
  { label: 'Last 3 months', months: 3 },
  { label: 'Last 6 months', months: 6 },
  { label: 'Last year', years: 1 },
];

const initialEnd = today(tz);
const modelValue = shallowRef({
  start: initialEnd.subtract({ days: 14 }),
  end: initialEnd,
});

const label = computed(() => {
  const { start, end } = modelValue.value;
  if (!start) {
    return 'Pick a date';
  }
  if (!end) {
    return df.format(start.toDate(tz));
  }
  return `${df.format(start.toDate(tz))} - ${df.format(end.toDate(tz))}`;
});

function computeStart(range: typeof ranges[number]) {
  const end = today(tz);
  return { start: end.subtract({ days: range.days, months: range.months, years: range.years }), end };
}

function isRangeSelected(range: typeof ranges[number]) {
  if (!modelValue.value?.start || !modelValue.value?.end) {
    return false;
  }
  const { start, end } = computeStart(range);
  return modelValue.value.start.compare(start) === 0 && modelValue.value.end.compare(end) === 0;
}

function selectRange(range: typeof ranges[number]) {
  modelValue.value = computeStart(range);
}
</script>

<template>
  <PPopover :content="{ align: 'center' }">
    <PButton
      color="neutral"
      variant="subtle"
      icon="i-lucide-calendar"
    >
      {{ label }}
    </PButton>

    <template #content>
      <div class="divide---ui-color-border flex items-stretch divide-(x)">
        <div class="py-2 flex-col hidden justify-center sm:flex">
          <PButton
            v-for="(range, index) in ranges"
            :key="index"
            :label="range.label"
            color="neutral"
            variant="ghost"
            class="px-4 rounded-none"
            :class="[isRangeSelected(range) ? 'bg-background-elevated' : 'hover:bg-background-elevated/50']"
            truncate
            @click="selectRange(range)"
          />
        </div>

        <PCalendar
          v-model="modelValue"
          class="p-2"
          :number-of-months="isDesktop ? 2 : 1"
          range
        />
      </div>
    </template>
  </PPopover>
</template>

API

Props

Prop Default Type

Slots

Slot Type

Emits

Event Type

Theme

app.config.ts
export default defineAppConfig({
  ui: {
    calendar: {
      slots: {
        root: '',
        header: 'flex items-center justify-between',
        body: 'pt-4 flex flex-col space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0',
        heading: 'flex-1 min-w-0 text-center',
        headingLabel: 'font-500 block truncate p-1.5',
        grid: 'w-full border-collapse select-none space-y-1 focus:outline-none',
        gridRow: 'grid',
        gridWeekDaysRow: 'mb-1 grid w-full grid-cols-7',
        gridBody: 'grid',
        headCell: 'rounded-md',
        headCellWeek: 'rounded-md color-text-muted',
        cell: 'relative text-center',
        cellTrigger: [
          'data-[disabled]:color-text-muted data-[unavailable]:color-text-muted m-0.5 flex whitespace-nowrap items-center justify-center relative data-[today]:font-600 focus-visible:outline-3 data-[unavailable]:line-through data-[unavailable]:pointer-events-none',
          'transition'
        ],
        cellWeek: 'relative text-center color-text-muted'
      },
      variants: {
        color: {
          primary: {
            headCell: 'color-primary',
            cellTrigger: 'outline-primary/25'
          },
          secondary: {
            headCell: 'color-secondary',
            cellTrigger: 'outline-secondary/25'
          },
          success: {
            headCell: 'color-success',
            cellTrigger: 'outline-success/25'
          },
          info: {
            headCell: 'color-info',
            cellTrigger: 'outline-info/25'
          },
          warning: {
            headCell: 'color-warning',
            cellTrigger: 'outline-warning/25'
          },
          error: {
            headCell: 'color-error',
            cellTrigger: 'outline-error/25'
          },
          neutral: {
            headCell: 'color-text-highlighted',
            cellTrigger: 'outline-border-inverted/25'
          }
        },
        variant: {
          solid: '',
          outline: '',
          soft: '',
          subtle: ''
        },
        size: {
          xs: {
            headingLabel: 'text-xs',
            cell: 'text-xs',
            cellWeek: 'text-xs',
            headCell: 'text-[10px]',
            headCellWeek: 'text-[10px]',
            body: 'space-y-2 pt-2'
          },
          sm: {
            headingLabel: 'text-xs',
            headCell: 'text-xs',
            headCellWeek: 'text-xs',
            cellWeek: 'text-xs',
            cell: 'text-xs'
          },
          md: {
            headingLabel: 'text-sm',
            headCell: 'text-xs',
            headCellWeek: 'text-xs',
            cellWeek: 'text-xs',
            cell: 'text-sm'
          },
          lg: {
            headingLabel: 'text-base',
            headCell: 'text-base',
            headCellWeek: 'text-base',
            cellWeek: 'text-base',
            cell: 'text-base'
          },
          xl: {
            headingLabel: 'text-lg',
            headCell: 'text-lg',
            headCellWeek: 'text-lg',
            cellWeek: 'text-lg',
            cell: 'text-lg'
          }
        },
        view: {
          day: {
            gridRow: 'grid-cols-7 place-items-center',
            cellTrigger: 'data-[outside-view]:color-text-muted rounded-full'
          },
          month: {
            gridRow: 'grid-cols-4',
            cellTrigger: 'rounded-md'
          },
          year: {
            gridRow: 'grid-cols-4',
            cellTrigger: 'rounded-md'
          }
        },
        weekNumbers: {
          true: ''
        }
      },
      compoundVariants: [
        {
          color: 'primary',
          variant: 'solid',
          class: {
            cellTrigger: 'data-[selected]:bg-primary data-[selected]:color-primary-foreground data-[today]:not-data-[selected]:color-primary data-[highlighted]:bg-primary/20 hover:not-data-[selected]:bg-primary/20'
          }
        },
        {
          color: 'primary',
          variant: 'outline',
          class: {
            cellTrigger: 'data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-primary/50 data-[selected]:color-primary data-[today]:not-data-[selected]:color-primary data-[highlighted]:bg-primary/10 hover:not-data-[selected]:bg-primary/10'
          }
        },
        {
          color: 'primary',
          variant: 'soft',
          class: {
            cellTrigger: 'data-[selected]:bg-primary/10 data-[selected]:color-primary data-[today]:not-data-[selected]:color-primary data-[highlighted]:bg-primary/20 hover:not-data-[selected]:bg-primary/20'
          }
        },
        {
          color: 'primary',
          variant: 'subtle',
          class: {
            cellTrigger: 'data-[selected]:bg-primary/10 data-[selected]:color-primary data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-primary/25 data-[today]:not-data-[selected]:color-primary data-[highlighted]:bg-primary/20 hover:not-data-[selected]:bg-primary/20'
          }
        },
        {
          color: 'neutral',
          variant: 'solid',
          class: {
            cellTrigger: 'data-[selected]:bg-background-inverted data-[selected]:color-text-inverted data-[today]:not-data-[selected]:color-text-highlighted data-[highlighted]:bg-background-inverted/20 hover:not-data-[selected]:bg-background-inverted/10'
          }
        },
        {
          color: 'neutral',
          variant: 'outline',
          class: {
            cellTrigger: 'data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-border-accented data-[selected]:color-text data-[selected]:bg-background data-[today]:not-data-[selected]:color-text-highlighted data-[highlighted]:bg-background-inverted/10 hover:not-data-[selected]:bg-background-inverted/10'
          }
        },
        {
          color: 'neutral',
          variant: 'soft',
          class: {
            cellTrigger: 'data-[selected]:bg-background-elevated data-[selected]:color-text data-[today]:not-data-[selected]:color-text-highlighted data-[highlighted]:bg-background-inverted/20 hover:not-data-[selected]:bg-background-inverted/10'
          }
        },
        {
          color: 'neutral',
          variant: 'subtle',
          class: {
            cellTrigger: 'data-[selected]:bg-background-elevated data-[selected]:color-text data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-border-accented data-[today]:not-data-[selected]:color-text-highlighted data-[highlighted]:bg-background-inverted/20 hover:not-data-[selected]:bg-background-inverted/10'
          }
        },
        {
          size: 'xs',
          view: 'day',
          class: {
            cellTrigger: 'size-6'
          }
        },
        {
          size: 'sm',
          view: 'day',
          class: {
            cellTrigger: 'size-7'
          }
        },
        {
          size: 'md',
          view: 'day',
          class: {
            cellTrigger: 'size-8'
          }
        },
        {
          size: 'lg',
          view: 'day',
          class: {
            cellTrigger: 'size-9'
          }
        },
        {
          size: 'xl',
          view: 'day',
          class: {
            cellTrigger: 'size-10'
          }
        },
        {
          size: 'xs',
          view: [
            'month',
            'year'
          ],
          class: {
            cellTrigger: 'h-6 px-2'
          }
        },
        {
          size: 'sm',
          view: [
            'month',
            'year'
          ],
          class: {
            cellTrigger: 'h-7 px-2'
          }
        },
        {
          size: 'md',
          view: [
            'month',
            'year'
          ],
          class: {
            cellTrigger: 'h-8 px-3'
          }
        },
        {
          size: 'lg',
          view: [
            'month',
            'year'
          ],
          class: {
            cellTrigger: 'h-9 px-4'
          }
        },
        {
          size: 'xl',
          view: [
            'month',
            'year'
          ],
          class: {
            cellTrigger: 'h-10 px-5'
          }
        },
        {
          view: 'day',
          weekNumbers: true,
          class: {
            gridRow: 'grid-cols-8',
            gridWeekDaysRow: 'grid-cols-8 [&>*:first-child]:col-start-2'
          }
        }
      ],
      defaultVariants: {
        size: 'md',
        color: 'primary',
        variant: 'solid',
        view: 'day'
      }
    }
  }
})
vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from 'pohon-ui/vite'

export default defineConfig({
  plugins: [
    vue(),
    ui({
      ui: {
        calendar: {
          slots: {
            root: '',
            header: 'flex items-center justify-between',
            body: 'pt-4 flex flex-col space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0',
            heading: 'flex-1 min-w-0 text-center',
            headingLabel: 'font-500 block truncate p-1.5',
            grid: 'w-full border-collapse select-none space-y-1 focus:outline-none',
            gridRow: 'grid',
            gridWeekDaysRow: 'mb-1 grid w-full grid-cols-7',
            gridBody: 'grid',
            headCell: 'rounded-md',
            headCellWeek: 'rounded-md color-text-muted',
            cell: 'relative text-center',
            cellTrigger: [
              'data-[disabled]:color-text-muted data-[unavailable]:color-text-muted m-0.5 flex whitespace-nowrap items-center justify-center relative data-[today]:font-600 focus-visible:outline-3 data-[unavailable]:line-through data-[unavailable]:pointer-events-none',
              'transition'
            ],
            cellWeek: 'relative text-center color-text-muted'
          },
          variants: {
            color: {
              primary: {
                headCell: 'color-primary',
                cellTrigger: 'outline-primary/25'
              },
              secondary: {
                headCell: 'color-secondary',
                cellTrigger: 'outline-secondary/25'
              },
              success: {
                headCell: 'color-success',
                cellTrigger: 'outline-success/25'
              },
              info: {
                headCell: 'color-info',
                cellTrigger: 'outline-info/25'
              },
              warning: {
                headCell: 'color-warning',
                cellTrigger: 'outline-warning/25'
              },
              error: {
                headCell: 'color-error',
                cellTrigger: 'outline-error/25'
              },
              neutral: {
                headCell: 'color-text-highlighted',
                cellTrigger: 'outline-border-inverted/25'
              }
            },
            variant: {
              solid: '',
              outline: '',
              soft: '',
              subtle: ''
            },
            size: {
              xs: {
                headingLabel: 'text-xs',
                cell: 'text-xs',
                cellWeek: 'text-xs',
                headCell: 'text-[10px]',
                headCellWeek: 'text-[10px]',
                body: 'space-y-2 pt-2'
              },
              sm: {
                headingLabel: 'text-xs',
                headCell: 'text-xs',
                headCellWeek: 'text-xs',
                cellWeek: 'text-xs',
                cell: 'text-xs'
              },
              md: {
                headingLabel: 'text-sm',
                headCell: 'text-xs',
                headCellWeek: 'text-xs',
                cellWeek: 'text-xs',
                cell: 'text-sm'
              },
              lg: {
                headingLabel: 'text-base',
                headCell: 'text-base',
                headCellWeek: 'text-base',
                cellWeek: 'text-base',
                cell: 'text-base'
              },
              xl: {
                headingLabel: 'text-lg',
                headCell: 'text-lg',
                headCellWeek: 'text-lg',
                cellWeek: 'text-lg',
                cell: 'text-lg'
              }
            },
            view: {
              day: {
                gridRow: 'grid-cols-7 place-items-center',
                cellTrigger: 'data-[outside-view]:color-text-muted rounded-full'
              },
              month: {
                gridRow: 'grid-cols-4',
                cellTrigger: 'rounded-md'
              },
              year: {
                gridRow: 'grid-cols-4',
                cellTrigger: 'rounded-md'
              }
            },
            weekNumbers: {
              true: ''
            }
          },
          compoundVariants: [
            {
              color: 'primary',
              variant: 'solid',
              class: {
                cellTrigger: 'data-[selected]:bg-primary data-[selected]:color-primary-foreground data-[today]:not-data-[selected]:color-primary data-[highlighted]:bg-primary/20 hover:not-data-[selected]:bg-primary/20'
              }
            },
            {
              color: 'primary',
              variant: 'outline',
              class: {
                cellTrigger: 'data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-primary/50 data-[selected]:color-primary data-[today]:not-data-[selected]:color-primary data-[highlighted]:bg-primary/10 hover:not-data-[selected]:bg-primary/10'
              }
            },
            {
              color: 'primary',
              variant: 'soft',
              class: {
                cellTrigger: 'data-[selected]:bg-primary/10 data-[selected]:color-primary data-[today]:not-data-[selected]:color-primary data-[highlighted]:bg-primary/20 hover:not-data-[selected]:bg-primary/20'
              }
            },
            {
              color: 'primary',
              variant: 'subtle',
              class: {
                cellTrigger: 'data-[selected]:bg-primary/10 data-[selected]:color-primary data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-primary/25 data-[today]:not-data-[selected]:color-primary data-[highlighted]:bg-primary/20 hover:not-data-[selected]:bg-primary/20'
              }
            },
            {
              color: 'neutral',
              variant: 'solid',
              class: {
                cellTrigger: 'data-[selected]:bg-background-inverted data-[selected]:color-text-inverted data-[today]:not-data-[selected]:color-text-highlighted data-[highlighted]:bg-background-inverted/20 hover:not-data-[selected]:bg-background-inverted/10'
              }
            },
            {
              color: 'neutral',
              variant: 'outline',
              class: {
                cellTrigger: 'data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-border-accented data-[selected]:color-text data-[selected]:bg-background data-[today]:not-data-[selected]:color-text-highlighted data-[highlighted]:bg-background-inverted/10 hover:not-data-[selected]:bg-background-inverted/10'
              }
            },
            {
              color: 'neutral',
              variant: 'soft',
              class: {
                cellTrigger: 'data-[selected]:bg-background-elevated data-[selected]:color-text data-[today]:not-data-[selected]:color-text-highlighted data-[highlighted]:bg-background-inverted/20 hover:not-data-[selected]:bg-background-inverted/10'
              }
            },
            {
              color: 'neutral',
              variant: 'subtle',
              class: {
                cellTrigger: 'data-[selected]:bg-background-elevated data-[selected]:color-text data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-border-accented data-[today]:not-data-[selected]:color-text-highlighted data-[highlighted]:bg-background-inverted/20 hover:not-data-[selected]:bg-background-inverted/10'
              }
            },
            {
              size: 'xs',
              view: 'day',
              class: {
                cellTrigger: 'size-6'
              }
            },
            {
              size: 'sm',
              view: 'day',
              class: {
                cellTrigger: 'size-7'
              }
            },
            {
              size: 'md',
              view: 'day',
              class: {
                cellTrigger: 'size-8'
              }
            },
            {
              size: 'lg',
              view: 'day',
              class: {
                cellTrigger: 'size-9'
              }
            },
            {
              size: 'xl',
              view: 'day',
              class: {
                cellTrigger: 'size-10'
              }
            },
            {
              size: 'xs',
              view: [
                'month',
                'year'
              ],
              class: {
                cellTrigger: 'h-6 px-2'
              }
            },
            {
              size: 'sm',
              view: [
                'month',
                'year'
              ],
              class: {
                cellTrigger: 'h-7 px-2'
              }
            },
            {
              size: 'md',
              view: [
                'month',
                'year'
              ],
              class: {
                cellTrigger: 'h-8 px-3'
              }
            },
            {
              size: 'lg',
              view: [
                'month',
                'year'
              ],
              class: {
                cellTrigger: 'h-9 px-4'
              }
            },
            {
              size: 'xl',
              view: [
                'month',
                'year'
              ],
              class: {
                cellTrigger: 'h-10 px-5'
              }
            },
            {
              view: 'day',
              weekNumbers: true,
              class: {
                gridRow: 'grid-cols-8',
                gridWeekDaysRow: 'grid-cols-8 [&>*:first-child]:col-start-2'
              }
            }
          ],
          defaultVariants: {
            size: 'md',
            color: 'primary',
            variant: 'solid',
            view: 'day'
          }
        }
      }
    })
  ]
})
Some colors in compoundVariants are omitted for readability. Check out the source code on GitHub.

Changelog

No recent changes