For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Calendar

Put a month right on the page for picking a delivery day, a deadline, or a run of nights.

Installation

$ pnpm dlx shadcn@latest add @cobalt/calendar

Ranges

mode="range" picks a start and an end. Set numberOfMonths={2} for two months side by side.

Limits and unavailable days

Days outside disabled fade and can't be picked. Days passed as unavailable stay in the grid, struck through, so the rhythm of the month reads at a glance.

Right to left

Pass dir="rtl" and a locale. The chevrons mirror and the weekday names follow the locale.

Source

"use client";

import { RiArrowDownSLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";
import { DayPicker, getDefaultClassNames } from "react-day-picker";
import type {
  ChevronProps,
  DayButton,
  Locale,
  DateRange,
  Matcher,
  RootProps,
  WeekNumberProps,
} from "react-day-picker";

import { Button, buttonVariants } from "@/components/ui/button";

// A month on the page rather than in a popover: the 326px card, the 16px month
// chevrons, the 32px day cells 12px apart with the selected day's accent pill
// and the range band that bridges those gaps. Today can carry a small accent
// dot, and days passed as `unavailable` are struck through. Colours come from
// theme-cobalt with semantic fallbacks, so the calendar also works without it.

const navButtonClass =
  "size-4 cursor-pointer rounded-[3px] p-0 text-[var(--foreground-secondary,var(--muted-foreground))] select-none hover:bg-[var(--calendar-hover,var(--calendar-day-hover,var(--surface-strong)))] dark:hover:bg-[var(--calendar-hover,var(--calendar-day-hover,var(--surface-strong)))] motion-reduce:transition-none [&_svg]:size-4";

// Day cells read these from context rather than closing over Calendar's props:
// a component created inside Calendar gets a new identity on every render, so
// React remounted every day on each click and the hover and selection fades,
// and the keyboard focus, were thrown away with it.
const CalendarContext = React.createContext<{
  locale?: Partial<Locale>;
  showToday: boolean;
}>({ showToday: false });

function CalendarChevron({ className, orientation, ...props }: ChevronProps) {
  // The month chevrons are a 16px glyph drawn with a 2px round-capped stroke,
  // heavier than the Remix arrows the dropdown uses.
  if (orientation === "left" || orientation === "right") {
    return (
      <svg
        width="16"
        height="16"
        viewBox="0 0 16 16"
        fill="none"
        aria-hidden
        className={cn("size-4", className)}
        {...props}
      >
        <path
          d={
            orientation === "left"
              ? "M9 4L5.70711 7.29289C5.31658 7.68342 5.31658 8.31658 5.70711 8.70711L9 12"
              : "M7 4L10.2929 7.29289C10.6834 7.68342 10.6834 8.31658 10.2929 8.70711L7 12"
          }
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
        />
      </svg>
    );
  }

  return <RiArrowDownSLine className={cn("size-4", className)} {...props} />;
}

function CalendarRoot({ className, rootRef, ...props }: RootProps) {
  return (
    <div
      data-slot="calendar"
      ref={rootRef}
      className={cn(className)}
      {...props}
    />
  );
}

function CalendarWeekNumber({ children, ...props }: WeekNumberProps) {
  return (
    <td {...props}>
      <div className="flex size-(--cell-size) items-center justify-center text-center">
        {children}
      </div>
    </td>
  );
}

function CalendarDay(props: React.ComponentProps<typeof DayButton>) {
  const { locale, showToday } = React.useContext(CalendarContext);

  return <CalendarDayButton locale={locale} showToday={showToday} {...props} />;
}

// A range is picked in two clicks, and the band follows the pointer in between
// (React Aria's RangeCalendar). react-day-picker would instead stretch the
// existing range towards the first click, so range mode keeps its own anchor
// and hands the finished range to `onSelect` on the second click.
function orderRange(a: Date, b: Date): DateRange {
  return a <= b ? { from: a, to: b } : { from: b, to: a };
}

function useRangeAnchor(
  props: React.ComponentProps<typeof DayPicker>
): Partial<React.ComponentProps<typeof DayPicker>> {
  const range = props as React.ComponentProps<typeof DayPicker> & {
    mode: "range";
  };
  const isRange = props.mode === "range";
  const [anchor, setAnchor] = React.useState<Date | undefined>();
  const [hovered, setHovered] = React.useState<Date | undefined>();
  const [uncontrolled, setUncontrolled] = React.useState<DateRange | undefined>(
    () =>
      isRange
        ? (props as { defaultSelected?: DateRange }).defaultSelected
        : undefined
  );

  React.useEffect(() => {
    if (!anchor) {
      return;
    }
    const cancel = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        setAnchor(undefined);
        setHovered(undefined);
      }
    };
    document.addEventListener("keydown", cancel);
    return () => document.removeEventListener("keydown", cancel);
  }, [anchor]);

  if (!isRange) {
    return {};
  }

  const committed = "selected" in range ? range.selected : uncontrolled;
  const preview = anchor ? orderRange(anchor, hovered ?? anchor) : committed;

  return {
    selected: preview,
    // A no-op keeps react-day-picker reading `selected` from props.
    onSelect: () => {},
    onDayClick: (day, modifiers, event) => {
      range.onDayClick?.(day, modifiers, event);
      if (!anchor) {
        setAnchor(day);
        setHovered(day);
        return;
      }
      const next = orderRange(anchor, day);
      setAnchor(undefined);
      setHovered(undefined);
      setUncontrolled(next);
      (range.onSelect as ((...args: unknown[]) => void) | undefined)?.(
        next,
        day,
        modifiers,
        event
      );
    },
    onDayMouseEnter: (day, modifiers, event) => {
      range.onDayMouseEnter?.(day, modifiers, event);
      if (anchor) {
        setHovered(day);
      }
    },
    onDayFocus: (day, modifiers, event) => {
      range.onDayFocus?.(day, modifiers, event);
      if (anchor) {
        setHovered(day);
      }
    },
  } as Partial<React.ComponentProps<typeof DayPicker>>;
}

function Calendar({
  className,
  classNames,
  showOutsideDays = true,
  captionLayout = "label",
  buttonVariant = "ghost",
  locale,
  formatters,
  components,
  showToday = false,
  unavailable,
  disabled,
  modifiers,
  ...props
}: React.ComponentProps<typeof DayPicker> & {
  buttonVariant?: React.ComponentProps<typeof Button>["variant"];
  /** Mark today with a dot under the date. */
  showToday?: boolean;
  /** Days that can be focused but not picked, drawn struck through. */
  unavailable?: Matcher | Matcher[];
}) {
  const defaultClassNames = getDefaultClassNames();

  // Two-letter weekday names only work in Latin scripts: cut down, every
  // Arabic name keeps just its article. Other scripts take the locale's
  // narrow names instead.
  const formatWeekday = React.useMemo(() => {
    const short = new Intl.DateTimeFormat(locale?.code, { weekday: "short" });
    const latin = /^\p{Script=Latin}/u.test(short.format(new Date(2024, 0, 7)));
    const formatter = latin
      ? short
      : new Intl.DateTimeFormat(locale?.code, { weekday: "narrow" });
    return (day: Date) => {
      const label = formatter.format(day);
      return latin ? label.slice(0, 2) : label;
    };
  }, [locale?.code]);

  // `unavailable` mirrors React Aria's isDateUnavailable: the day is struck
  // through and cannot be picked, so it also joins the disabled matchers.
  const disabledMatchers = React.useMemo(() => {
    if (disabled === undefined) {
      return unavailable;
    }

    if (unavailable === undefined) {
      return disabled;
    }

    return [disabled, unavailable].flat();
  }, [disabled, unavailable]);

  const context = React.useMemo(
    () => ({ locale, showToday }),
    [locale, showToday]
  );

  const rangeProps = useRangeAnchor(props);

  return (
    <CalendarContext.Provider value={context}>
      <DayPicker
        showOutsideDays={showOutsideDays}
        className={cn(
          "group/calendar [--cell-radius:8px] [--cell-size:32px]",
          className
        )}
        captionLayout={captionLayout}
        locale={locale}
        navLayout="around"
        disabled={disabledMatchers}
        modifiers={unavailable ? { ...modifiers, unavailable } : modifiers}
        formatters={{
          formatWeekdayName: formatWeekday,
          formatMonthDropdown: (date) =>
            date.toLocaleString(locale?.code, { month: "short" }),
          ...formatters,
        }}
        classNames={{
          button_next: cn(
            buttonVariants({ variant: buttonVariant }),
            navButtonClass,
            "absolute end-[15px] top-[15px] z-10",
            defaultClassNames.button_next
          ),
          button_previous: cn(
            buttonVariants({ variant: buttonVariant }),
            navButtonClass,
            "absolute start-[15px] top-[15px] z-10",
            defaultClassNames.button_previous
          ),
          caption_label: cn(
            "font-medium select-none",
            captionLayout === "label"
              ? "text-sm"
              : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
            defaultClassNames.caption_label
          ),
          day: cn(
            "group/day relative size-8 p-0 text-center select-none before:pointer-events-none before:absolute before:inset-y-0 before:-inset-x-1.5 before:bg-[var(--calendar-range,var(--accent))] before:opacity-0 before:transition-[opacity,border-radius] before:duration-100 before:ease-out motion-reduce:before:transition-none first:before:start-0 first:before:rounded-s-[8px] last:before:end-0 last:before:rounded-e-[8px]",
            defaultClassNames.day
          ),
          disabled: cn("text-muted-foreground", defaultClassNames.disabled),
          dropdown: cn(
            "absolute inset-0 bg-popover opacity-0",
            defaultClassNames.dropdown
          ),
          dropdown_root: cn(
            "relative rounded-(--cell-radius)",
            defaultClassNames.dropdown_root
          ),
          dropdowns: cn(
            "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
            defaultClassNames.dropdowns
          ),
          hidden: cn("invisible", defaultClassNames.hidden),
          month: cn(
            "relative flex w-[326px] shrink-0 flex-col gap-5 rounded-[16px] bg-popover p-[15px] shadow-[shadow:var(--shadow-control,0_1px_2px_0_rgb(0_0_0/0.05))]",
            defaultClassNames.month
          ),
          month_caption: cn(
            "flex h-4 w-full items-center justify-center px-8",
            defaultClassNames.month_caption
          ),
          month_grid: cn(
            "-m-3 border-separate [border-spacing:12px_12px]",
            defaultClassNames.month_grid
          ),
          months: cn(
            "relative flex flex-wrap justify-center gap-2",
            defaultClassNames.months
          ),
          nav: cn(
            "absolute inset-x-[15px] top-[15px] z-10 flex items-center justify-between gap-1",
            defaultClassNames.nav
          ),
          outside: cn("text-muted-foreground", defaultClassNames.outside),
          range_end: cn(
            "before:end-1/2 before:opacity-100",
            defaultClassNames.range_end
          ),
          range_middle: cn(
            "before:opacity-100",
            defaultClassNames.range_middle
          ),
          range_start: cn(
            "before:start-1/2 before:opacity-100 [&.rdp-range_end]:before:opacity-0",
            defaultClassNames.range_start
          ),
          root: cn("w-fit", defaultClassNames.root),
          today: cn("", defaultClassNames.today),
          week: cn("", defaultClassNames.week),
          week_number: cn(
            "text-[0.8rem] text-muted-foreground select-none",
            defaultClassNames.week_number
          ),
          week_number_header: cn(
            "w-(--cell-size) select-none",
            defaultClassNames.week_number_header
          ),
          weekday: cn(
            "size-6 p-0 text-center text-sm font-medium text-[var(--foreground-secondary,var(--muted-foreground))] select-none",
            defaultClassNames.weekday
          ),
          weekdays: cn("", defaultClassNames.weekdays),
          ...classNames,
        }}
        components={{
          Chevron: CalendarChevron,
          DayButton: CalendarDay,
          Root: CalendarRoot,
          WeekNumber: CalendarWeekNumber,
          ...components,
        }}
        {...({ ...props, ...rangeProps } as React.ComponentProps<
          typeof DayPicker
        >)}
      />
    </CalendarContext.Provider>
  );
}

function CalendarDayButton({
  className,
  day,
  modifiers,
  locale,
  showToday = false,
  children,
  ...props
}: React.ComponentProps<typeof DayButton> & {
  locale?: Partial<Locale>;
  showToday?: boolean;
}) {
  const defaultClassNames = getDefaultClassNames();

  const ref = React.useRef<HTMLButtonElement>(null);
  React.useEffect(() => {
    if (modifiers.focused) {
      ref.current?.focus();
    }
  }, [modifiers.focused]);

  const isSelectedSingle =
    modifiers.selected &&
    !modifiers.range_start &&
    !modifiers.range_end &&
    !modifiers.range_middle;
  // The dot under today inverts on the accent pill so it stays visible there.
  const onEdge =
    isSelectedSingle || modifiers.range_start || modifiers.range_end;

  return (
    <Button
      ref={ref}
      variant="ghost"
      size="icon"
      data-day={day.date.toLocaleDateString(locale?.code)}
      data-selected-single={isSelectedSingle}
      data-range-start={modifiers.range_start}
      data-range-end={modifiers.range_end}
      data-range-middle={modifiers.range_middle}
      data-unavailable={modifiers.unavailable}
      className={cn(
        // Hover is a surface behind the day, so a selected day keeps its pill:
        // each selected state re-points --calendar-hover at the colour the
        // cell already has instead of fighting the hover class. The selected
        // fill sits on a `before:` layer so it switches instantly instead of
        // fading with the hover transition.
        "relative isolate z-10 size-8 before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] min-w-8 rounded-[8px] border-0 p-0 text-sm leading-5 font-medium text-foreground transition-[background-color,border-radius] duration-100 ease-out hover:bg-[var(--calendar-hover,var(--calendar-day-hover,var(--surface-strong)))] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-0 disabled:opacity-100 disabled:text-[var(--foreground-faint,var(--muted-foreground))] data-[unavailable=true]:line-through data-[range-end=true]:rounded-s-none data-[range-end=true]:before:bg-[var(--calendar-range-edge,var(--accent))] data-[range-middle=true]:bg-transparent data-[range-start=true]:rounded-e-none data-[range-start=true]:before:bg-[var(--calendar-range-edge,var(--accent))] data-[selected-single=true]:before:bg-[var(--calendar-range-edge,var(--accent))] data-[range-start=true]:data-[range-end=true]:rounded-[8px] data-[range-middle=true]:[--calendar-hover:transparent] data-[range-end=true]:[--calendar-hover:var(--calendar-range-edge,var(--accent))] data-[range-start=true]:[--calendar-hover:var(--calendar-range-edge,var(--accent))] data-[selected-single=true]:[--calendar-hover:var(--calendar-range-edge,var(--accent))] dark:hover:bg-[var(--calendar-hover,var(--calendar-day-hover,var(--surface-strong)))] motion-reduce:transition-none motion-reduce:active:scale-100",
        defaultClassNames.day,
        className
      )}
      {...props}
    >
      {children}
      {showToday && modifiers.today ? (
        <span
          aria-hidden
          data-slot="calendar-today-dot"
          className={cn(
            "pointer-events-none absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full",
            onEdge ? "bg-foreground" : "bg-ring"
          )}
        />
      ) : null}
    </Button>
  );
}

export { Calendar, CalendarDayButton };