Calendar
A date picker calendar built on react-day-picker with UI.MD styling.
Installation
$
Usage
import { Calendar } from "@/components/ui/calendar"Examples
Default
Picking one date. mode, selected, and onSelect go together, and the shape of selected follows the mode: a single Date here, a { from, to } object under range.
Range
A start and an end date from one run of clicks. to is undefined between the first click and the second, so anything reading the range has to handle a half-finished one.
Month and year dropdowns
captionLayout="dropdown" swaps the caption label for month and year selects. They are controls at the 32px height, so they take the action ramp (rounded-action) like a Select trigger, not the container ladder. A dropdown caption needs startMonth and endMonth to know what to list.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| captionLayout | "label" | "dropdown" | "dropdown-months" | "dropdown-years" | "label" | How the month caption renders. dropdown turns the month and year into selects; pair it with startMonth and endMonth to bound the lists. |
| mode | "single" | "multiple" | "range" | - | Selection mode. Pair with selected and onSelect for controlled selection. |
| selected | Date | Date[] | DateRange | undefined | - | The current selection; its shape depends on mode. |
| onSelect | (selected) => void | - | Called with the new selection when a day is picked. |
| showOutsideDays | boolean | true | Shows the leading and trailing days of adjacent months in each grid. |
| captionLayout | "label" | "dropdown" | "dropdown-months" | "dropdown-years" | "label" | How the month caption renders: a static label or month/year dropdowns. |
| buttonVariant | "default" | "soft" | "outline" | "ghost" | "destructive" | "subtle" | "link" | "ghost" | Button variant used for the previous/next navigation buttons. |
| numberOfMonths | number | 1 | Number of months displayed side by side. |
| disabled | Matcher | Matcher[] | - | Dates that cannot be selected, e.g. { before: new Date() }. |
"use client"
import * as React from "react"
import { IconChevronDown as ChevronDownIcon, IconChevronLeft as ChevronLeftIcon, IconChevronRight as ChevronRightIcon } from "@tabler/icons-react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker"
import { cn } from "@/registry/lib/utils"
import { Button } from "@/registry/ui/button"
import { buttonVariants } from "@/registry/ui/button-variants"
// @use-when picking a date or a date range.
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
// Ring is the edge (matches Card/menu-popup tiers): no border.
"bg-card group/calendar rounded-lg p-3 shadow-ring-md [--cell-size:--spacing(8)] pointer-coarse:[--cell-size:--spacing(11)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=card-content]_&]:shadow-none [[data-slot=popover-content]_&]:bg-transparent [[data-slot=popover-content]_&]:shadow-none",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-border shadow-xs has-focus:ring-ring-focus has-focus:ring-[3px] rounded-action",
// Only the MONTH box gets a floor. Month names are alphabetic, so
// tabular figures cannot hold them still, and the caption formatter
// above renders them SHORT ("Oct"), which is what sets the size:
// at 14px/500 this face runs "Jul" 19.7px to "May" 28.2px, so the
// box twitched 8.5px on every month change. 60px clears the widest
// plus the chevron, gap and padding.
// Do not raise this without re-checking the budget: the caption is
// 224px wide with `px-(--cell-size)` reserving 32px per side for the
// nav arrows, so the two boxes plus their 6px gap have 160px. Month
// 60 + year ~67 + 6 leaves 27px of clearance. Sizing this for the
// FULL month name (108px) overflowed that and shoved the boxes under
// the arrows' hover targets.
// Scoped with :has() because `dropdown_root` is shared by both, and
// the `months_dropdown` class lands on the SELECT, which is
// `absolute inset-0` and therefore sizes nothing.
"[&:has(>.months-dropdown)]:min-w-15",
defaultClassNames.dropdown_root
),
months_dropdown: cn("months-dropdown", defaultClassNames.months_dropdown),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
// `tabular-nums` on both layouts: this label is what sizes the
// caption, so proportional digits make the control a different width
// per year (a 1 is narrower than a 0) and the month and year
// dropdowns jump sideways as you page through. Fixed-width figures
// hold the box still. The day grid does not need this: its cells are
// already pinned to --cell-size.
"select-none font-medium tabular-nums",
captionLayout === "label"
? "text-sm"
: // `justify-between` pins the chevron to the right edge, the same
// way SelectTrigger does. Packed to the start it sat immediately
// after the label, so it slid horizontally as the month name
// changed width: the box was already held still by the floor
// below, but the caret inside it was not.
"rounded-md pl-2 pr-1 flex items-center justify-between gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-xs select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-xs select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"rounded-md 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,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon aria-hidden="true" className={cn("size-3", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
aria-hidden="true"
className={cn("size-3", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon aria-hidden="true" className={cn("size-3", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
layoutAnimation={false}
data-day={day.date.toLocaleDateString()}
data-today={modifiers.today}
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}
// A day cell sizes itself from --cell-size, which already steps up to 44px
// on a coarse pointer, so it opts OUT of the square touch box `size="icon"`
// grows to: a fixed 44px width would replace the fluid `w-full` that keeps
// the seven columns even. The range shapes are forced for the same reason.
// A day in the middle of a selected range is square on purpose so the run
// reads as one bar, and every radius utility inside the coarse media query
// is emitted after the data-attribute ones, so without the mark the touch
// corner would round the middle of every range back off on a phone.
className={cn(
"pointer-coarse:size-auto data-[selected-single=true]:bg-accent data-[selected-single=true]:text-accent-foreground data-[range-middle=true]:bg-accent-100 dark:data-[range-middle=true]:bg-accent-700 data-[range-middle=true]:text-accent dark:data-[range-middle=true]:text-accent-200 data-[range-start=true]:bg-accent data-[range-start=true]:text-accent-foreground data-[range-end=true]:bg-accent data-[range-end=true]:text-accent-foreground data-[today=true]:outline-solid data-[today=true]:outline-1 data-[today=true]:-outline-offset-1 data-[today=true]:outline-accent group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring-focus dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:!rounded-md data-[range-end=true]:!rounded-r-md data-[range-middle=true]:!rounded-none data-[range-start=true]:!rounded-md data-[range-start=true]:!rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }