Installation
Install the dependencies
pnpm add cn class-variance-authority date-fns @date-fns/tz github:sveltery/baseThe calendar runs on @sveltery/day-picker, the Svelte port of React DayPicker in the Sveltery UI repository. It is not published yet, so copy packages/day-picker/src into your app as $lib/day-picker and import it from there.
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.
<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}
/><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,
)}
/>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>;
};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:
selectedandmonthare bindable, so there is no controlledselectedplusonSelectpair to keep in sync.- The
componentsprop becomes snippet props:dayButton,chevron,weekNumber,rootandfooter. Each snippet gets the attributes to spread on its element, anddayButtonandweekNumberalso get the formattedtext. localetakes a date-fns locale, such asesfromdate-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.
| Key | Moves focus to |
|---|---|
| Arrow Left and Arrow Right | The previous or next day. dir="rtl" swaps them |
| Arrow Up and Arrow Down | The same day in the previous or next week |
| Page Up and Page Down | The same day in the previous or next month |
| Shift with Page Up or Page Down | The same day in the previous or next year |
| Shift with an arrow key | The same day in the previous or next month (left and right) or year (up and down) |
| Home and End | The first or last day of the week |
| Enter and Space | Selects 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.