# Calendar

A keyboard-accessible calendar with single, multiple, and range selection.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. An agent skill is available at [/.well-known/agent-skills/site-skill.md](/.well-known/agent-skills/site-skill.md).





## Playground [#playground]

```tsx
"use client";

import { useState } from "react";
import type { DateRange } from "react-day-picker";
import { Calendar } from "@/components/ui/calendar";

export function CalendarDemo() {
  const [date, setDate] = useState<Date>();
  return (
    <>
      <Calendar mode="single" selected={date} onSelect={setDate}
        captionLayout="label"
        buttonVariant="ghost"
        showOutsideDays={true}
        showWeekNumber={false}
        numberOfMonths={1}
        weekStartsOn={0}
        motion={true}
      />
    </>
  );
}
```

## Installation [#installation]

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/calendar.json
```

## Dependencies [#dependencies]

Installed automatically by the registry CLI. For manual setup:

```bash
npm install @base-ui/react cn lucide-react motion react-day-picker@^10.0.2
```

## Usage [#usage]

```tsx
import { Calendar } from "@/components/ui/calendar";

<Calendar mode="single" selected={date} onSelect={setDate} />;
```

`Calendar` preserves react-day-picker's discriminated selection modes, localization, date matchers, and component overrides.

## Examples [#examples]

This controlled single-date example is the smallest complete selection loop. The playground also supports multiple and range modes.

<ComponentPreview name="calendar-demo" title="calendar-demo.tsx">
  <CalendarDemo />
</ComponentPreview>

### Date range [#date-range]

Range endpoints use the primary highlight; intervening dates form a connected muted band. Two months stack vertically on narrow screens. Range values can be incomplete while the user selects an end date.

<ComponentPreview name="calendar-range-demo" title="calendar-range-demo.tsx">
  <CalendarRangeDemo />
</ComponentPreview>

### Localized dropdowns and disabled dates [#localized-dropdowns-and-disabled-dates]

Indonesian labels, Monday-first weeks, month/year selectors, and disabled weekends. Navigation bounds limit the displayed months, not the dates that may be selected.

<ComponentPreview name="calendar-dropdown-demo" title="calendar-dropdown-demo.tsx">
  <CalendarDropdownDemo />
</ComponentPreview>

## Motion [#motion]

Vandor adds a directional 8px slide/fade on month changes (200ms), a selection-highlight transition (160ms), and press feedback (120ms, scale 0.96). There is no entrance animation or staggered day reveal. Month changes update the existing container rather than rendering duplicate grids. Selection and callbacks remain immediate.

Set `motion={false}` to disable these animations. System `prefers-reduced-motion` disables them automatically, including press scaling. Custom `components` replace the corresponding Vandor behavior. DayPicker's separate `animate` prop requires its own animation CSS; it is not needed for Vandor motion.

The playground's generated code reproduces configuration, not the dates/month reached through interaction. Reset restores configuration and clears transient selection/navigation.

## Consumer setup [#consumer-setup]

Use React 19, Tailwind CSS 4, Motion 12, and React DayPicker 10. Include your installed UI directory in Tailwind source scanning and provide the standard shadcn theme tokens. The default cell size is 36px; override it with `className="[--cell-size:2.75rem]"`. No DayPicker stylesheet is required for Vandor's default styling/motion.

Calendar installs as one UI file in your configured UI directory. Its shadcn-inspired native buttons are self-contained: it does not install or overwrite Vandor Button or Loading. Base UI's `useRender` composes the native refs/handlers with Motion gestures. For manual installation, install all declared packages and copy Source into your UI directory with the theme setup above.

## Storybook Stories (Optional) [#storybook-stories-optional]

Install Calendar first, then add portable stories to your existing React Storybook:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/calendar-stories.json
```

This item only installs `calendar.stories.tsx` beside Calendar in your configured UI directory. It does not reinstall Calendar, install packages, or modify `.storybook/`. Keep `@storybook/react` aligned with your existing Storybook packages. Stories are typechecked and composed against Storybook React 10.6.1.

Include `../components/ui/**/*.stories.@(js|jsx|mjs|ts|tsx)` in your existing story discovery patterns, adjusted to your UI directory. Import your application's global Tailwind stylesheet in `.storybook/preview.ts`; include the stories directory in Tailwind scanning. Stories inherit consumer theme/decorators. The `autodocs` tag requires your existing `@storybook/addon-docs` configuration; Canvas and Controls do not.

Playground controls selection mode, captions, week start, week numbers, outside days, visible months, navigation appearance, and motion. Dedicated stories cover controlled single/multiple/range selection, a populated range, disabled weekends, Indonesian localization, dropdowns, and motion opt-out.

## Behavior & Accessibility [#behavior--accessibility]

Use keyboard navigation to move between dates and months. `disabled` matchers prevent selection; `startMonth` and `endMonth` constrain navigation but do not independently disable dates.

`selected` and `onSelect` are controlled and mode-specific: `Date | undefined`, `Date[] | undefined`, or `DateRange | undefined`. Clearing is allowed unless `required` is set. Omitting `mode` renders a calendar without selection, not an implicit single-select calendar. Use `month`/`onMonthChange` for controlled navigation, or `defaultMonth` for an initial month.

Arrow keys move day focus; Page Up/Down changes months. Dropdowns retain native select semantics and visible focus rings. Date buttons preserve native refs and caller event handlers. Calendar does not submit a form field; serialize selection explicitly in your application.

Dates represent calendar days in the local timezone unless `timeZone` is supplied. Locale changes visible and accessible labels; `weekStartsOn` overrides the locale's first weekday. Keep timezone handling consistent when persisting dates, rather than treating a local midnight as a universal UTC date.

## Props [#props]

| Prop            | Type                                                                        | Default        | Description                                                                          |
| --------------- | --------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------ |
| buttonVariant   | "ghost" \| "outline" \| "default" \| "secondary" \| "destructive" \| "link" | "ghost"        | Navigation button appearance.                                                        |
| captionLayout   | "label" \| "dropdown" \| "dropdown-months" \| "dropdown-years"              | "label"        | Month/year caption layout.                                                           |
| className       | string                                                                      | undefined      | Calendar root class; merged with default styling.                                    |
| disabled        | Matcher \| Matcher\[]                                                       | undefined      | Calendar: dates that cannot be selected.                                             |
| endMonth        | Date                                                                        | undefined      | Calendar: latest navigable month.                                                    |
| locale          | Locale                                                                      | undefined      | Calendar: react-day-picker locale; affects labels and formatting.                    |
| mode            | "single" \| "multiple" \| "range"                                           | undefined      | Calendar: selection mode and corresponding selected/onSelect types.                  |
| onSelect        | DayPicker selection callback                                                | undefined      | Calendar: receives the selection for the chosen mode.                                |
| motion          | boolean                                                                     | true           | Vandor month, selection, and press motion. Reduced-motion preferences take priority. |
| numberOfMonths  | number                                                                      | 1              | Number of visible months; stacks on narrow screens.                                  |
| selected        | Date \| Date\[] \| DateRange                                                | undefined      | Calendar: selected date(s), matching mode.                                           |
| showOutsideDays | boolean                                                                     | true           | Calendar: displays adjacent-month days.                                              |
| showWeekNumber  | boolean                                                                     | false          | Displays ISO/locale week numbers.                                                    |
| startMonth      | Date                                                                        | undefined      | Calendar: earliest navigable month.                                                  |
| weekStartsOn    | 0 \| 1 \| 2 \| 3 \| 4 \| 5 \| 6                                             | locale default | First weekday; 0 is Sunday.                                                          |
| month           | Date                                                                        | undefined      | Controlled visible month; pair with onMonthChange.                                   |
| defaultMonth    | Date                                                                        | current month  | Initial visible month in uncontrolled usage.                                         |
| onMonthChange   | (month: Date) => void                                                       | undefined      | Called when the visible month changes.                                               |
| classNames      | Partial\<ClassNames>                                                        | undefined      | Overrides individual DayPicker slot classes.                                         |
| components      | Partial\<CustomComponents>                                                  | undefined      | Overrides slots, replacing Vandor motion for those slots.                            |
| required        | boolean                                                                     | false          | Prevents clearing a selection in a selection mode.                                   |
| timeZone        | string                                                                      | local timezone | DayPicker timezone for interpreting calendar dates.                                  |

The reference lists component-specific props and selected native props, not the complete React API. Other native attributes are forwarded to the rendered element.

## Source [#source]

```tsx
"use client";

// Adapted from shadcn/ui's base-nova Calendar (MIT).
/*
 * Copyright (c) 2026 Shadcn Labs
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */
import { useRender } from "@base-ui/react/use-render";
import { cn } from "cn";
import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
import {
  motion as motionElement,
  useAnimate,
  useReducedMotion,
} from "motion/react";
import * as React from "react";
import {
  DayPicker,
  getDefaultClassNames,
  useDayPicker,
} from "react-day-picker";
import type {
  ChevronProps,
  DayButtonProps,
  DayPickerProps,
  MonthProps,
  RootProps,
  WeekNumberProps,
} from "react-day-picker";

type NavigationVariant =
  | "default"
  | "secondary"
  | "outline"
  | "ghost"
  | "destructive"
  | "link";

const navigationVariants: Record<NavigationVariant, string> = {
  default:
    "border border-primary bg-primary text-primary-foreground hover:bg-primary/90",
  destructive:
    "border border-destructive bg-destructive text-white hover:bg-destructive/90",
  ghost: "hover:bg-accent hover:text-accent-foreground",
  link: "text-primary underline-offset-4 hover:underline",
  outline:
    "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
  secondary:
    "border border-input bg-secondary text-secondary-foreground hover:bg-secondary/80",
};
const calendarButtonClasses =
  "inline-flex shrink-0 cursor-pointer items-center justify-center rounded-md text-sm outline-none transition-colors duration-150 motion-reduce:transition-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0";

type CalendarProps = DayPickerProps & {
  buttonVariant?: NavigationVariant;
  /** Enables Vandor motion. System reduced-motion preferences always take priority. */
  motion?: boolean;
};

const CalendarMotionContext = React.createContext(true);

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

const CalendarMonth = ({
  calendarMonth,
  displayIndex: _displayIndex,
  ...props
}: MonthProps) => {
  const enabled = React.useContext(CalendarMotionContext);
  const reduced = useReducedMotion();
  const { dayPickerProps } = useDayPicker();
  const [scope, animate] = useAnimate<HTMLDivElement>();
  const month =
    calendarMonth.date.getFullYear() * 12 + calendarMonth.date.getMonth();
  const previous = React.useRef(month);
  React.useEffect(() => {
    const direction =
      Math.sign(month - previous.current) *
      (dayPickerProps.dir === "rtl" ? -1 : 1);
    previous.current = month;
    if (!enabled || reduced || !direction || !scope.current) {
      return;
    }
    const node = scope.current;
    const originalOpacity = node.style.opacity;
    const originalTransform = node.style.transform;
    const animation = animate(
      node,
      { opacity: [0.65, 1], x: [direction * 8, 0] },
      {
        duration: 0.2,
        ease: [0.22, 1, 0.36, 1],
      }
    );
    return () => {
      animation.stop();
      node.style.opacity = originalOpacity;
      node.style.transform = originalTransform;
    };
  }, [animate, dayPickerProps.dir, enabled, month, reduced, scope]);
  return <div {...props} ref={scope} />;
};

const CalendarChevron = ({
  className,
  orientation,
  ...props
}: ChevronProps) => {
  const icons = {
    down: ChevronDown,
    left: ChevronLeft,
    right: ChevronRight,
    up: ChevronDown,
  };
  const Icon = icons[orientation ?? "down"];
  return (
    <Icon aria-hidden="true" className={cn("size-4", className)} {...props} />
  );
};

const NativeButtonSlot = ({
  nativeProps,
  ref,
  ...props
}: React.ComponentProps<"button"> & {
  nativeProps: React.ComponentProps<"button">;
}) => useRender({ props, ref, render: <button {...nativeProps} /> });
const MotionNativeButton = motionElement.create(NativeButtonSlot);

const CalendarNativeButton = ({
  ref: forwardedRef,
  ...props
}: React.ComponentProps<"button">) => {
  const enabled = React.useContext(CalendarMotionContext);
  const reduced = useReducedMotion();
  // Keep native DayPicker handlers on the child, separate from Motion's gesture props.
  return (
    <MotionNativeButton
      nativeProps={props}
      ref={forwardedRef}
      whileTap={
        enabled && !reduced && !props.disabled && !props["aria-disabled"]
          ? { scale: 0.96 }
          : undefined
      }
      transition={{ duration: 0.12, ease: "easeOut" }}
    />
  );
};

const CalendarNavigationButton = (props: React.ComponentProps<"button">) => (
  <CalendarNativeButton
    {...props}
    disabled={props.disabled || props["aria-disabled"] === true}
  />
);

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

const Calendar = ({
  className,
  classNames,
  showOutsideDays = true,
  captionLayout = "label",
  buttonVariant = "ghost",
  motion = true,
  components,
  ...props
}: CalendarProps) => {
  const defaults = getDefaultClassNames();
  return (
    <CalendarMotionContext.Provider value={motion}>
      <DayPicker
        {...props}
        showOutsideDays={showOutsideDays}
        captionLayout={captionLayout}
        className={cn(
          "group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(9)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
          className
        )}
        classNames={{
          button_next: cn(
            calendarButtonClasses,
            navigationVariants[buttonVariant],
            "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
            defaults.button_next
          ),
          button_previous: cn(
            calendarButtonClasses,
            navigationVariants[buttonVariant],
            "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
            defaults.button_previous
          ),
          caption_label: cn(
            "text-sm font-medium select-none",
            captionLayout !== "label" &&
              "flex items-center gap-1 [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
            defaults.caption_label
          ),
          day: cn(
            "group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)",
            props.showWeekNumber
              ? "[&:nth-child(2)[data-selected=true]_button]:rounded-s-(--cell-radius)"
              : "[&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius)",
            defaults.day
          ),
          disabled: cn("text-muted-foreground opacity-50", defaults.disabled),
          dropdown: cn(
            "absolute inset-0 cursor-pointer bg-popover opacity-0",
            defaults.dropdown
          ),
          dropdown_root: cn(
            "relative rounded-(--cell-radius) border border-input px-1.5 py-1 has-focus-visible:border-ring has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50",
            defaults.dropdown_root
          ),
          dropdowns: cn(
            "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
            defaults.dropdowns
          ),
          hidden: cn("invisible", defaults.hidden),
          month: cn("flex w-full flex-col gap-4", defaults.month),
          month_caption: cn(
            "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
            defaults.month_caption
          ),
          month_grid: cn("w-full border-collapse", defaults.month_grid),
          months: cn(
            "relative flex flex-col gap-4 md:flex-row",
            defaults.months
          ),
          nav: cn(
            "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
            defaults.nav
          ),
          outside: cn("text-muted-foreground", defaults.outside),
          range_end: cn(
            "rounded-e-(--cell-radius) bg-muted",
            defaults.range_end
          ),
          range_middle: cn("rounded-none bg-muted", defaults.range_middle),
          range_start: cn(
            "rounded-s-(--cell-radius) bg-muted",
            defaults.range_start
          ),
          root: cn("w-fit", defaults.root),
          today: cn(
            "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
            defaults.today
          ),
          week: cn("mt-2 flex w-full", defaults.week),
          week_number: cn(
            "text-[0.8rem] text-muted-foreground select-none",
            defaults.week_number
          ),
          week_number_header: cn(
            "w-(--cell-size) select-none",
            defaults.week_number_header
          ),
          weekday: cn(
            "flex-1 text-center text-[0.8rem] font-normal text-muted-foreground select-none",
            defaults.weekday
          ),
          weekdays: cn("flex", defaults.weekdays),
          ...classNames,
        }}
        components={{
          Chevron: CalendarChevron,
          // Stable slot identity preserves focus during parent updates.
          // eslint-disable-next-line no-use-before-define
          DayButton: CalendarDayButton,
          Month: CalendarMonth,
          NextMonthButton: CalendarNavigationButton,
          PreviousMonthButton: CalendarNavigationButton,
          Root: CalendarRoot,
          WeekNumber: CalendarWeekNumber,
          ...components,
        }}
      />
    </CalendarMotionContext.Provider>
  );
};

const CalendarDayButton = ({
  className,
  day,
  modifiers,
  children,
  ref: forwardedRef,
  ...props
}: DayButtonProps & { ref?: React.Ref<HTMLButtonElement> }) => {
  const enabled = React.useContext(CalendarMotionContext);
  const reduced = useReducedMotion();
  const ref = React.useRef<HTMLButtonElement>(null);
  const selected = Boolean(modifiers.selected);
  const endpoint = Boolean(modifiers.range_start || modifiers.range_end);
  const single = selected && !endpoint && !modifiers.range_middle;
  React.useEffect(() => {
    if (modifiers.focused) {
      ref.current?.focus();
    }
  }, [modifiers.focused]);
  const mergedRef = React.useCallback(
    (node: HTMLButtonElement | null) => {
      ref.current = node;
      if (typeof forwardedRef === "function") {
        return forwardedRef(node);
      }
      if (forwardedRef) {
        forwardedRef.current = node;
      }
    },
    [forwardedRef]
  );
  return (
    <CalendarNativeButton
      {...props}
      ref={mergedRef}
      data-day={day.isoDate}
      data-selected-single={single}
      data-range-start={Boolean(modifiers.range_start)}
      data-range-end={Boolean(modifiers.range_end)}
      data-range-middle={Boolean(modifiers.range_middle)}
      className={cn(
        calendarButtonClasses,
        navigationVariants.ghost,
        "relative isolate flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal 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-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:text-primary-foreground data-[range-end=true]:text-primary-foreground data-[selected-single=true]:text-primary-foreground [&>span]:text-xs [&>span]:opacity-70",
        getDefaultClassNames().day_button,
        className
      )}
    >
      <motionElement.span
        aria-hidden="true"
        data-slot="calendar-selection"
        initial={false}
        animate={{
          opacity: single || endpoint ? 1 : 0,
          scale: single || endpoint || !enabled || reduced ? 1 : 0.92,
        }}
        transition={{
          duration: enabled && !reduced ? 0.16 : 0,
          ease: [0.22, 1, 0.36, 1],
        }}
        className="pointer-events-none absolute inset-0 -z-10 rounded-[inherit] bg-primary"
      />
      {children}
    </CalendarNativeButton>
  );
};

export { Calendar, CalendarDayButton };
export type { CalendarProps };

```

## Credits [#credits]

* [shadcn/ui](https://ui.shadcn.com/docs/components/base/calendar): Calendar adapted from the MIT-licensed base-nova component, with Vandor motion and theme adjustments.

* [React DayPicker](https://daypicker.dev): Accessible calendar, date selection, localization, and keyboard navigation.
