Calendar
A month grid for picking a date or a range.
Schedule payment run
ACH goes out on business days. Oct 12 and Nov 11 are bank holidays.
- Pays on
- Friday, October 2
- Invoices
- 212 to 48 suppliers
- Total
- $1,284,310.52
import { Button } from "@oration/canon/components/button";import { Calendar } from "@oration/canon/components/calendar";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() { const today = new Date(2026, 8, 28); const bankHolidays = [new Date(2026, 9, 12), new Date(2026, 10, 11)]; const [date, setDate] = React.useState<Date | undefined>( new Date(2026, 9, 2), ); const long = (d: Date) => d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", }); return ( <div className="grid w-full max-w-2xl overflow-hidden rounded-xl bg-card text-left shadow-border sm:grid-cols-[auto_minmax(0,1fr)]"> <div className="flex justify-center border-b border-border p-3 sm:border-r sm:border-b-0"> <Calendar locale={{ code: "en-US" }} mode="single" required selected={date} onSelect={setDate} today={today} defaultMonth={today} disabled={[ { dayOfWeek: [0, 6] }, { before: today }, ...bankHolidays, ]} className="bg-transparent" /> </div> <div className="flex flex-col gap-4 p-4"> <div className="flex flex-col gap-1"> <p className="text-sm font-semibold text-foreground"> Schedule payment run </p> <p className="text-13 text-muted-foreground"> ACH goes out on business days. Oct 12 and Nov 11 are bank holidays. </p> </div> <dl className="flex flex-col gap-2 rounded-[10px] bg-muted/70 px-3 py-2.5 text-13"> <div className="flex justify-between gap-3"> <dt className="text-muted-foreground">Pays on</dt> <dd className="font-medium text-foreground"> {date ? long(date) : "Pick a date"} </dd> </div> <div className="flex justify-between gap-3"> <dt className="text-muted-foreground">Invoices</dt> <dd className="text-foreground tabular-nums"> 212 to 48 suppliers </dd> </div> <div className="flex justify-between gap-3"> <dt className="text-muted-foreground">Total</dt> <dd className="font-medium text-foreground tabular-nums"> $1,284,310.52 </dd> </div> </dl> <Button type="button" className="mt-auto self-end" disabled={!date} onClick={() => date && toast.add({ type: "success", title: "Payment run scheduled", description: `${long(date)}, 2:00 PM CT.`, }) } > Schedule run </Button> </div> </div> );}Usage#
Calendar is a month grid for picking one date, several dates or a range, built on react-day-picker 10 and styled with Canon buttons. Selected days are Quiet Indigo, today and the days inside a range are Well Gray, and days that can't be chosen are dimmed. Oration uses it to mark holidays on a business-hours schedule, and it is the grid to put in a Popover when a form needs a date. The mistake people make is disabling days without saying why: when weekends, past dates or bank holidays can't be picked, say so beside the calendar.
When to use
- To pick a payment date or a callback date, inside a Popover opened from a button.
- To pick a period, such as the statement range for a remittance report, with
mode="range". - To mark several dates, such as holidays on a schedule, with
mode="multiple". - When some days are not allowed, such as weekends and bank holidays for ACH.
When not to use
- For a length of time, such as a retention window. Use Duration picker
- For recurring weekly hours. Use Business hours picker
- For a date people know and type, such as an invoice date copied from a PDF. A text field with a format hint is faster. Use Input
- For a time zone next to a date. Use Timezone select
The Quiet Indigo Rule
Disabled days explain themselves
Anatomy#
- Caption. The month and year at 14px weight 500, centered, or month and year dropdowns with
captionLayout="dropdown". - Navigation. Previous and next month buttons: 28px ghost buttons with 16px chevrons.
- Weekdays. Two-letter day names in Slate Meta.
- Day. A 28px square ghost button with 8px corners. Days from the neighboring months show in Slate Meta.
- Today. A Well Gray fill.
- Selected day. A Quiet Indigo fill with Indigo Paper text.
Examples#
Single date
mode="single" with today and defaultMonth from the data. Clicking the selected day again clears it unless required is set.
Callback on Wednesday, September 30
import { Calendar } from "@oration/canon/components/calendar";import * as React from "react";export function Single() { const [date, setDate] = React.useState<Date | undefined>( new Date(2026, 8, 30), ); return ( <div className="flex flex-col items-center gap-3"> <Calendar locale={{ code: "en-US" }} mode="single" selected={date} onSelect={setDate} today={new Date(2026, 8, 28)} defaultMonth={new Date(2026, 8, 1)} className="rounded-[10px] shadow-border" /> <p className="text-13 text-muted-foreground" aria-live="polite"> {date ? `Callback on ${date.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" })}` : "No date picked. Click the selected day again to clear it."} </p> </div> );}Range
mode="range" over two months for a statement period. Future days are disabled with { after: today }.
Remittance statement: Aug 1 – 31, 2026
import { Calendar } from "@oration/canon/components/calendar";import * as React from "react";export function Range() { const [range, setRange] = React.useState< { from: Date | undefined; to?: Date | undefined } | undefined >({ from: new Date(2026, 7, 1), to: new Date(2026, 7, 31), }); const short = (d: Date) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); const label = range?.from ? range.to ? range.to.getMonth() === range.from.getMonth() ? `${short(range.from)} – ${range.to.getDate()}, 2026` : `${short(range.from)} – ${short(range.to)}, 2026` : `From ${short(range.from)}, pick an end date` : "Pick a statement period"; return ( <div className="flex flex-col items-center gap-3"> <Calendar locale={{ code: "en-US" }} mode="range" selected={range} onSelect={setRange} numberOfMonths={2} today={new Date(2026, 8, 28)} defaultMonth={new Date(2026, 7, 1)} disabled={{ after: new Date(2026, 8, 28) }} className="rounded-[10px] shadow-border" /> <p className="text-13 text-muted-foreground tabular-nums" aria-live="polite" > Remittance statement: {label} </p> </div> );}Several dates
mode="multiple" for holidays on a schedule, as business hours does, on a tint well inside the card with the list beside it.
Holidays
The support line is closed all day on these dates.
- Mon, Oct 12
- Wed, Nov 11
- Thu, Nov 26
- Fri, Nov 27
import { Button } from "@oration/canon/components/button";import { Calendar } from "@oration/canon/components/calendar";import { XIcon } from "lucide-react";import * as React from "react";export function Multiple() { const [dates, setDates] = React.useState<Date[] | undefined>([ new Date(2026, 9, 12), new Date(2026, 10, 11), new Date(2026, 10, 26), new Date(2026, 10, 27), ]); const sorted = [...(dates ?? [])].sort((a, b) => a.getTime() - b.getTime()); return ( <div className="grid w-full max-w-xl gap-4 rounded-xl bg-card p-4 shadow-border md:grid-cols-[auto_minmax(0,1fr)]"> <Calendar locale={{ code: "en-US" }} mode="multiple" selected={dates} onSelect={setDates} today={new Date(2026, 8, 28)} defaultMonth={new Date(2026, 10, 1)} className="rounded-[10px] bg-muted/70" /> <div className="flex min-w-0 flex-col gap-2"> <p className="text-sm font-semibold text-foreground"> Holidays </p> <p className="text-13 text-muted-foreground"> The support line is closed all day on these dates. </p> <ul aria-label="Holidays" className="flex flex-col gap-1"> {sorted.map((d) => ( <li key={d.toISOString()} className="flex h-8 items-center justify-between gap-2 rounded-lg px-2 text-13 hover:bg-muted" > <span className="text-foreground tabular-nums"> {d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", })} </span> <Button type="button" variant="ghost" size="icon-xs" aria-label={`Remove ${d.toLocaleDateString("en-US", { month: "long", day: "numeric" })}`} onClick={() => setDates((current) => current?.filter( (x) => x.getTime() !== d.getTime(), ), ) } className="text-muted-foreground" > <XIcon /> </Button> </li> ))} </ul> </div> </div> );}Disabled days
Combine matchers: weekends with { dayOfWeek: [0, 6] }, the past with { before: today }, and single dates. Say why under the grid.
Payments go out on business days. Oct 12 is a bank holiday.
import { Calendar } from "@oration/canon/components/calendar";import * as React from "react";export function DisabledDays() { const today = new Date(2026, 8, 28); const [date, setDate] = React.useState<Date | undefined>(); return ( <div className="flex flex-col items-center gap-3"> <Calendar locale={{ code: "en-US" }} mode="single" selected={date} onSelect={setDate} today={today} defaultMonth={new Date(2026, 9, 1)} disabled={[ { dayOfWeek: [0, 6] }, { before: today }, new Date(2026, 9, 12), ]} className="rounded-[10px] shadow-border" /> <p className="max-w-64 text-center text-13 text-muted-foreground"> Payments go out on business days. Oct 12 is a bank holiday. </p> </div> );}In a popover
The date picker recipe: an outline button with the date in words opens a Popover with the Calendar, which closes on select and returns focus to the button.
import { Button } from "@oration/canon/components/button";import { Calendar } from "@oration/canon/components/calendar";import { Popover, PopoverContent, PopoverTrigger } from "@oration/canon/components/popover";import { cn } from "@oration/canon/lib/utils";import { CalendarIcon } from "lucide-react";import * as React from "react";export function InPopover() { const id = React.useId(); const [open, setOpen] = React.useState(false); const [date, setDate] = React.useState<Date | undefined>( new Date(2026, 9, 2), ); const label = date ? date.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric", }) : "Pick a date"; return ( <div className="flex flex-col gap-2"> <span id={`${id}-label`} className="text-sm font-medium text-foreground" > Payment date </span> <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger render={ <Button type="button" variant="outline" aria-labelledby={`${id}-label ${id}-value`} className={cn( "w-64 justify-start font-normal", !date && "text-muted-foreground", )} /> } > <CalendarIcon data-icon="inline-start" aria-hidden="true" /> <span id={`${id}-value`}>{label}</span> </PopoverTrigger> <PopoverContent align="start" className="w-auto p-0"> <Calendar locale={{ code: "en-US" }} mode="single" selected={date} onSelect={(next) => { setDate(next); setOpen(false); }} today={new Date(2026, 8, 28)} defaultMonth={date ?? new Date(2026, 8, 1)} disabled={[ { dayOfWeek: [0, 6] }, { before: new Date(2026, 8, 28) }, ]} /> </PopoverContent> </Popover> </div> );}Month and year dropdowns
captionLayout="dropdown" for dates far from today, such as an invoice date from last year, bounded by startMonth and endMonth.
Invoice date: Nov 14, 2025
import { Calendar } from "@oration/canon/components/calendar";import * as React from "react";export function Dropdowns() { const [date, setDate] = React.useState<Date | undefined>( new Date(2025, 10, 14), ); return ( <div className="flex flex-col items-center gap-3"> <Calendar locale={{ code: "en-US" }} mode="single" selected={date} onSelect={setDate} captionLayout="dropdown" startMonth={new Date(2024, 0, 1)} endMonth={new Date(2026, 11, 1)} today={new Date(2026, 8, 28)} defaultMonth={date} className="rounded-[10px] shadow-border" /> <p className="text-13 text-muted-foreground"> Invoice date:{" "} {date ? date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }) : "none"} </p> </div> );}States#
| State | Treatment |
|---|---|
| Rest | Graphite Ink numbers on the White Plane. |
| Hover | The ghost button's Well Gray fill. |
| Focus visible | An indigo border and a 3px ring at 50% on the focused day. |
| Today | Well Gray fill. |
| Selected | Quiet Indigo fill, Indigo Paper text. |
| Range | Start and end are indigo with rounded outer corners; the days between are Well Gray with square corners, joined into one band. |
| Outside | Days from the previous and next month in Slate Meta. Hide them with showOutsideDays={false}. |
| Disabled | Slate Meta at 50% opacity. It can't be selected, and arrow keys skip it. |
Behavior#
modeissingle,multipleorrange. Each takesselectedandonSelect;requiredstops people from clearing the selection.disabledtakes matchers: a date, an array of dates,{ before: date },{ after: date },{ dayOfWeek: [0, 6] },{ from, to }or a function.- In range mode the first click sets the start and the next sets the end.
minandmaxbound the number of days, andexcludeDisabledrestarts the range if it would cross a disabled day. todaysets which day is marked as today, anddefaultMonthwhich month opens. Pass both from your data rather than the device clock when the page is about a fixed date.numberOfMonths={2}shows two months side by side from 768px and stacked below.captionLayout="dropdown"adds month and year selects; bound them withstartMonthandendMonth.- Inside a Popover or a card the background becomes transparent, so it takes the surface's color.
weekStartsOn,localeandtimeZonepass through to react-day-picker, along with every other DayPicker prop. A partial locale such as{ code: "en-US" }is merged over the default English one.
Do and don't#
Payments go out on business days.
Invalid date.
Content#
- Show the chosen date in full on its trigger: Friday, October 2, 2026, or Oct 2, 2026 where space is tight.
- Write ranges with an en dash and no repeated month: Sep 1 – 30, 2026.
- The trigger's empty state says what to pick: Pick a date, Pick a period.
- Explain disabled days in one sentence, naming the rule: Payments go out on business days.
Accessibility#
- The month is a grid of buttons; each day's name includes the full date, and selected days announce as selected.
- The grid has one tab stop: Tab lands on the selected day, or today, and arrow keys move from there with a visible ring.
- In a Popover, focus returns to the trigger on close. Name the trigger with its purpose and the current value: Payment date, Friday, October 2.
- Explain disabled days in text; dimming alone is a color-only cue.
- Day cells are 28px, above the 24px minimum. For touch-first screens, raise
--cell-sizethroughclassName.
| Keys | Action |
|---|---|
| ←→ | Previous or next day. |
| ↑↓ | Same day in the previous or next week. |
| Page UpPage Down | Previous or next month. |
| ShiftPage Up | Previous year; with Page Down, next year. |
| HomeEnd | Start or end of the week. |
| Enter | Selects the focused day. Space does the same. |
Design tokens#
| Token | Used for |
|---|---|
--primary | Selected day and range ends |
--primary-foreground | Text on selected days |
--muted | Today, range middle, hover |
--muted-foreground | Weekdays, outside and disabled days |
--ring | Focused day border and 3px ring at 50% |
--radius-md | 8px day corners (--cell-radius) |
--cell-size | 28px day cells (--spacing(7)) |
API reference#
Calendar
The month grid. Every react-day-picker prop is passed through; the ones below are the ones Oration uses.
Other props spread onto react-day-picker DayPicker.
| Prop | Type | Default | Description |
|---|---|---|---|
mode | "single" | "multiple" | "range" | No default | What can be selected. Omit for a display-only calendar. |
selected | Date | Date[] | DateRange | undefined | No default | The selection, typed by mode. |
onSelect | (selected, triggerDate, modifiers, event) => void | No default | Called with the next selection. |
required | boolean | No default | Prevents clearing the selection. |
disabled | Matcher | Matcher[] | No default | Days that can't be selected. |
defaultMonth | Date | No default | The month shown first. |
month | Date | No default | The month shown, controlled with onMonthChange. |
today | Date | No default | The day marked as today. |
numberOfMonths | number | 1 | Months shown side by side. |
captionLayout | "label" | "dropdown" | "dropdown-months" | "dropdown-years" | "label" | Month and year as text or as selects. |
startMonth | Date | No default | Earliest month people can navigate to. |
endMonth | Date | No default | Latest month people can navigate to. |
showOutsideDays | boolean | true | Show days from the neighboring months. |
min | number | No default | In range and multiple mode, the fewest days. |
max | number | No default | In range and multiple mode, the most days. |
excludeDisabled | boolean | No default | In range mode, restart the range rather than include a disabled day. |
locale | Partial<DayPickerLocale> | No default | Month and day names, and the format of each day's data-day. Pass at least { code } on server-rendered pages. |
weekStartsOn | 0 | 1 | 2 | 3 | 4 | 5 | 6 | No default | First day of the week. |
showWeekNumber | boolean | No default | Adds a week number column. |
buttonVariant | Button variant | "ghost" | The variant of the previous and next buttons. |
classNames | Partial<ClassNames> | No default | Per-part classes, merged over Canon's. |
CalendarDayButton
The day cell. Use it when replacing components.DayButton so custom days keep the selected and range styles.
Other props spread onto react-day-picker DayButton props.
| Prop | Type | Default | Description |
|---|---|---|---|
locale | Partial<Locale> | No default | Used to format the data-day attribute. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Without a locale, CalendarDayButton writes data-day with the runtime's default date format, which differs between the server and the browser (30/8/2026 against 30/08/2026 here), so every server-rendered Calendar logs a hydration mismatch, including the holidays section in business hours. Pass locale={{ code: "en-US" }}, as every example on this page does, until it is fixed.
One product screen uses it, to mark holidays. Campaign scheduling uses native <input type="date"> (apps/web/src/components/ai/campaigns/steps/schedule-step.tsx), and there is no Canon date picker; compose Popover and Calendar as in the example above.
Weekday headers and week numbers are 12.8px (text-[0.8rem]), off the 12, 13 and 14px ramp.
Today and the middle of a range share the Well Gray fill, so today can't be told apart inside a range.
Day cells are 28px, under the 32px touch target the suite uses on touch surfaces.