dotUI
dotUI
beta
  1. Components
  2. Dates
  3. Calendar

Calendar

A component that allows users to select a single date.

<Calendar />

Installation

Install the following dependencies:

npm install react-aria-components

Copy and paste the following code into your project.

"use client";

import * as React from "react";
import {
  composeRenderProps,
  Calendar as AriaCalendar,
  CalendarCell as AriaCalendarCell,
  CalendarGrid as AriaCalendarGrid,
  CalendarGridHeader as AriaCalendarGridHeader,
  CalendarHeaderCell as AriaCalendarHeaderCell,
  CalendarGridBody as AriaCalendarGridBody,
  type CalendarProps as AriaCalendarProps,
  type CalendarGridProps as AriaCalendarGridProps,
  type CalendarGridHeaderProps as AriaCalendarGridHeaderProps,
  type CalendarHeaderCellProps as AriaCalendarHeaderCellProps,
  type CalendarGridBodyProps as AriaCalendarGridBodyProps,
  type CalendarCellProps as AriaCalendarCellProps,
  type DateValue,
} from "react-aria-components";
import { tv } from "tailwind-variants";
import { ChevronLeftIcon, ChevronRightIcon } from "@/lib/icons";
import { Button, buttonStyles } from "./button";
import { Heading } from "./heading";
import { Text } from "./text";

const calendarStyles = tv({
  slots: {
    root: "w-fit max-w-full rounded-md border bg-bg p-3",
    header: "mb-4 flex items-center justify-between gap-2",
    grid: "w-full border-collapse",
    gridHeader: "mb-4",
    gridHeaderCell: "text-xs font-normal text-fg-muted",
    gridBody: "[&>tr>td]:p-0",
    cell: "",
  },
  variants: {
    range: {
      false: {
        cell: [
          buttonStyles({
            variant: "quiet",
            shape: "square",
            size: "sm",
          }),
          "my-1 selected:bg-bg-primary selected:text-fg-onPrimary disabled:cursor-default disabled:bg-transparent",
          "selected:invalid:bg-bg-danger selected:invalid:text-fg-onDanger",
          "unavailable:line-through unavailable:hover:bg-transparent unavailable:cursor-default unavailable:text-fg-muted",
          "outside-month:hidden",
        ],
      },
      true: {
        cell: [
          "relative my-1 flex size-8 items-center justify-center rounded-md text-sm font-normal leading-normal disabled:cursor-default disabled:bg-transparent disabled:text-fg-disabled",
          "after:absolute after:inset-0 after:transition-colors after:content-[''] selected:after:bg-bg-primary/10",
          "before:absolute before:inset-0 before:z-10 before:rounded-[inherit] before:transition-colors before:content-[''] hover:before:bg-bg-inverse/10",
          "outline-none before:border before:border-transparent before:ring-0 before:ring-border-focus focus-visible:z-50 focus-visible:before:border-border focus-visible:before:ring-2 focus-visible:before:ring-offset-2 focus-visible:before:ring-offset-bg",
          "selection-start:after:rounded-l-[inherit] selection-end:after:rounded-r-[inherit]",
          "selection-start:pressed:before:bg-primary/90 selection-start:text-fg-onPrimary selection-start:before:bg-bg-primary",
          "selection-end:pressed:before:bg-primary/90 selection-end:text-fg-onPrimary selection-end:before:bg-bg-primary",
          "selected:invalid:after:bg-bg-danger selected:invalid:[&:not([data-selection-start])]:[&:not([data-selection-end])]:text-fg-onDanger",
          "unavailable:line-through unavailable:hover:before:bg-transparent unavailable:cursor-default unavailable:text-fg-muted",
          "outside-month:hidden",
        ],
      },
    },
  },
  defaultVariants: {
    range: false,
  },
});

interface CalendarProps<T extends DateValue> extends Omit<AriaCalendarProps<T>, "visibleDuration"> {
  visibleMonths?: number;
  errorMessage?: string;
}
const Calendar = <T extends DateValue>({
  errorMessage,
  visibleMonths = 1,
  ...props
}: CalendarProps<T>) => {
  visibleMonths = Math.min(Math.max(visibleMonths, 1), 3);

  return (
    <AriaCalendar visibleDuration={{ months: visibleMonths }} {...props}>
      {({ isInvalid }) => (
        <>
          <CalendarHeader>
            <Button slot="previous" variant="outline" shape="square" size="sm">
              <ChevronLeftIcon />
            </Button>
            <Heading className="text-sm" />
            <Button slot="next" variant="outline" shape="square" size="sm">
              <ChevronRightIcon />
            </Button>
          </CalendarHeader>
          <div className="flex items-start gap-4">
            {Array.from({ length: visibleMonths }).map((_, index) => (
              <CalendarGrid key={index} offset={index === 0 ? undefined : { months: index }}>
                <CalendarGridHeader>
                  {(day) => <CalendarHeaderCell>{day}</CalendarHeaderCell>}
                </CalendarGridHeader>
                <CalendarGridBody>{(date) => <CalendarCell date={date} />}</CalendarGridBody>
              </CalendarGrid>
            ))}
          </div>
          {isInvalid && errorMessage && <Text slot="errorMessage">{errorMessage}</Text>}
        </>
      )}
    </AriaCalendar>
  );
};

type CalendarRootProps<T extends DateValue> = AriaCalendarProps<T>;
const CalendarRoot = <T extends DateValue>(props: CalendarRootProps<T>) => {
  const { root } = calendarStyles();
  return (
    <AriaCalendar
      className={composeRenderProps(props.className, (className) => root({ className }))}
      {...props}
    />
  );
};

type CalendarHeaderProps = React.HTMLAttributes<HTMLElement>;
const CalendarHeader = ({ className, ...props }: CalendarHeaderProps) => {
  const { header } = calendarStyles();
  return <header className={header({ className })} {...props} />;
};

type CalendarGridProps = AriaCalendarGridProps;
const CalendarGrid = ({ className, ...props }: CalendarGridProps) => {
  const { grid } = calendarStyles();
  return <AriaCalendarGrid className={grid({ className })} {...props} />;
};

type CalendarGridHeaderProps = AriaCalendarGridHeaderProps;
const CalendarGridHeader = ({ className, ...props }: CalendarGridHeaderProps) => {
  const { gridHeader } = calendarStyles();
  return <AriaCalendarGridHeader className={gridHeader({ className })} {...props} />;
};

type CalendarHeaderCellProps = AriaCalendarHeaderCellProps;
const CalendarHeaderCell = ({ className, ...props }: CalendarHeaderCellProps) => {
  const { gridHeaderCell } = calendarStyles();
  return <AriaCalendarHeaderCell className={gridHeaderCell({ className })} {...props} />;
};

type CalendarGridBodyProps = AriaCalendarGridBodyProps;
const CalendarGridBody = ({ className, ...props }: CalendarGridBodyProps) => {
  const { gridBody } = calendarStyles();
  return <AriaCalendarGridBody className={gridBody({ className })} {...props} />;
};

type CalendarCellProps = AriaCalendarCellProps & { range?: boolean };
const CalendarCell = ({ range, ...props }: CalendarCellProps) => {
  const { cell } = calendarStyles({ range });
  return (
    <AriaCalendarCell
      {...props}
      className={composeRenderProps(props.className, (className) => cell({ className }))}
    />
  );
};

export type {
  CalendarProps,
  CalendarRootProps,
  CalendarGridProps,
  CalendarGridHeaderProps,
  CalendarHeaderCellProps,
  CalendarGridBodyProps,
  CalendarCellProps,
};
export {
  Calendar,
  CalendarRoot,
  CalendarHeader,
  CalendarGrid,
  CalendarGridHeader,
  CalendarHeaderCell,
  CalendarGridBody,
  CalendarCell,
  calendarStyles,
};

Update the import paths to match your project setup.

Usage

Use Calendar to allow users to select a single date.

Options

Label

An aria-label must be provided to the Calendar for accessibility. If it is labeled by a separate element, an aria-labelledby prop must be provided using the id of the labeling element instead.

<Calendar aria-label="Event date" />

Error message

Calendar tries to avoid allowing the user to select invalid dates in the first place (see Min and max values and Unavailable dates). However, if according to application logic a selected date is invalid, Use isInvalid and errorMessage props.

const [date, setDate] = React.useState(today(getLocalTimeZone()));
const { locale } = useLocale();
const isInvalid = isWeekend(date, locale);
return (
  <Calendar
    value={date}
    onChange={setDate}
    isInvalid={isInvalid}
    errorMessage={"We are closed on weekends"}
  />
);

Min and max values

By default, Calendar allows selecting any date. The minValue and maxValue props can also be used to prevent the user from selecting dates outside a certain range.

<Calendar minValue={today(getLocalTimeZone())} />

Unavailable dates

Calendar supports marking certain dates as unavailable. These dates cannot be selected by the user and are displayed with a crossed out appearance. The isDateUnavailable prop accepts a callback that is called to evaluate whether each visible date is unavailable.

const now = today(getLocalTimeZone());
const disabledRanges = [
  [now, now.add({ days: 5 })],
  [now.add({ days: 14 }), now.add({ days: 16 })],
  [now.add({ days: 23 }), now.add({ days: 24 })],
];

const { locale } = useLocale();
const isDateUnavailable = (date: DateValue) =>
  isWeekend(date, locale) ||
  disabledRanges.some(
    (interval) => date.compare(interval[0]) >= 0 && date.compare(interval[1]) <= 0
  );

return (
  <Calendar
    minValue={today(getLocalTimeZone())}
    isDateUnavailable={isDateUnavailable}
  />
);

Visible months

By default, Calendar displays a single month. The visibleMonths prop allows displaying up to 3 months at a time.

<Calendar visibleMonths={2} />

Page behaviour

The pageBehavior prop allows you to control how the calendar navigates between months.

<Calendar visibleMonths={2} pageBehavior="single" />

Disabled

The isDisabled boolean prop makes the Calendar disabled. Cells cannot be focused or selected.

<Calendar isDisabled />

Read only

The isReadOnly boolean prop makes the Calendar's value immutable. Unlike isDisabled, the Calendar remains focusable.

<Calendar isReadOnly value={today(getLocalTimeZone())} />

Uncontrolled

An initial, uncontrolled value can be provided to the Calendar using the defaultValue prop.

<Calendar defaultValue={today(getLocalTimeZone())} />

Controlled

The Calendar component can be controlled by passing the value and onChange props.

const [value, setValue] = React.useState(today(getLocalTimeZone()));
return <Calendar aria-label="Date (controlled)" value={value} onChange={setValue} />

Composition

If you need to customize things further, you can drop down to the composition level.

"use client";

import { Button } from "@/lib/components/core/default/button";
import {
  CalendarRoot,
  CalendarHeader,
  CalendarGrid,
  CalendarGridHeader,
  CalendarHeaderCell,
  CalendarGridBody,
  CalendarCell,
} from "@/lib/components/core/default/calendar";
import { Heading } from "@/lib/components/core/default/heading";
import { ChevronLeftIcon, ChevronRightIcon } from "@/lib/icons";

function Demo() {
  return (
    <CalendarRoot>
      <CalendarHeader>
        <Button slot="previous" variant="outline" shape="square" size="sm">
          <ChevronLeftIcon />
        </Button>
        <Heading className="text-sm" />
        <Button slot="next" variant="outline" shape="square" size="sm">
          <ChevronRightIcon />
        </Button>
      </CalendarHeader>
      <CalendarGrid>
        <CalendarGridHeader>
          {(day) => <CalendarHeaderCell>{day}</CalendarHeaderCell>}
        </CalendarGridHeader>
        <CalendarGridBody>{(date) => <CalendarCell date={date} />}</CalendarGridBody>
      </CalendarGrid>
    </CalendarRoot>
  );
}

API Reference

PropTypeDefaultDescription
visibleMonths
number
1
The number of months to display at once. Up to 3 months are supported.
minValue
DateValue
-
The minimum allowed date that a user may select.
maxValue
DateValue
-
The maximum allowed date that a user may select.
isDateUnavailable
(date: DateValue) => boolean
-
Callback that is called for each date of the calendar. If it returns true, then the date is unavailable.
isDisabled
boolean
false
Whether the calendar is disabled.
isReadOnly
boolean
false
Whether the calendar value is immutable.
autoFocus
boolean
false
Whether to automatically focus the calendar when it mounts.
focusedValue
DateValue
-
Controls the currently focused date within the calendar.
defaultFocusedValue
DateValue
-
The date that is focused when the calendar first mounts (uncountrolled).
isInvalid
boolean
-
Whether the current selection is invalid according to application logic.
pageBehavior
'single' | 'visible'
'visible'
Controls the behavior of paging. Pagination either works by advancing the visible page by visibleDuration (default) or one unit of visibleDuration.
value
DateValue | null
-
The current value (controlled).
defaultValue
DateValue | null
-
The default value (uncontrolled).
children
ReactNode | (values: CalendarRenderProps & {defaultChildren: ReactNode | undefined}) => ReactNode
-
The children of the component. A function may be provided to alter the children based on component state.
className
string
-
The CSS className for the element.
style
CSSProperties | (values: CalendarRenderProps & {defaultStyle: CSSProperties}) => CSSProperties
-
The inline style for the element. A function may be provided to compute the style based on component state.
EventTypeDescription
onFocusChange
(date: CalendarDate) => void
Handler that is called when the focused date changes.
onChange
(value: MappedDateValue<DateValue>) => void
Handler that is called when the value changes.

Accessibility

Keyboard interactions

KeyDescription
Tab
Moves focus to the next focusable item in the calendar.
Shift+Tab
Moves focus to the previous focusable item in the calendar.
ArrowRight
Moves focus to the next day.
ArrowLeft
Moves focus to the previous day.
ArrowDown
Moves focus to the same day of the week in the next week.
ArrowUp
Moves focus to the same day of the week in the previous week.
Space Enter
Selects the focused date.

Built by mehdibha. The source code is available on GitHub.