Calendar
A date calendar on react-day-picker v10, styled with the ai2 Button and tokens. Single, multiple and range selection, label or dropdown caption.
"use client"
import * as React from "react"
import { Calendar } from "@/components/ui/calendar"
export default function CalendarDemo() {
/* A fixed date, NOT `new Date()` (AGENTS 6.6): an argument-less `new Date()`
can put the server prerender and the client hydration on different days and
produce a hydration mismatch. This file is an example shipped to the
consumer, so it would carry the bug along with it. In a real application
this value comes from a prop or a user choice. */
const [date, setDate] = React.useState<Date | undefined>(
() => new Date(2026, 0, 15),
)
return (
<Calendar
mode="single"
selected={date}
onSelect={setDate}
/* defaultMonth de sabit: yoksa takvim bugunun ayini acar ve secili gun
gorunmez kalirdi (secim ocakta, gorunum temmuzda). */
defaultMonth={new Date(2026, 0, 1)}
className="rounded-xl border border-border"
/>
)
}Installation
Run the following command
npx shadcn@latest add @ai2/calendarreact-day-picker, the ai2 Button, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install react-day-picker@^10.0.1 lucide-react@^1.23.0The calendar also depends on the ai2 Button, so add @ai2/button too.
Add the cn util
lib/utils.tsimport { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/* Adds a source-attribution ref param to a URL (the inspiration exports mark their
outbound links with an ai2.design attribution). An invalid URL is returned as is.
This file is SHOWN TO THE CONSUMER: the docs component pages render the source of
`cn` in a code block, so a Turkish comment here would reach every one of those
pages. Keep it English. */
export function withRef(url: string, ref = "ai2.design"): string {
try {
const u = new URL(url)
u.searchParams.set("ref", ref)
return u.toString()
} catch {
return url
}
}Copy the source code
components/ui/calendar.tsx"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
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}
/* WCAG 1.4.4 (200% text): a seven-column date grid CANNOT reflow on a
narrow screen - cells are rem-based, so doubling the font size makes
the calendar want ~500px. Squeezing it clips the inner structure
(tried in one round, the headers were cut off), so the correct fix is
to CONTAIN it: the calendar scrolls inside itself instead of pushing
the page. Same pattern as `tabs-list`; the scrollbar is hidden and
keyboard and mouse access are unchanged. */
className={cn(
"group/calendar max-w-full overflow-x-auto bg-popover p-3 [--cell-size:--spacing(8)] [&::-webkit-scrollbar]:hidden [scrollbar-width:none] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
// The abbreviation must respect the locale prop (2026-07-11 audit R68).
// The fallback is "en-US", NOT "default": "default" reads the system
// locale, so with no locale prop the month names came out in the
// language of whatever MACHINE ran the render. On a Turkish Windows box
// the SSR HTML said "Oca Sub Mar" while the same code on Vercel said
// "Jan Feb Mar" (measured 2026-08-05). "en-US" also matches
// react-day-picker's own default locale, so the dropdown and the
// weekday row now agree.
formatMonthDropdown: (date) =>
date.toLocaleString(props.locale?.code ?? "en-US", { 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) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex min-h-(--cell-size) w-full items-center justify-center px-(--cell-size) [&>*]:min-w-0",
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(
"relative rounded-md border border-field-border shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium [overflow-wrap:anywhere]",
captionLayout === "label"
? "text-sm"
: "flex min-h-8 items-center gap-1 rounded-md ps-2 pe-1 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 select-none rounded-md text-[0.8rem] font-normal text-muted-foreground",
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(
"select-none text-[0.8rem] text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md",
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 bg-accent text-accent-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,
}}
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 className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
}
return (
<ChevronDownIcon className={cn("size-4", 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"
data-day={day.date.toLocaleDateString()}
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}
className={cn(
"flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 font-normal leading-none 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-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md 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-accent-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }Manual installs skip the @ai2/tokens theme - add the token CSS from the theming guide or the selection colors will be missing.
Usage
import { Calendar } from "@/components/ui/calendar"
<Calendar mode="single" className="rounded-xl border border-border" />Hold the selection in React state and pass it back through selected and onSelect. The base component is frameless, so add a border and rounding through className.
Examples
Single day
Date range
Set mode="range" and hold a DateRange state. The start, middle and end days each get their own token-driven highlight.
Dropdown caption
captionLayout="dropdown" turns the month and year caption into select menus. Pair it with startMonth and endMonth to bound the range.
Hide outside days
showOutsideDays={false} drops the leading and trailing days of adjacent months, leaving a strictly single-month grid.
Localized
Pass any date-fns locale from react-day-picker/locale to translate the weekday and month names and set the first day of the week. This example uses Spanish (es).
Props
Calendar forwards every react-day-picker DayPicker prop. The most common ones are listed here.
| Prop | Type | Default | Description |
|---|---|---|---|
mode | "single" | "multiple" | "range" | undefined | Selection behaviour: a single day, several individual days, or a continuous range. Determines the shape of selected and the onSelect argument. |
selected | Date | Date[] | DateRange | undefined | The controlled selection, typed to match mode: a Date for single, a Date array for multiple, and a { from, to } DateRange for range. |
onSelect | (value) => void | undefined | Fires when the user picks a day. The argument matches mode, so pass a state setter of the corresponding type. |
captionLayout | "label" | "dropdown" | "dropdown-months" | "dropdown-years" | "label" | How the month and year caption renders: a static label, or select dropdowns for fast navigation. |
showOutsideDays | boolean | true | Show the trailing and leading days of adjacent months to fill the first and last week of the grid. |
numberOfMonths | number | 1 | How many months to render side by side, useful with range selection. |
buttonVariant | "solid" | "soft" | "outline" | "ghost" | "link" | "ghost" | The ai2 Button variant used for the previous and next navigation buttons. |
startMonth / endMonth | Date | undefined | Bound the navigable range and populate the dropdown caption with the corresponding months and years. |
locale | Locale | enUS | A date-fns locale from react-day-picker/locale. Localizes weekday and month names, the dropdown caption and the first day of the week. |
className | string | undefined | Classes for the calendar root. Add border and radius here, since the base component ships without a frame. |
ai2 Calendar: a react-day-picker date grid for React
The ai2 Calendar is a shadcn-compatible date calendar for React, built on react-day-picker v10 and styled with the ai2 Button and Tailwind CSS v4 tokens. It renders an accessible month grid with keyboard navigation and supports single day, multiple day and continuous range selection through one mode prop.
Because it ships through the shadcn registry format, you install it with one CLI command, an MCP agent, or a copy-paste, and the source lands in your own project. You own the file; there is no runtime dependency on ai2 itself. The live example above is the exact component you get.
What is the ai2 Calendar?
It is a thin, well-styled wrapper around the react-day-picker DayPicker component. Every day cell is an ai2 Button, the navigation chevrons come from lucide-react, and the month grid, weekday row and caption read from the shared ai2 token file, so the calendar matches the rest of your UI in both light and dark mode.
It exports two parts: Calendar and CalendarDayButton. All react-day-picker props pass straight through, so mode, selected, onSelect, captionLayout, showOutsideDays, numberOfMonths, disabled dates and the rest of the DayPicker API keep working exactly as documented upstream.
Why use it
- Three selection modes: One mode prop switches between single, multiple and range selection, with selected and onSelect typed to match each case.
- Keyboard and screen reader ready: react-day-picker handles arrow-key navigation, focus management and ARIA roles, so the grid is usable without a mouse out of the box.
- Label or dropdown caption: captionLayout switches the month and year caption between a static label and select dropdowns for jumping across months and years quickly.
- Token-driven styling: Selected, range, today and outside days all read from the ai2 token file, so the calendar stays consistent in light and dark mode with no extra CSS.
- Agent-readable metadata: The registry item describes its modes and parts in plain words, so an MCP agent can find, inspect and install it without guessing.
Features
- shadcn registry install: One command adds the component, react-day-picker, the ai2 Button dependency and the @ai2/tokens theme to your project.
- ai2 Button day cells: CalendarDayButton renders each day with the ghost Button variant, so hover, focus ring and selected states match your buttons.
- Range highlighting: Range mode marks the start, middle and end days with distinct token-driven backgrounds via data-range-start, data-range-middle and data-range-end.
- Configurable navigation: buttonVariant sets the previous and next button style, and startMonth with endMonth bound the navigable window and fill the dropdown caption.
- Outside days toggle: showOutsideDays fills the first and last week with adjacent-month days, or hides them for a strictly single-month grid.
- Data attributes for styling: Every part exposes data-slot, and day cells expose selection and range data attributes, so you can restyle states from CSS without forking the component.
Production tips
- Match the selected type to mode: Use a Date for single, a Date array for multiple and a { from, to } DateRange for range. Passing the wrong shape stops selection from rendering.
- Add your own frame: The base Calendar ships without a border. Add border and rounding through className, or drop it inside a Card or Popover which already provide the frame.
- Use dropdown caption for wide ranges: When users pick birthdays or historical dates, captionLayout="dropdown" with startMonth and endMonth beats clicking the chevron dozens of times.
- Disable dates with the matcher API: Pass the react-day-picker disabled prop, for example { before: new Date() }, to block past days rather than filtering selections after the fact.
- Show two months for ranges: numberOfMonths={2} makes range selection far easier, since users can see both ends of the range without paging between months.
Works with the rest of ai2
The Calendar is the grid behind the ai2 Date Picker, which drops it into a Popover for a compact date field. Each day cell is an ai2 Button, so hover and focus states match the rest of your controls.
Place a Calendar inside a ai2 Card for a framed scheduling panel, or inside a ai2 Popover to build your own trigger. Everything shares one token source, so combinations stay visually consistent in both modes.