Date Picker

Single-date picker with month navigation, built on React Aria.

Single date

Month navigation in a popover.

function DatePickerDemo() {
  return (
    <div className="flex flex-wrap items-start gap-4">
      <DatePicker aria-label="Due date" />
    </div>
  );
}
function DatePickerDemo() {
  return (
    <div className="flex flex-wrap items-start gap-4">
      <DatePicker aria-label="Due date" />
    </div>
  );
}

Installation

npx shadcn@latest add https://boardcn.dev/r/date-picker.json
npx shadcn@latest add https://boardcn.dev/r/date-picker.json

npm packages

  • @internationalized/date
  • @remixicon/react
  • motion
  • react-aria-components

BoardCN dependencies

The CLI installs these for you — you do not need to add them yourself.

Source

The 2 files the CLI copies into your project.

components/base/date-picker/date-picker.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { Calendar, Dialog, Popover } from "react-aria-components";
import type { CalendarDate } from "@internationalized/date";
import { RiCalendarLine } from "@remixicon/react";
import { AnimatePresence, motion } from "motion/react";
import { Button } from "@/components/base/buttons/button";
import {
  DateChipInput,
  MonthPanel,
  formatTriggerDate,
  popoverClassName,
  triggerButtonClassName,
} from "@/components/base/date-picker/shared";
import { cx } from "@/utils/cx";
import { useDismissOnOutsidePress } from "@/utils/use-dismiss-on-outside-press";

export interface DatePickerProps {
  value?: CalendarDate | null;
  defaultValue?: CalendarDate | null;
  onChange?: (value: CalendarDate | null) => void;
  isDisabled?: boolean;
  className?: string;
  "aria-label"?: string;
  /** Anchor the popover to an external element instead of DatePicker's own
   *  trigger button (which is hidden when this is provided). Pair with
   *  `isOpen`/`onOpenChange` for full external control. */
  triggerRef?: RefObject<HTMLElement | null>;
  isOpen?: boolean;
  onOpenChange?: (isOpen: boolean) => void;
}

export function DatePicker({
  value,
  defaultValue = null,
  onChange,
  isDisabled,
  className,
  "aria-label": ariaLabel = "Date",
  triggerRef: externalTriggerRef,
  isOpen: controlledIsOpen,
  onOpenChange: controlledOnOpenChange,
}: DatePickerProps) {
  const ownTriggerRef = useRef<HTMLButtonElement>(null);
  const triggerRef = externalTriggerRef ?? ownTriggerRef;
  const isExternal = externalTriggerRef !== undefined;
  const popoverRef = useRef<HTMLElement>(null);

  const [internalOpen, setInternalOpen] = useState(false);
  const isOpen = isExternal ? (controlledIsOpen ?? false) : internalOpen;
  const setIsOpen = isExternal ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen;

  const isControlled = value !== undefined;
  const [internalValue, setInternalValue] = useState<CalendarDate | null>(defaultValue);
  const committedValue = isControlled ? (value ?? null) : internalValue;

  const [pendingValue, setPendingValue] = useState<CalendarDate | null>(committedValue);
  // React Aria's `Calendar` keeps its own internal `visibleRange` state for
  // as long as it stays mounted (Popover keeps its content mounted between
  // opens for the exit animation), so it never re-derives the visible month
  // from a later `value` change on its own. Bumping this on every open and
  // keying `Calendar` on it forces a fresh mount showing the right month —
  // needed for the external-trigger case, where `value` can jump (e.g. the
  // calendar template's month switcher) between one open and the next.
  const [openKey, setOpenKey] = useState(0);

  // Re-sync the calendar's displayed month/selection to the current
  // committed value every time the popover opens — not just when DatePicker's
  // own trigger toggles it, but also when `isOpen` is flipped true by an
  // external trigger (`triggerRef`), which never runs `openChange` below.
  useEffect(() => {
    if (isOpen) {
      setPendingValue(committedValue);
      setOpenKey((k) => k + 1);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps -- resync on the open transition only, not every committedValue change
  }, [isOpen]);

  const commit = (next: CalendarDate | null) => {
    if (!isControlled) setInternalValue(next);
    onChange?.(next);
    setIsOpen(false);
  };

  const openChange = (open: boolean) => {
    setIsOpen(open);
  };

  useDismissOnOutsidePress(isOpen, () => setIsOpen(false), [triggerRef, popoverRef]);

  return (
    <>
      {!isExternal && (
        <button
          ref={ownTriggerRef}
          type="button"
          disabled={isDisabled}
          onClick={() => openChange(!isOpen)}
          className={cx(triggerButtonClassName, className)}
        >
          <RiCalendarLine className="size-5 shrink-0 text-foreground-icon-primary" aria-hidden />
          <span className="flex items-center justify-center whitespace-nowrap px-1 text-body-medium text-text-primary">
            {committedValue ? formatTriggerDate(committedValue) : "Select date"}
          </span>
        </button>
      )}
      <Popover
        ref={popoverRef}
        triggerRef={triggerRef}
        isOpen={isOpen}
        onOpenChange={openChange}
        offset={4}
        placement="bottom end"
        isNonModal
        className={popoverClassName}
      >
        <Dialog aria-label={ariaLabel} className="outline-none">
          <Calendar key={openKey} aria-label={ariaLabel} value={pendingValue} onChange={setPendingValue}>
            <div className="flex flex-col pt-2 pr-2 pb-3 pl-2">
              <MonthPanel offset={0} showPrev showNext />
              <div className="flex items-center justify-between pt-3 pr-4 pl-4">
                <div>
                  <AnimatePresence>
                    {pendingValue && (
                      <motion.div
                        key="date-summary"
                        initial={{ opacity: 0, y: -12 }}
                        animate={{ opacity: 1, y: 0 }}
                        exit={{ opacity: 0, y: -12 }}
                        transition={{ duration: 0.25, ease: [0.34, 1.2, 0.64, 1] }}
                      >
                        <DateChipInput date={pendingValue} label="Date" onCommit={setPendingValue} />
                      </motion.div>
                    )}
                  </AnimatePresence>
                </div>
                <div className="flex items-center gap-2.5">
                  <Button
                    variant="secondary"
                    onClick={() => {
                      setPendingValue(committedValue);
                      setIsOpen(false);
                    }}
                  >
                    Cancel
                  </Button>
                  <Button onClick={() => commit(pendingValue)} disabled={!pendingValue}>
                    Apply
                  </Button>
                </div>
              </div>
            </div>
          </Calendar>
        </Dialog>
      </Popover>
    </>
  );
}
"use client";

import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { Calendar, Dialog, Popover } from "react-aria-components";
import type { CalendarDate } from "@internationalized/date";
import { RiCalendarLine } from "@remixicon/react";
import { AnimatePresence, motion } from "motion/react";
import { Button } from "@/components/base/buttons/button";
import {
  DateChipInput,
  MonthPanel,
  formatTriggerDate,
  popoverClassName,
  triggerButtonClassName,
} from "@/components/base/date-picker/shared";
import { cx } from "@/utils/cx";
import { useDismissOnOutsidePress } from "@/utils/use-dismiss-on-outside-press";

export interface DatePickerProps {
  value?: CalendarDate | null;
  defaultValue?: CalendarDate | null;
  onChange?: (value: CalendarDate | null) => void;
  isDisabled?: boolean;
  className?: string;
  "aria-label"?: string;
  /** Anchor the popover to an external element instead of DatePicker's own
   *  trigger button (which is hidden when this is provided). Pair with
   *  `isOpen`/`onOpenChange` for full external control. */
  triggerRef?: RefObject<HTMLElement | null>;
  isOpen?: boolean;
  onOpenChange?: (isOpen: boolean) => void;
}

export function DatePicker({
  value,
  defaultValue = null,
  onChange,
  isDisabled,
  className,
  "aria-label": ariaLabel = "Date",
  triggerRef: externalTriggerRef,
  isOpen: controlledIsOpen,
  onOpenChange: controlledOnOpenChange,
}: DatePickerProps) {
  const ownTriggerRef = useRef<HTMLButtonElement>(null);
  const triggerRef = externalTriggerRef ?? ownTriggerRef;
  const isExternal = externalTriggerRef !== undefined;
  const popoverRef = useRef<HTMLElement>(null);

  const [internalOpen, setInternalOpen] = useState(false);
  const isOpen = isExternal ? (controlledIsOpen ?? false) : internalOpen;
  const setIsOpen = isExternal ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen;

  const isControlled = value !== undefined;
  const [internalValue, setInternalValue] = useState<CalendarDate | null>(defaultValue);
  const committedValue = isControlled ? (value ?? null) : internalValue;

  const [pendingValue, setPendingValue] = useState<CalendarDate | null>(committedValue);
  // React Aria's `Calendar` keeps its own internal `visibleRange` state for
  // as long as it stays mounted (Popover keeps its content mounted between
  // opens for the exit animation), so it never re-derives the visible month
  // from a later `value` change on its own. Bumping this on every open and
  // keying `Calendar` on it forces a fresh mount showing the right month —
  // needed for the external-trigger case, where `value` can jump (e.g. the
  // calendar template's month switcher) between one open and the next.
  const [openKey, setOpenKey] = useState(0);

  // Re-sync the calendar's displayed month/selection to the current
  // committed value every time the popover opens — not just when DatePicker's
  // own trigger toggles it, but also when `isOpen` is flipped true by an
  // external trigger (`triggerRef`), which never runs `openChange` below.
  useEffect(() => {
    if (isOpen) {
      setPendingValue(committedValue);
      setOpenKey((k) => k + 1);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps -- resync on the open transition only, not every committedValue change
  }, [isOpen]);

  const commit = (next: CalendarDate | null) => {
    if (!isControlled) setInternalValue(next);
    onChange?.(next);
    setIsOpen(false);
  };

  const openChange = (open: boolean) => {
    setIsOpen(open);
  };

  useDismissOnOutsidePress(isOpen, () => setIsOpen(false), [triggerRef, popoverRef]);

  return (
    <>
      {!isExternal && (
        <button
          ref={ownTriggerRef}
          type="button"
          disabled={isDisabled}
          onClick={() => openChange(!isOpen)}
          className={cx(triggerButtonClassName, className)}
        >
          <RiCalendarLine className="size-5 shrink-0 text-foreground-icon-primary" aria-hidden />
          <span className="flex items-center justify-center whitespace-nowrap px-1 text-body-medium text-text-primary">
            {committedValue ? formatTriggerDate(committedValue) : "Select date"}
          </span>
        </button>
      )}
      <Popover
        ref={popoverRef}
        triggerRef={triggerRef}
        isOpen={isOpen}
        onOpenChange={openChange}
        offset={4}
        placement="bottom end"
        isNonModal
        className={popoverClassName}
      >
        <Dialog aria-label={ariaLabel} className="outline-none">
          <Calendar key={openKey} aria-label={ariaLabel} value={pendingValue} onChange={setPendingValue}>
            <div className="flex flex-col pt-2 pr-2 pb-3 pl-2">
              <MonthPanel offset={0} showPrev showNext />
              <div className="flex items-center justify-between pt-3 pr-4 pl-4">
                <div>
                  <AnimatePresence>
                    {pendingValue && (
                      <motion.div
                        key="date-summary"
                        initial={{ opacity: 0, y: -12 }}
                        animate={{ opacity: 1, y: 0 }}
                        exit={{ opacity: 0, y: -12 }}
                        transition={{ duration: 0.25, ease: [0.34, 1.2, 0.64, 1] }}
                      >
                        <DateChipInput date={pendingValue} label="Date" onCommit={setPendingValue} />
                      </motion.div>
                    )}
                  </AnimatePresence>
                </div>
                <div className="flex items-center gap-2.5">
                  <Button
                    variant="secondary"
                    onClick={() => {
                      setPendingValue(committedValue);
                      setIsOpen(false);
                    }}
                  >
                    Cancel
                  </Button>
                  <Button onClick={() => commit(pendingValue)} disabled={!pendingValue}>
                    Apply
                  </Button>
                </div>
              </div>
            </div>
          </Calendar>
        </Dialog>
      </Popover>
    </>
  );
}

Props

Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.

ChevronLeft16

PropTypeDefaultDescription
classNamestring

ChevronRight16

PropTypeDefaultDescription
classNamestring

DateChipInput

One editable "DD/MM/YYYY" chip. Keeps its own draft text while typing so the field doesn't reformat on every keystroke; commits on blur/Enter, reverting to the last valid value if the text doesn't parse.

PropTypeDefaultDescription
daterequiredCalendarDate
labelrequiredstring
onCommitrequired(date: CalendarDate) => void

DatePicker

PropTypeDefaultDescription
aria-labelstringDate
classNamestring
defaultValueCalendarDatenull
isDisabledboolean
isOpenboolean
onChange(value: CalendarDate) => void
onOpenChange(isOpen: boolean) => void
triggerRefRefObject<HTMLElement>Anchor the popover to an external element instead of DatePicker's own trigger button (which is hidden when this is provided). Pair with `isOpen`/`onOpenChange` for full external control.
valueCalendarDate

DayCell

PropTypeDefaultDescription
isRangerequiredboolean

formatChipDate

PropTypeDefaultDescription
#privaterequiredany
addrequired(duration: DateDuration) => CalendarDateReturns a new `CalendarDate` with the given duration added to it.
calendarrequiredCalendarThe calendar system associated with this date, e.g. Gregorian.
comparerequired(b: AnyCalendarDate) => numberCompares this date with another. A negative result indicates that this date is before the given one, and a positive date indicates that it is after.
copyrequired() => CalendarDateReturns a copy of this date.
cyclerequired(field: keyof DateFields, amount: number, options?: CycleOptions) => CalendarDateReturns a new `CalendarDate` with the given field adjusted by a specified amount. When the resulting value reaches the limits of the field, it wraps around.
dayrequirednumberThe day number within the month.
erarequiredstringThe calendar era for this date, e.g. "BC" or "AD".
monthrequirednumberThe month number within the year. Note that some calendar systems such as Hebrew may have a variable number of months per year. Therefore, month numbers may not always correspond to the same month names in different years.
setrequired(fields: DateFields) => CalendarDateReturns a new `CalendarDate` with the given fields set to the provided values. Other fields will be constrained accordingly.
subtractrequired(duration: DateDuration) => CalendarDateReturns a new `CalendarDate` with the given duration subtracted from it.
toDaterequired(timeZone: string) => DateConverts the date to a native JavaScript Date object, with the time set to midnight in the given time zone.
yearrequirednumberThe year of this date within the era.
toString() => stringConverts the date to an ISO 8601 formatted string.

formatTriggerDate

PropTypeDefaultDescription
#privaterequiredany
addrequired(duration: DateDuration) => CalendarDateReturns a new `CalendarDate` with the given duration added to it.
calendarrequiredCalendarThe calendar system associated with this date, e.g. Gregorian.
comparerequired(b: AnyCalendarDate) => numberCompares this date with another. A negative result indicates that this date is before the given one, and a positive date indicates that it is after.
copyrequired() => CalendarDateReturns a copy of this date.
cyclerequired(field: keyof DateFields, amount: number, options?: CycleOptions) => CalendarDateReturns a new `CalendarDate` with the given field adjusted by a specified amount. When the resulting value reaches the limits of the field, it wraps around.
dayrequirednumberThe day number within the month.
erarequiredstringThe calendar era for this date, e.g. "BC" or "AD".
monthrequirednumberThe month number within the year. Note that some calendar systems such as Hebrew may have a variable number of months per year. Therefore, month numbers may not always correspond to the same month names in different years.
setrequired(fields: DateFields) => CalendarDateReturns a new `CalendarDate` with the given fields set to the provided values. Other fields will be constrained accordingly.
subtractrequired(duration: DateDuration) => CalendarDateReturns a new `CalendarDate` with the given duration subtracted from it.
toDaterequired(timeZone: string) => DateConverts the date to a native JavaScript Date object, with the time set to midnight in the given time zone.
yearrequirednumberThe year of this date within the era.
toString() => stringConverts the date to an ISO 8601 formatted string.

MonthPanel

PropTypeDefaultDescription
offsetrequirednumber
barebooleanfalseSkip the panel's own card chrome (width, bg, padding, shadow) so it can be embedded directly inside a caller-styled container instead — used by the calendar template's inline month switcher, which supplies its own card (matching a different surface's border/shadow).
hideHeaderbooleanfalseSkip the title + prev/next row entirely — used when a caller already renders its own single month title/nav (the calendar template's month switcher pill) and only needs the day grid underneath it.
showNextboolean
showPrevboolean