Calendar

A calendar component that allows users to select a date or a range of dates.

Single
October 2026
Multiple
October 2026
Week Numbers
February 2026
06
07
08
09
Booked Dates
February 2026
Range
January 2026
February 2026
Range Multiple Months
abril 2026
mayo 2026
junio 2026
With Time
October 2026
Custom Days
December 2026
With Presets
October 2026
Date Picker Simple
Date Picker with Dropdowns
Date Picker Range
In Card
October 2026
In Popover

Installation

Install the dependencies

pnpm add cn class-variance-authority date-fns @date-fns/tz github:sveltery/base

Add the Button component

The Calendar component uses the Button component. Make sure you have it in your project.

Copy the component

Copy the following files into src/lib/components/ui/calendar.

It imports the button component, so copy its folder too.

src/lib/components/ui/calendar/Calendar.svelte
<script lang="ts">
  import { DayPicker, defaultClassNames } from '@sveltery/day-picker';
  import type { ChevronSnippetProps, DayButtonSnippetProps, RootSnippetProps, WeekNumberSnippetProps } from '@sveltery/day-picker';
  import { cn } from 'cn';
  import { buttonVariants } from '../button/index.js';
  import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
  import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
  import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
  import type { CalendarProps } from './types.js';
  import CalendarDayButton from './CalendarDayButton.svelte';

  let {
    month = $bindable(),
    selected = $bindable(),
    class: classProp,
    classNames,
    showOutsideDays = true,
    captionLayout = 'label',
    buttonVariant = 'ghost',
    locale,
    formatters,
    root = calendarRoot,
    chevron = calendarChevron,
    dayButton = calendarDayButton,
    weekNumber = calendarWeekNumber,
    ...props
  }: CalendarProps = $props();
</script>

{#snippet calendarRoot({ props: { children, class: className, ...rootProps } }: RootSnippetProps)}
  <div data-slot="calendar" {...rootProps} class={cn(className)}>{@render children()}</div>
{/snippet}

{#snippet calendarChevron({ orientation, class: className }: ChevronSnippetProps)}
  {#if orientation === 'left'}
    <ChevronLeftIcon class={cn('size-4', className)} />
  {:else if orientation === 'right'}
    <ChevronRightIcon class={cn('size-4', className)} />
  {:else}
    <ChevronDownIcon class={cn('size-4', className)} />
  {/if}
{/snippet}

{#snippet calendarDayButton({ day, modifiers, props: buttonProps, text }: DayButtonSnippetProps)}
  <CalendarDayButton {day} {modifiers} {locale} {...buttonProps}>{text}</CalendarDayButton>
{/snippet}

{#snippet calendarWeekNumber({ props: cellProps, text }: WeekNumberSnippetProps)}
  <td {...cellProps}>
    <div class="flex size-(--cell-size) items-center justify-center text-center">{text}</div>
  </td>
{/snippet}

<DayPicker
  bind:month
  bind:selected={selected as never}
  {showOutsideDays}
  class={cn(
    'p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] group/calendar bg-background in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
    String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
    String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
    classProp,
  )}
  {captionLayout}
  {locale}
  formatters={{
    formatMonthDropdown: (date) => date.toLocaleString(locale?.code, { month: 'short' }),
    ...formatters,
  }}
  classNames={{
    root: cn('w-fit', defaultClassNames.root),
    months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
    month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
    nav: cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', defaultClassNames.nav),
    button_previous: cn(buttonVariants({ variant: buttonVariant }), 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50', defaultClassNames.button_previous),
    button_next: cn(buttonVariants({ variant: buttonVariant }), 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50', defaultClassNames.button_next),
    month_caption: cn('flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)', defaultClassNames.month_caption),
    dropdowns: cn('flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium', defaultClassNames.dropdowns),
    dropdown_root: cn('has-focus:border-ring border-input has-focus:ring-ring/50 border has-focus:ring-3 relative rounded-(--cell-radius)', defaultClassNames.dropdown_root),
    dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown),
    caption_label: cn(
      'font-medium select-none',
      captionLayout === 'label'
        ? 'text-sm'
        : 'h-6 pr-1 pl-1.5 flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
      defaultClassNames.caption_label,
    ),
    month_grid: cn('w-full border-collapse', defaultClassNames.month_grid),
    weekdays: cn('flex', defaultClassNames.weekdays),
    weekday: cn('flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none', defaultClassNames.weekday),
    week: cn('mt-2 flex w-full', defaultClassNames.week),
    week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
    week_number: cn('text-[0.8rem] text-muted-foreground select-none', defaultClassNames.week_number),
    day: cn(
      'group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)',
      props.showWeekNumber ? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)' : '[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)',
      defaultClassNames.day,
    ),
    range_start: cn('relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted', defaultClassNames.range_start),
    range_middle: cn('rounded-none', defaultClassNames.range_middle),
    range_end: cn('relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted', defaultClassNames.range_end),
    today: cn('rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none', defaultClassNames.today),
    outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
    disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
    hidden: cn('invisible', defaultClassNames.hidden),
    ...classNames,
  }}
  {root}
  {chevron}
  {dayButton}
  {weekNumber}
  {...props}
/>
src/lib/components/ui/calendar/CalendarDayButton.svelte
<script lang="ts">
  import { defaultClassNames } from '@sveltery/day-picker';
  import { cn } from 'cn';
  import { Button } from '../button/index.js';
  import type { CalendarDayButtonProps } from './types.js';
  let { class: classProp, day, modifiers, locale, ...props }: CalendarDayButtonProps = $props();
</script>
<Button
  variant="ghost"
  size="icon"
  data-day={day.date.toLocaleDateString(locale?.code)}
  data-selected-single={modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle}
  data-range-start={modifiers.range_start}
  data-range-end={modifiers.range_end}
  data-range-middle={modifiers.range_middle}
  {...props}
  class={cn(
    'relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70',
    defaultClassNames.day,
    classProp,
  )}
/>
src/lib/components/ui/calendar/types.ts
import type { CalendarDay, DayPickerProps, Modifiers } from '@sveltery/day-picker';
import type { Locale } from 'date-fns';
import type { ButtonProps, ButtonVariants } from '../button/index.js';

export type CalendarProps = DayPickerProps & {
  /** The variant of the previous and next month buttons. */
  buttonVariant?: ButtonVariants['variant'];
};

export type CalendarDayButtonProps = ButtonProps & {
  day: CalendarDay;
  modifiers: Modifiers;
  locale?: Partial<Locale>;
};
src/lib/components/ui/calendar/index.ts
export { default as Calendar } from './Calendar.svelte';
export { default as CalendarDayButton } from './CalendarDayButton.svelte';
export type { CalendarProps, CalendarDayButtonProps } from './types.js';
export type { DateRange, Matcher, Modifiers } from '@sveltery/day-picker';

Update the import paths

Change the imports to match your project setup.

Usage

<script lang="ts">
  import { Calendar } from '$lib/components/ui/calendar';

  let date = $state<Date | undefined>(new Date());
</script>

<Calendar mode="single" bind:selected={date} class="rounded-lg border" />

bind:selected holds a Date in single mode, a Date[] in multiple mode and a { from, to } range in range mode. bind:month holds the first displayed month. Both work without a binding too, and onSelect and onMonthChange report each change.

About

The Calendar component is built on @sveltery/day-picker, a Svelte port of React DayPicker v9. It takes the same props, with three Svelte changes:

  • selected and month are bindable, so there is no controlled selected plus onSelect pair to keep in sync.
  • The components prop becomes snippet props: dayButton, chevron, weekNumber, root and footer. Each snippet gets the attributes to spread on its element, and dayButton and weekNumber also get the formatted text.
  • locale takes a date-fns locale, such as es from date-fns/locale.

The date math runs on date-fns, and timeZone on @date-fns/tz.

Range Calendar

Use mode="range" to select a range. min and max limit its length in nights, and excludeDisabled restarts a range that would include a disabled day.

<Calendar mode="range" bind:selected={range} numberOfMonths={2} />

Month and Year Selector

Use captionLayout="dropdown" to show month and year dropdowns. dropdown-months and dropdown-years show only one of them. startMonth and endMonth set the years the dropdown lists; without them it lists the last 100 years.

Disabled and Booked Dates

disabled and hidden take a matcher or an array of matchers: a Date, an array of dates, a { from, to } range, { before }, { after }, { before, after }, { dayOfWeek } or a function that receives the date. modifiers adds named matchers, and modifiersClassNames styles them.

<Calendar
  mode="single"
  disabled={[{ dayOfWeek: [0, 6] }, { before: new Date() }]}
  modifiers={{ booked: bookedDates }}
  modifiersClassNames={{ booked: '[&>button]:line-through opacity-100' }}
/>

Custom Days

Pass a dayButton snippet to render each day. Spread props on the button so it keeps its handlers, ARIA and focus.

<Calendar mode="range" bind:selected={range}>
  {#snippet dayButton({ day, modifiers, props, text })}
    <CalendarDayButton {day} {modifiers} {...props}>
      {text}
      {#if !modifiers.outside}<span>$100</span>{/if}
    </CalendarDayButton>
  {/snippet}
</Calendar>

Custom Cell Size

Set the cell size with the --cell-size CSS variable. It can change at a breakpoint:

<Calendar mode="single" bind:selected={date} class="rounded-lg border [--cell-size:--spacing(11)] md:[--cell-size:--spacing(12)]" />

Selected Date (With TimeZone)

timeZone takes an IANA time zone and computes days in it, so a selected day does not shift when the browser is in another zone. Read the user's zone once the page runs in the browser, because the server may be in a different zone:

<script lang="ts">
  import { onMount } from 'svelte';

  let date = $state<Date | undefined>();
  let timeZone = $state<string | undefined>();
  onMount(() => (timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone));
</script>

<Calendar mode="single" bind:selected={date} {timeZone} />

Keyboard

The grid follows the WAI-ARIA date picker pattern. One day takes Tab focus: the selected day, else today, else the first day that can be focused.

KeyMoves focus to
Arrow Left and Arrow RightThe previous or next day. dir="rtl" swaps them
Arrow Up and Arrow DownThe same day in the previous or next week
Page Up and Page DownThe same day in the previous or next month
Shift with Page Up or Page DownThe same day in the previous or next year
Shift with an arrow keyThe same day in the previous or next month (left and right) or year (up and down)
Home and EndThe first or last day of the week
Enter and SpaceSelects the focused day

Focus skips disabled and hidden days and changes month when it leaves the displayed months.

Week Numbers

Use showWeekNumber to show week numbers. ISOWeek uses ISO weeks, and weekStartsOn sets the first day of the week.

RTL

Pass dir="rtl" and a right-to-left locale, such as arSA from date-fns/locale.

<Calendar mode="single" bind:selected={date} locale={arSA} dir="rtl" />

Date Picker

You can use the Calendar component to build a date picker. See the Date Picker page.

API Reference

See the React DayPicker documentation for every prop. The props it lists work the same here, except the three Svelte changes in About. animate, broadcastCalendar, numerals, the non-Gregorian calendars and the deprecated fromYear, toYear, fromMonth and toMonth are not ported.