Skip to content

Calendar

A month grid for picking a date or a range.

Status
Stable
Category
Inputs
Adoption
Not used yet
import { Calendar } from "@oration/canon/components/calendar";
packages/canon/src/components/calendar.tsx
September 2026

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

Indigo marks the selected day and the ends of a range, because they are selection. Today, range middles and hover are Well Gray; nothing else in the grid is colored.

Disabled days explain themselves

When days can't be chosen, a line beside or under the calendar says why: ACH runs on business days. Oct 12 is a bank holiday.

Anatomy#

September 2026
  1. Caption. The month and year at 14px weight 500, centered, or month and year dropdowns with captionLayout="dropdown".
  2. Navigation. Previous and next month buttons: 28px ghost buttons with 16px chevrons.
  3. Weekdays. Two-letter day names in Slate Meta.
  4. Day. A 28px square ghost button with 8px corners. Days from the neighboring months show in Slate Meta.
  5. Today. A Well Gray fill.
  6. 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.

September 2026

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 }.

August 2026
September 2026

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.

November 2026

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.

October 2026

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.

Payment date
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>    );}

States#

States
StateTreatment
RestGraphite Ink numbers on the White Plane.
HoverThe ghost button's Well Gray fill.
Focus visibleAn indigo border and a 3px ring at 50% on the focused day.
TodayWell Gray fill.
SelectedQuiet Indigo fill, Indigo Paper text.
RangeStart and end are indigo with rounded outer corners; the days between are Well Gray with square corners, joined into one band.
OutsideDays from the previous and next month in Slate Meta. Hide them with showOutsideDays={false}.
DisabledSlate Meta at 50% opacity. It can't be selected, and arrow keys skip it.

Behavior#

  • mode is single, multiple or range. Each takes selected and onSelect; required stops people from clearing the selection.
  • disabled takes 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. min and max bound the number of days, and excludeDisabled restarts the range if it would cross a disabled day.
  • today sets which day is marked as today, and defaultMonth which 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 with startMonth and endMonth.
  • Inside a Popover or a card the background becomes transparent, so it takes the surface's color.
  • weekStartsOn, locale and timeZone pass 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#

Do. Open it from a button that shows the chosen date in words, and close it once a date is picked.
Payment date
October 2026
Don't. Leave a full calendar open inline in a form for a single date.
October 2026

Payments go out on business days.

Do. Disable the days that can't be chosen and say why under the calendar.
October 2026

Invalid date.

Don't. Let people pick a Saturday and reject it after they submit.

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-size through className.
Keyboard interactions
KeysAction
←→Previous or next day.
↑↓Same day in the previous or next week.
Page UpPage DownPrevious or next month.
ShiftPage UpPrevious year; with Page Down, next year.
HomeEndStart or end of the week.
EnterSelects the focused day. Space does the same.

Design tokens#

Design tokens
TokenUsed for
--primarySelected day and range ends
--primary-foregroundText on selected days
--mutedToday, range middle, hover
--muted-foregroundWeekdays, outside and disabled days
--ringFocused day border and 3px ring at 50%
--radius-md8px day corners (--cell-radius)
--cell-size28px 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.

Props of Calendar
PropTypeDefaultDescription
mode"single" | "multiple" | "range"No defaultWhat can be selected. Omit for a display-only calendar.
selectedDate | Date[] | DateRange | undefinedNo defaultThe selection, typed by mode.
onSelect(selected, triggerDate, modifiers, event) => voidNo defaultCalled with the next selection.
requiredbooleanNo defaultPrevents clearing the selection.
disabledMatcher | Matcher[]No defaultDays that can't be selected.
defaultMonthDateNo defaultThe month shown first.
monthDateNo defaultThe month shown, controlled with onMonthChange.
todayDateNo defaultThe day marked as today.
numberOfMonthsnumber1Months shown side by side.
captionLayout"label" | "dropdown" | "dropdown-months" | "dropdown-years""label"Month and year as text or as selects.
startMonthDateNo defaultEarliest month people can navigate to.
endMonthDateNo defaultLatest month people can navigate to.
showOutsideDaysbooleantrueShow days from the neighboring months.
minnumberNo defaultIn range and multiple mode, the fewest days.
maxnumberNo defaultIn range and multiple mode, the most days.
excludeDisabledbooleanNo defaultIn range mode, restart the range rather than include a disabled day.
localePartial<DayPickerLocale>No defaultMonth and day names, and the format of each day's data-day. Pass at least { code } on server-rendered pages.
weekStartsOn0 | 1 | 2 | 3 | 4 | 5 | 6No defaultFirst day of the week.
showWeekNumberbooleanNo defaultAdds a week number column.
buttonVariantButton variant"ghost"The variant of the previous and next buttons.
classNamesPartial<ClassNames>No defaultPer-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.

Props of CalendarDayButton
PropTypeDefaultDescription
localePartial<Locale>No defaultUsed 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.