Calendar
Date-grid primitive on bits-ui Calendar. Keyboard-first roving focus across weeks, a calm ring-on-focus hand-off on each cell, and a 4xl cell radius that softens the grid without erasing it.
Usage
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
<script lang="ts">
import { Calendar } from '$lib/components/ui/calendar';
import type { DateValue } from '@internationalized/date';
let value = $state<DateValue | undefined>(undefined);
</script>
<Calendar type="single" bind:value />Dropdown caption
Swap the heading for month + year selects. Useful when users need to leap across years without hammering the nav arrows.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
<Calendar type="single" captionLayout="dropdown" />Range
Multi-day span selection via RangeCalendar. Click a start date,
then an end date — the in-between days highlight as a connected band.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
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 |
<script lang="ts">
import { RangeCalendar } from '$lib/components/ui/range-calendar';
let value = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({
start: undefined,
end: undefined
});
</script>
<RangeCalendar bind:value class="rounded-xl border border-border" />Date of Birth Picker
Month and year dropdowns above the grid let users reach a birth year without paging through decades of months.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
<Calendar
type="single"
captionLayout="dropdown"
class="rounded-xl border border-border"
/>Date and Time Picker
Compose Calendar with a time input below the grid. The date and time
values stay independent — combine them when submitting.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
<script lang="ts">
import { Calendar } from '$lib/components/ui/calendar';
import { Input } from '$lib/components/ui/input';
import type { DateValue } from '@internationalized/date';
let dateValue = $state<DateValue | undefined>(undefined);
let timeValue = $state('12:00');
</script>
<div class="flex flex-col gap-3">
<Calendar type="single" bind:value={dateValue} class="rounded-xl border border-border" />
<div class="flex items-center gap-2 px-1">
<span class="text-sm text-muted-foreground">Time</span>
<Input type="time" bind:value={timeValue} class="w-36" />
</div>
</div>Natural Language Picker
A text input parses plain-English phrases — today, tomorrow, in 3 days, 2025-06-15 — and syncs to the calendar below. Shows the pattern; swap the regex parser for chrono-node in production.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
<script lang="ts">
import { Calendar } from '$lib/components/ui/calendar';
import { Input } from '$lib/components/ui/input';
import { today, getLocalTimeZone, CalendarDate, type DateValue } from '@internationalized/date';
let input = $state('');
let value = $state<DateValue | undefined>(undefined);
let error = $state('');
function parse(s: string): DateValue | undefined {
const t = s.trim().toLowerCase();
const base = today(getLocalTimeZone());
if (!t) return undefined;
if (t === 'today') return base;
if (t === 'tomorrow') return base.add({ days: 1 });
if (t === 'yesterday') return base.subtract({ days: 1 });
const m = t.match(/^in\s+(\d+)\s+days?$/);
if (m) return base.add({ days: parseInt(m[1]) });
const iso = t.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (iso) return new CalendarDate(parseInt(iso[1]), parseInt(iso[2]), parseInt(iso[3]));
return undefined;
}
function onInput(e: Event) {
input = (e.target as HTMLInputElement).value;
const result = parse(input);
error = input && !result ? 'Try: today · tomorrow · in 3 days · 2025-06-15' : '';
value = result;
}
</script>
<div class="flex flex-col gap-3">
<Input placeholder="today · tomorrow · in 3 days · 2025-06-15" oninput={onInput} value={input} />
{#if error}<p class="text-xs text-destructive">{error}</p>{/if}
<Calendar type="single" bind:value class="rounded-xl border border-border" />
</div>Multi-month
Pass numberOfMonths=2 to render side-by-side months — the
canonical date-range picker layout. Nav arrows page both grids together.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
<Calendar type="single" numberOfMonths={2} class="rounded-xl border border-border" />Keyboard navigation
Roving tabindex — Tab once to enter the grid, then arrow keys move day by day. Focused cell
lights up with the standard --ring outline (focus-visible
only — clicks don't show a ring).
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
- ←/→
- Previous / next day
- ↑/↓
- Previous / next week
- PgUp / PgDn
- Previous / next month
- Home / End
- Start / end of week
- Enter / Space
- Select focused day
<!-- Tab into the calendar, then:
←/→ previous / next day
↑/↓ previous / next week
PgUp / PgDn previous / next month
Home / End start / end of week
Enter / Space select focused day -->
<Calendar type="single" class="rounded-xl border border-border" />Source
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
<script lang="ts">
import { Calendar as CalendarPrimitive } from 'bits-ui';
import * as Calendar from './index.js';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import type { ButtonVariant } from '../button/button.svelte';
import { isEqualMonth, type DateValue } from '@internationalized/date';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
value = $bindable(),
placeholder = $bindable(),
class: className,
weekdayFormat = 'short',
buttonVariant = 'ghost',
captionLayout = 'label',
locale = 'en-US',
months: monthsProp,
years,
monthFormat: monthFormatProp,
yearFormat = 'numeric',
day,
disableDaysOutsideMonth = false,
...restProps
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
buttonVariant?: ButtonVariant;
captionLayout?: 'dropdown' | 'dropdown-months' | 'dropdown-years' | 'label';
months?: CalendarPrimitive.MonthSelectProps['months'];
years?: CalendarPrimitive.YearSelectProps['years'];
monthFormat?: CalendarPrimitive.MonthSelectProps['monthFormat'];
yearFormat?: CalendarPrimitive.YearSelectProps['yearFormat'];
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
} = $props();
const monthFormat = $derived.by(() => {
if (monthFormatProp) return monthFormatProp;
if (captionLayout.startsWith('dropdown')) return 'short';
return 'long';
});
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<CalendarPrimitive.Root
bind:value={value as never}
bind:ref
bind:placeholder
data-slot="calendar"
{weekdayFormat}
{disableDaysOutsideMonth}
class={cn(
'group/calendar bg-background p-3 [--cell-radius:var(--radius-lg)] [--cell-size:--spacing(8)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
className
)}
{locale}
{monthFormat}
{yearFormat}
{...restProps}
>
{#snippet children({ months, weekdays })}
<Calendar.Months>
<Calendar.Nav>
<Calendar.PrevButton variant={buttonVariant} />
<Calendar.NextButton variant={buttonVariant} />
</Calendar.Nav>
{#each months as month, monthIndex (month)}
<Calendar.Month>
<Calendar.Header>
<Calendar.Caption
{captionLayout}
months={monthsProp}
{monthFormat}
{years}
{yearFormat}
month={month.value}
bind:placeholder
{locale}
{monthIndex}
/>
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHead>
<Calendar.GridRow class="select-none">
{#each weekdays as weekday, i (i)}
<Calendar.HeadCell>
{weekday.slice(0, 2)}
</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody>
{#each month.weeks as weekDates (weekDates)}
<Calendar.GridRow class="mt-2 w-full">
{#each weekDates as date (date)}
<Calendar.Cell {date} month={month.value}>
{#if day}
{@render day({
day: date,
outsideMonth: !isEqualMonth(date, month.value)
})}
{:else}
<Calendar.Day />
{/if}
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
</Calendar.Month>
{/each}
</Calendar.Months>
{/snippet}
</CalendarPrimitive.Root>
Calendar props
Inherits bits-ui Calendar.Root props via spread. Subcomponents (Months, Nav, Grid, Cell, Day, HeadCell, MonthSelect, YearSelect) are composed internally; override by passing a `day` snippet.
| Prop | Type | Default | Description |
|---|---|---|---|
'single' | 'multiple' | — | Required. `single` accepts one date at a time; `multiple` accepts an array. Narrows the `value` type. | |
DateValue | DateValue[] | undefined (bindable) | — | The selected date(s). `DateValue` when type is `single`, `DateValue[]` when `multiple`. | |
DateValue (bindable) | — | The visible month anchor. Controls which month renders when `value` is empty. | |
'label' | 'dropdown' | 'dropdown-months' | 'dropdown-years' | 'label' | Heading style. `dropdown` swaps both month and year for selects; the others mix one label with one dropdown. | |
ButtonVariant | 'ghost' | Variant applied to the Prev/Next nav buttons. | |
string | 'en-US' | BCP-47 tag used for weekday names, month labels, and number formatting. | |
'narrow' | 'short' | 'long' | 'short' | Intl.DateTimeFormat option for the weekday header row. | |
boolean | false | When true, days drawn from adjacent months are rendered but not interactive. | |
Snippet<[{ day: DateValue; outsideMonth: boolean }]> | — | Optional render override for a single cell. Falls back to `Calendar.Day`. | |
string | — | Merged onto the root container via tailwind-merge. |