Accessible date grid with controlled selection and month navigation
SuMoTuWeThFrSa
import { Calendar } from '@/components/ui/calendar'
...import { Calendar } from '@/components/ui/calendar'
export default function CalendarDemoExample() {
let date = $state(new Date())
return (
<Calendar
mode="single"
selected={() => date}
onSelect={next => { if (next instanceof Date) date = next }}
captionLayout="dropdown"
class="rounded-lg border"
/>
)
}
Installation
npx @fictjs/shadcn@latest add calendarCopy the Fict shadcn component into your project.
Source
components/ui/calendar.tsx
src/components/ui/calendar.tsx
import { createContext, useContext } from 'fict'
import { createSignal } from 'fict/advanced'
import { cn } from '@/lib/cn'
type DateLike = Date | string | null | undefined
type MaybeAccessor<T> = T | (() => T)
export type CalendarDateRange = { from?: Date; to?: Date }
type CalendarSelection = Date | CalendarDateRange | null
type CalendarDayModifiers = {
selected: boolean
rangeStart: boolean
rangeMiddle: boolean
rangeEnd: boolean
outside: boolean
disabled: boolean
}
type CalendarProps = {
class?: string
value?: MaybeAccessor<DateLike>
defaultValue?: DateLike
onValueChange?: (value: Date | null) => void
mode?: 'single' | 'range'
selected?: MaybeAccessor<DateLike | CalendarDateRange>
defaultSelected?: DateLike | CalendarDateRange
onSelect?: (value: Date | CalendarDateRange | undefined) => void
month?: MaybeAccessor<DateLike>
defaultMonth?: DateLike
onMonthChange?: (month: Date) => void
locale?: MaybeAccessor<string>
weekStartsOn?: MaybeAccessor<number>
showOutsideDays?: MaybeAccessor<boolean>
numberOfMonths?: number
captionLayout?: 'label' | 'dropdown'
showWeekNumber?: boolean
fixedWeeks?: boolean
disabled?: ((date: Date) => boolean) | Date[]
dayContent?: (date: Date, modifiers: CalendarDayModifiers) => unknown
children?: unknown
[key: string]: unknown
}
type CalendarContextValue = {
selection: () => CalendarSelection
setSelection: (value: Date) => void
mode: 'single' | 'range'
month: () => Date
setMonth: (month: Date) => void
locale: () => string
weekStartsOn: () => number
showOutsideDays: () => boolean
showWeekNumber: boolean
captionLayout: 'label' | 'dropdown'
disabled: (date: Date) => boolean
dayContent?: (date: Date, modifiers: CalendarDayModifiers) => unknown
}
type GenericProps = {
class?: string
children?: unknown
[key: string]: unknown
}
type CalendarGridProps = GenericProps & {
showOutsideDays?: MaybeAccessor<boolean>
onDaySelect?: (day: Date, event: MouseEvent) => void
monthOffset?: number
}
const CalendarContext = createContext<CalendarContextValue | null>(null)
function read<T>(value: MaybeAccessor<T> | undefined, fallback: T): T {
if (typeof value === 'function') return (value as () => T)()
return value ?? fallback
}
function toDate(value: DateLike): Date | null {
if (value === null || value === undefined) return null
const date = value instanceof Date ? new Date(value.getTime()) : new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
function toSelection(value: DateLike | CalendarDateRange): CalendarSelection {
if (value && typeof value === 'object' && !(value instanceof Date) && ('from' in value || 'to' in value)) {
const from = toDate(value.from)
const to = toDate(value.to)
return { from: from ? normalizeDate(from) : undefined, to: to ? normalizeDate(to) : undefined }
}
const date = toDate(value as DateLike)
return date ? normalizeDate(date) : null
}
function normalizeDate(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
function normalizeMonth(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), 1)
}
function isSameDay(left: Date | null, right: Date): boolean {
return Boolean(
left &&
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate(),
)
}
function dateTime(date: Date): number {
return normalizeDate(date).getTime()
}
function isDateDisabled(disabled: CalendarProps['disabled'], date: Date): boolean {
if (typeof disabled === 'function') return disabled(date)
return disabled?.some(candidate => isSameDay(candidate, date)) ?? false
}
function selectionModifiers(selection: CalendarSelection, day: Date, outside: boolean, disabled: boolean): CalendarDayModifiers {
if (selection instanceof Date || selection === null) {
const selected = isSameDay(selection, day)
return { selected, rangeStart: false, rangeMiddle: false, rangeEnd: false, outside, disabled }
}
const from = selection.from ? normalizeDate(selection.from) : undefined
const to = selection.to ? normalizeDate(selection.to) : undefined
const time = dateTime(day)
const rangeStart = Boolean(from && isSameDay(from, day))
const rangeEnd = Boolean(to && isSameDay(to, day))
const rangeMiddle = Boolean(from && to && time > dateTime(from) && time < dateTime(to))
return { selected: rangeStart || rangeMiddle || rangeEnd, rangeStart, rangeMiddle, rangeEnd, outside, disabled }
}
function isSameMonth(left: Date, right: Date): boolean {
return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth()
}
function addMonths(month: Date, offset: number): Date {
return new Date(month.getFullYear(), month.getMonth() + offset, 1)
}
function visibleDays(month: Date, weekStartsOn: number): Date[] {
const offset = (month.getDay() - weekStartsOn + 7) % 7
const start = new Date(month.getFullYear(), month.getMonth(), 1 - offset)
return Array.from({ length: 42 }, (_, index) =>
normalizeDate(new Date(start.getFullYear(), start.getMonth(), start.getDate() + index)),
)
}
function weekNumber(date: Date): number {
const firstDay = new Date(date.getFullYear(), 0, 1)
const elapsedDays = Math.floor((normalizeDate(date).getTime() - normalizeDate(firstDay).getTime()) / 86400000)
return Math.ceil((elapsedDays + firstDay.getDay() + 1) / 7)
}
function useCalendar(): CalendarContextValue {
const context = useContext(CalendarContext)
if (!context) throw new Error('Calendar parts must be used inside Calendar')
return context
}
export function Calendar(props: CalendarProps) {
const mode = props.mode ?? 'single'
const initialSelection = toSelection(props.defaultSelected ?? props.defaultValue)
const internalSelection = createSignal<CalendarSelection>(initialSelection)
const internalMonth = createSignal(
normalizeMonth(
toDate(props.defaultMonth) ??
toDate(read(props.month, null)) ??
(initialSelection instanceof Date ? initialSelection : initialSelection?.from) ??
new Date(),
),
)
const currentSelection = () => {
if (props.selected !== undefined) return toSelection(read(props.selected, null))
if (props.value !== undefined) return toSelection(read(props.value, null))
return internalSelection()
}
const currentMonth = () => {
const controlled = props.month === undefined ? null : toDate(read(props.month, null))
return props.month === undefined ? internalMonth() : normalizeMonth(controlled ?? internalMonth())
}
const contextValue: CalendarContextValue = {
selection: currentSelection,
setSelection: value => {
const next = normalizeDate(value)
if (mode === 'range') {
const current = currentSelection()
const range = current && !(current instanceof Date) ? current : {}
const nextRange = !range.from || range.to
? { from: next, to: undefined }
: dateTime(next) < dateTime(range.from)
? { from: next, to: range.from }
: { from: range.from, to: next }
if (props.selected === undefined && props.value === undefined) internalSelection(nextRange)
props.onSelect?.(nextRange)
} else {
if (props.selected === undefined && props.value === undefined) internalSelection(next)
props.onValueChange?.(next)
props.onSelect?.(next)
}
},
mode,
month: currentMonth,
setMonth: value => {
const next = normalizeMonth(value)
if (props.month === undefined) internalMonth(next)
props.onMonthChange?.(next)
},
locale: () => read(props.locale, 'en-US'),
weekStartsOn: () => Math.min(6, Math.max(0, Math.floor(read(props.weekStartsOn, 0)))),
showOutsideDays: () => read(props.showOutsideDays, true),
showWeekNumber: props.showWeekNumber ?? false,
captionLayout: props.captionLayout ?? 'label',
disabled: date => isDateDisabled(props.disabled, date),
dayContent: props.dayContent,
}
const {
class: className,
children,
value,
defaultValue,
onValueChange,
mode: _mode,
selected,
defaultSelected,
onSelect,
month,
defaultMonth,
onMonthChange,
locale,
weekStartsOn,
showOutsideDays,
numberOfMonths = 1,
captionLayout,
showWeekNumber,
fixedWeeks,
disabled,
dayContent,
...rest
} = props
return (
<CalendarContext.Provider value={contextValue}>
<div data-slot='calendar' class={cn('rounded-lg border p-3', className)} {...rest}>
{children ?? (
<div data-slot='calendar-months' class='flex flex-col gap-4 md:flex-row'>
{Array.from({ length: Math.max(1, numberOfMonths) }, (_, monthOffset) => (
<section data-slot='calendar-month'>
<CalendarHeader>
{monthOffset === 0 ? <CalendarPrevButton>Previous month</CalendarPrevButton> : <span />}
<CalendarTitle monthOffset={monthOffset} />
{monthOffset === Math.max(1, numberOfMonths) - 1 ? <CalendarNextButton>Next month</CalendarNextButton> : <span />}
</CalendarHeader>
<CalendarGrid monthOffset={monthOffset} />
</section>
))}
</div>
)}
</div>
</CalendarContext.Provider>
)
}
export function CalendarHeader(props: GenericProps) {
const { class: className, ...rest } = props
return <div data-slot='calendar-header' class={cn('mb-3 flex items-center justify-between gap-2', className)} {...rest} />
}
export function CalendarTitle(props: GenericProps) {
const context = useCalendar()
const { class: className, children, monthOffset = 0, ...rest } = props
const visibleMonth = () => addMonths(context.month(), Number(monthOffset))
if (context.captionLayout === 'dropdown') {
return () => {
const month = visibleMonth()
return (
<span data-slot='calendar-title' class={cn('flex items-center gap-1 text-sm font-medium', className)} {...rest}>
<select
aria-label='Choose the Month'
value={String(month.getMonth())}
onChange={(event: Event) => context.setMonth(new Date(month.getFullYear(), Number((event.currentTarget as HTMLSelectElement).value) - Number(monthOffset), 1))}
>
{Array.from({ length: 12 }, (_, index) => <option value={String(index)}>{new Intl.DateTimeFormat(context.locale(), { month: 'short' }).format(new Date(2026, index, 1))}</option>)}
</select>
<select
aria-label='Choose the Year'
value={String(month.getFullYear())}
onChange={(event: Event) => context.setMonth(new Date(Number((event.currentTarget as HTMLSelectElement).value), month.getMonth() - Number(monthOffset), 1))}
>
{Array.from({ length: 201 }, (_, index) => month.getFullYear() - 100 + index).map(year => <option value={String(year)}>{year}</option>)}
</select>
</span>
)
}
}
return (
<span data-slot='calendar-title' class={cn('text-sm font-medium', className)} {...rest}>
{children ?? (() => new Intl.DateTimeFormat(context.locale(), { month: 'long', year: 'numeric' }).format(visibleMonth()))}
</span>
)
}
export function CalendarPrevButton(props: GenericProps) {
const context = useCalendar()
const { class: className, children, onClick, ...rest } = props
return (
<button
type='button'
aria-label='Previous month'
data-slot='calendar-prev'
class={cn('rounded-md border px-2 py-1 text-xs hover:bg-accent', className)}
onClick={(event: MouseEvent) => {
;(onClick as ((event: MouseEvent) => void) | undefined)?.(event)
if (!event.defaultPrevented) context.setMonth(addMonths(context.month(), -1))
}}
{...rest}
>
{children ?? '‹'}
</button>
)
}
export function CalendarNextButton(props: GenericProps) {
const context = useCalendar()
const { class: className, children, onClick, ...rest } = props
return (
<button
type='button'
aria-label='Next month'
data-slot='calendar-next'
class={cn('rounded-md border px-2 py-1 text-xs hover:bg-accent', className)}
onClick={(event: MouseEvent) => {
;(onClick as ((event: MouseEvent) => void) | undefined)?.(event)
if (!event.defaultPrevented) context.setMonth(addMonths(context.month(), 1))
}}
{...rest}
>
{children ?? '›'}
</button>
)
}
export function CalendarGrid(props: CalendarGridProps) {
const context = useCalendar()
const { class: className, children, showOutsideDays, onDaySelect, monthOffset = 0, ...rest } = props
return () => {
if (children) {
return <div role='grid' data-slot='calendar-grid' class={cn('grid grid-cols-7 gap-1', className)} {...rest}>{children}</div>
}
const month = addMonths(context.month(), monthOffset)
const weekStartsOn = context.weekStartsOn()
const labels = Array.from({ length: 7 }, (_, index) => {
const date = new Date(2026, 0, 4 + ((weekStartsOn + index) % 7))
return new Intl.DateTimeFormat(context.locale(), { weekday: 'short' }).format(date)
})
return (
<div role='grid' data-slot='calendar-grid' class={cn('grid gap-1', context.showWeekNumber ? 'grid-cols-8' : 'grid-cols-7', className)} {...rest}>
{context.showWeekNumber ? <span role='columnheader' class='py-1 text-center text-xs text-muted-foreground'>#</span> : null}
{labels.map(label => (
<span role='columnheader' class='py-1 text-center text-xs text-muted-foreground'>{label}</span>
))}
{visibleDays(month, weekStartsOn).map((day, index) => {
const outside = !isSameMonth(day, month)
const hidden = !read(showOutsideDays, context.showOutsideDays()) && outside
const dayDisabled = context.disabled(day)
const modifiers = selectionModifiers(context.selection(), day, outside, dayDisabled)
return hidden ? (
<>{context.showWeekNumber && index % 7 === 0 ? <span aria-hidden='true' /> : null}<span aria-hidden='true' /></>
) : (
<>
{context.showWeekNumber && index % 7 === 0 ? <span data-slot='calendar-week-number' class='flex h-8 items-center justify-center text-xs text-muted-foreground'>{weekNumber(day)}</span> : null}
<button
type='button'
role='gridcell'
aria-selected={modifiers.selected}
disabled={dayDisabled}
data-state={modifiers.selected ? 'selected' : 'idle'}
data-range-start={modifiers.rangeStart ? 'true' : undefined}
data-range-middle={modifiers.rangeMiddle ? 'true' : undefined}
data-range-end={modifiers.rangeEnd ? 'true' : undefined}
data-outside-month={outside ? 'true' : undefined}
class={cn(
'flex h-8 flex-col items-center justify-center rounded-md text-sm hover:bg-accent disabled:pointer-events-none disabled:opacity-40',
modifiers.selected && 'bg-primary text-primary-foreground hover:bg-primary',
modifiers.rangeMiddle && 'rounded-none bg-accent text-accent-foreground hover:bg-accent',
outside && 'text-muted-foreground opacity-60',
)}
onClick={(event: MouseEvent) => {
onDaySelect?.(day, event)
if (event.defaultPrevented || dayDisabled) return
context.setSelection(day)
context.setMonth(day)
}}
>
{context.dayContent?.(day, modifiers) ?? String(day.getDate())}
</button>
</>
)
})}
</div>
)
}
}
Usage
Examples
Hijri
ش۱ش۲ش۳ش۴ش۵شج
import { Calendar } from '@/components/ui/calendar'
...import { Calendar } from '@/components/ui/calendar'
export default function CalendarHijriExample() {
let date = $state(new Date(2025, 5, 12))
return (
<Calendar
mode="single"
defaultMonth={date}
selected={() => date}
onSelect={next => { if (next instanceof Date) date = next }}
locale="fa-IR-u-ca-persian"
dir="rtl"
/>
)
}
Basic
SuMoTuWeThFrSa
import { Calendar } from '@/components/ui/calendar'
...import { Calendar } from '@/components/ui/calendar'
export default function CalendarBasicExample() {
return <Calendar mode="single" class="rounded-lg border" />
}
Range
SuMoTuWeThFrSa
SuMoTuWeThFrSa
import { Calendar, type CalendarDateRange } from '@/components/ui/calendar'
import { Card, CardContent } from '@/components/ui/card'
...import { Calendar, type CalendarDateRange } from '@/components/ui/calendar'
import { Card, CardContent } from '@/components/ui/card'
export default function CalendarRangeExample() {
const year = new Date().getFullYear()
let range = $state<CalendarDateRange>({
from: new Date(year, 0, 12),
to: new Date(year, 1, 11),
})
return (
<Card class="mx-auto w-fit p-0">
<CardContent class="p-0">
<Calendar
mode="range"
defaultMonth={range.from}
selected={() => range}
onSelect={next => { if (next && !(next instanceof Date)) range = next }}
numberOfMonths={2}
/>
</CardContent>
</Card>
)
}
Caption
SuMoTuWeThFrSa
import { Calendar } from '@/components/ui/calendar'
...import { Calendar } from '@/components/ui/calendar'
export default function CalendarCaptionExample() {
return (
<Calendar mode="single" captionLayout="dropdown" class="rounded-lg border" />
)
}
Presets
SuMoTuWeThFrSa
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
...import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Card, CardContent, CardFooter } from '@/components/ui/card'
const presets = [
['Today', 0],
['Tomorrow', 1],
['In 3 days', 3],
['In a week', 7],
['In 2 weeks', 14],
] as const
export default function CalendarPresetsExample() {
let date = $state(new Date())
let month = $state(new Date(date.getFullYear(), date.getMonth(), 1))
return (
<Card class="mx-auto w-fit max-w-[300px]" size="sm">
<CardContent>
<Calendar mode="single" selected={() => date} onSelect={next => { if (next instanceof Date) date = next }} month={() => month} onMonthChange={next => { month = next }} fixedWeeks class="border-0 p-0" />
</CardContent>
<CardFooter class="flex flex-wrap gap-2 border-t">
{presets.map(([label, offset]) => <Button variant="outline" size="sm" class="flex-1" onClick={() => { const next = new Date(); next.setDate(next.getDate() + offset); date = next; month = new Date(next.getFullYear(), next.getMonth(), 1) }}>{label}</Button>)}
</CardFooter>
</Card>
)
}
Time
SuMoTuWeThFrSa
import { Calendar } from '@/components/ui/calendar'
import { Card, CardContent, CardFooter } from '@/components/ui/card'
...import { Calendar } from '@/components/ui/calendar'
import { Card, CardContent, CardFooter } from '@/components/ui/card'
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
function ClockIcon() {
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>
}
export default function CalendarTimeExample() {
let date = $state(new Date(new Date().getFullYear(), new Date().getMonth(), 12))
return (
<Card size="sm" class="mx-auto w-fit">
<CardContent><Calendar mode="single" selected={() => date} onSelect={next => { if (next instanceof Date) date = next }} class="border-0 p-0" /></CardContent>
<CardFooter class="border-t bg-card">
<FieldGroup>
<Field><FieldLabel for="time-from">Start Time</FieldLabel><InputGroup><InputGroupInput id="time-from" type="time" step="1" value="10:30:00" /><InputGroupAddon><ClockIcon /></InputGroupAddon></InputGroup></Field>
<Field><FieldLabel for="time-to">End Time</FieldLabel><InputGroup><InputGroupInput id="time-to" type="time" step="1" value="12:30:00" /><InputGroupAddon><ClockIcon /></InputGroupAddon></InputGroup></Field>
</FieldGroup>
</CardFooter>
</Card>
)
}
Booked Dates
SuMoTuWeThFrSa
import { Card, CardContent } from '@/components/ui/card'
import { Calendar } from '@/components/ui/calendar'
...import { Card, CardContent } from '@/components/ui/card'
import { Calendar } from '@/components/ui/calendar'
export default function CalendarBookedDatesExample() {
const year = new Date().getFullYear()
let date = $state(new Date(year, 1, 3))
const bookedDates = Array.from({ length: 15 }, (_, index) => new Date(year, 1, 12 + index))
return (
<Card class="mx-auto w-fit p-0">
<CardContent class="p-0">
<Calendar mode="single" defaultMonth={date} selected={() => date} onSelect={next => { if (next instanceof Date) date = next }} disabled={bookedDates} />
</CardContent>
</Card>
)
}
Custom Days
SuMoTuWeThFrSa
import { Calendar, type CalendarDateRange } from '@/components/ui/calendar'
import { Card, CardContent } from '@/components/ui/card'
...import { Calendar, type CalendarDateRange } from '@/components/ui/calendar'
import { Card, CardContent } from '@/components/ui/card'
export default function CalendarCustomDaysExample() {
const year = new Date().getFullYear()
let range = $state<CalendarDateRange>({ from: new Date(year, 11, 8), to: new Date(year, 11, 18) })
return (
<Card class="mx-auto w-fit p-0">
<CardContent class="p-0">
<Calendar
mode="range"
defaultMonth={range.from}
selected={() => range}
onSelect={next => { if (next && !(next instanceof Date)) range = next }}
captionLayout="dropdown"
dayContent={(day, modifiers) => <>{day.getDate()}{!modifiers.outside ? <small>{day.getDay() % 6 === 0 ? '$120' : '$100'}</small> : null}</>}
/>
</CardContent>
</Card>
)
}
Week Numbers
#SuMoTuWeThFrSa06070809
import { Card, CardContent } from '@/components/ui/card'
import { Calendar } from '@/components/ui/calendar'
...import { Card, CardContent } from '@/components/ui/card'
import { Calendar } from '@/components/ui/calendar'
export default function CalendarWeekNumbersExample() {
const year = new Date().getFullYear()
let date = $state(new Date(year, 1, 3))
return (
<Card class="mx-auto w-fit p-0">
<CardContent class="p-0">
<Calendar mode="single" defaultMonth={date} selected={() => date} onSelect={next => { if (next instanceof Date) date = next }} showWeekNumber />
</CardContent>
</Card>
)
}
Rtl
حنثرخجس
import { Calendar } from '@/components/ui/calendar'
...import { Calendar } from '@/components/ui/calendar'
const languages = {
ar: { dir: 'rtl', locale: 'ar-SA' },
he: { dir: 'rtl', locale: 'he-IL' },
en: { dir: 'ltr', locale: 'en-US' },
} as const
export default function CalendarRtlExample() {
let language = $state<keyof typeof languages>('ar')
let date = $state(new Date())
const settings = () => languages[language]
return (
<div class="grid gap-4">
<select value={language} onChange={event => { language = event.currentTarget.value as keyof typeof languages }}>
<option value="ar">Arabic (العربية)</option>
<option value="he">Hebrew (עברית)</option>
<option value="en">English</option>
</select>
<Calendar mode="single" selected={() => date} onSelect={next => { if (next instanceof Date) date = next }} captionLayout="dropdown" dir={settings().dir} locale={() => settings().locale} />
</div>
)
}