Forms

DatePicker

DatePicker pairs a built-in popover trigger with a built-in calendar grid, supporting single or range selection, min/max bounds, and specific disabled dates.

Installation

npx shadcn add https://rck.vibeboxph.com//r/date-picker.json

Live preview & controls

Controls
<DatePicker />

Anatomy

  • date-picker-triggerThe button showing the formatted date or range.
  • date-picker-contentThe popover panel containing the calendar.
  • DayPickerThe built-in calendar grid.

Props

PropTypeDefaultDescription
mode"single" | "range"singleSelection mode.
value / defaultValueDate | null | DateRange | nullControlled / uncontrolled selection (shape depends on `mode`).
onValueChange(value) => voidFires on selection change.
minDate / maxDateDateInclusive bounds; dates outside are disabled.
disabledDatesDate[]Specific dates to disable, e.g. holidays.
numberOfMonthsnumber1 (2 for range)Months rendered side by side.
clearablebooleantrueShows a Clear action once a value is set.
dateFormatstringPPPIntl.DateTimeFormat options or preset for the trigger label.

Accessibility

  • The trigger is a real button with `aria-haspopup="dialog"` and `aria-expanded`.
  • The calendar grid is keyboard-navigable (Arrow keys move between days, Enter selects, Escape closes) via the built-in Calendar primitive.
  • Disabled days are marked `aria-disabled` and excluded from tab order.

Source

registry/rck/ui/date-picker.tsx
"use client";

import * as React from "react";
import {
  DayPicker,
  type DateRange,
  type Matcher,
  formatDate,
} from "@/registry/rck/primitives/calendar";
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from "@/lib/icons";
import { cn } from "@/lib/utils";
import { PopoverRoot, PopoverContent, PopoverTrigger } from "@/registry/rck/ui/popover";

export type { DateRange };

const dayPickerClassNames = {
  root: "p-3",
  months: "flex flex-col gap-4 sm:flex-row",
  month: "flex flex-col gap-3",
  month_caption: "flex items-center justify-center pt-1 relative",
  caption_label: "text-sm font-medium",
  nav: "flex items-center justify-between absolute inset-x-1 top-1",
  button_previous: cn(
    "inline-flex size-7 items-center justify-center rounded-md border border-border-strong",
    "text-muted-foreground hover:bg-surface-2 hover:text-foreground disabled:opacity-40",
  ),
  button_next: cn(
    "inline-flex size-7 items-center justify-center rounded-md border border-border-strong",
    "text-muted-foreground hover:bg-surface-2 hover:text-foreground disabled:opacity-40",
  ),
  month_grid: "w-full border-collapse mt-2",
  weekdays: "flex",
  weekday: "text-muted-foreground w-9 text-[0.8rem] font-normal text-center",
  weeks: "flex flex-col gap-1 mt-1",
  week: "flex w-full",
  day: "size-9 p-0 text-center text-sm relative",
  day_button: cn(
    "size-9 rounded-md text-sm font-normal inline-flex items-center justify-center",
    "hover:bg-surface-2 aria-selected:opacity-100 outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
  ),
  selected:
    "[&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary/90",
  today: "[&>button]:border [&>button]:border-primary/60 [&>button]:font-semibold",
  outside: "text-muted-foreground/50",
  disabled: "text-muted-foreground/30 pointer-events-none",
  range_start:
    "[&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:rounded-l-md [&>button]:rounded-r-none",
  range_middle:
    "bg-primary-soft [&>button]:bg-transparent [&>button]:text-primary-soft-foreground [&>button]:rounded-none",
  range_end:
    "[&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:rounded-r-md [&>button]:rounded-l-none",
  hidden: "invisible",
};

export interface DatePickerBaseProps extends Omit<
  React.ButtonHTMLAttributes<HTMLButtonElement>,
  "value" | "defaultValue" | "onChange"
> {
  placeholder?: string;
  dateFormat?: string;
  minDate?: Date;
  maxDate?: Date;
  disabledDates?: Date[];
  numberOfMonths?: number;
  clearable?: boolean;
  contentClassName?: string;
  /** Fires when the calendar popover opens or closes. */
  onOpenChange?: (open: boolean) => void;
}

export interface SingleDatePickerProps extends DatePickerBaseProps {
  mode?: "single";
  value?: Date | null;
  defaultValue?: Date | null;
  onValueChange?: (value: Date | null) => void;
}

export interface RangeDatePickerProps extends DatePickerBaseProps {
  mode: "range";
  value?: DateRange | null;
  defaultValue?: DateRange | null;
  onValueChange?: (value: DateRange | null) => void;
}

export type DatePickerProps = SingleDatePickerProps | RangeDatePickerProps;

function buildDisabledMatcher({
  minDate,
  maxDate,
  disabledDates,
}: Pick<DatePickerBaseProps, "minDate" | "maxDate" | "disabledDates">):
  Matcher | Matcher[] | undefined {
  const matchers: Matcher[] = [];
  if (minDate) matchers.push({ before: minDate });
  if (maxDate) matchers.push({ after: maxDate });
  if (disabledDates?.length) matchers.push(disabledDates);
  return matchers.length ? matchers : undefined;
}

export function DatePicker(props: DatePickerProps) {
  const {
    placeholder = "Pick a date",
    dateFormat = "PPP",
    minDate,
    maxDate,
    disabledDates,
    numberOfMonths,
    disabled,
    clearable = true,
    className,
    contentClassName,
    id,
    value,
    defaultValue,
    onValueChange,
    onOpenChange,
    mode: rawMode,
    onBlur,
    onFocus,
    onKeyDown,
    ...triggerProps
  } = props;

  const mode = rawMode ?? "single";
  const resolvedNumberOfMonths = numberOfMonths ?? (mode === "range" ? 2 : 1);

  const [open, setOpen] = React.useState(false);
  const isControlled = value !== undefined;
  const [internalValue, setInternalValue] = React.useState<
    Date | DateRange | null | undefined
  >(defaultValue ?? null);
  const currentValue = isControlled ? value : internalValue;
  const disabledMatcher = buildDisabledMatcher({ minDate, maxDate, disabledDates });

  function commit(next: Date | DateRange | null) {
    if (!isControlled) setInternalValue(next);
    (onValueChange as ((value: Date | DateRange | null) => void) | undefined)?.(next);
  }

  const label = React.useMemo(() => {
    if (!currentValue) return placeholder;
    if (mode === "range") {
      const range = currentValue as DateRange;
      if (range.from && range.to) {
        return `${formatDate(range.from, dateFormat)}${formatDate(range.to, dateFormat)}`;
      }
      if (range.from) return `${formatDate(range.from, dateFormat)} – …`;
      return placeholder;
    }
    return formatDate(currentValue as Date, dateFormat);
  }, [currentValue, dateFormat, mode, placeholder]);

  return (
    <PopoverRoot
      open={open}
      onOpenChange={(nextOpen) => {
        setOpen(nextOpen);
        onOpenChange?.(nextOpen);
      }}
    >
      <PopoverTrigger asChild>
        <button
          type="button"
          id={id}
          disabled={disabled}
          data-slot="date-picker-trigger"
          onBlur={onBlur}
          onFocus={onFocus}
          onKeyDown={onKeyDown}
          className={cn(
            "border-border-strong bg-background flex h-[39px] w-full items-center gap-2 rounded-md border px-3 text-[11px] shadow-sm",
            "focus-visible:border-primary focus-visible:ring-ring/30 transition-colors outline-none focus-visible:ring-2",
            "disabled:cursor-not-allowed disabled:opacity-50",
            className,
          )}
          {...triggerProps}
        >
          <CalendarIcon
            className="text-muted-foreground size-4 shrink-0"
            aria-hidden="true"
          />
          <span className={cn("truncate", !currentValue && "text-muted-foreground")}>
            {label}
          </span>
        </button>
      </PopoverTrigger>
      <PopoverContent
        data-slot="date-picker-content"
        align="start"
        className={cn("w-auto p-0", contentClassName)}
      >
        {mode === "range" ? (
          <DayPicker
            mode="range"
            numberOfMonths={resolvedNumberOfMonths}
            selected={(currentValue as DateRange | undefined) ?? undefined}
            onSelect={(range) => commit(range ?? null)}
            disabled={disabledMatcher}
            classNames={dayPickerClassNames}
            components={{
              Chevron: ({ orientation }) =>
                orientation === "left" ? (
                  <ChevronLeft className="size-4" />
                ) : (
                  <ChevronRight className="size-4" />
                ),
            }}
          />
        ) : (
          <DayPicker
            mode="single"
            numberOfMonths={resolvedNumberOfMonths}
            selected={(currentValue as Date | undefined) ?? undefined}
            onSelect={(date) => {
              commit(date ?? null);
              setOpen(false);
            }}
            disabled={disabledMatcher}
            classNames={dayPickerClassNames}
            components={{
              Chevron: ({ orientation }) =>
                orientation === "left" ? (
                  <ChevronLeft className="size-4" />
                ) : (
                  <ChevronRight className="size-4" />
                ),
            }}
          />
        )}
        {clearable && currentValue && (
          <div className="border-border border-t p-2">
            <button
              type="button"
              onClick={() => commit(null)}
              className="text-muted-foreground hover:bg-surface-2 hover:text-foreground w-full rounded-md px-2 py-1.5 text-xs"
            >
              Clear
            </button>
          </div>
        )}
      </PopoverContent>
    </PopoverRoot>
  );
}
DatePicker.displayName = "DatePicker";
registry/rck/ui/popover.tsx
"use client";

import * as React from "react";
import * as PopoverPrimitive from "@/registry/rck/primitives/popover";
import { X } from "@/lib/icons";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/registry/rck/ui/button";

/** Attribute placed on external trigger elements. Pair with the Popover `trigger` prop. */
export const POPUP_TRIGGER_ATTR = "data-popup-id";
export const POPUP_TRIGGER_ALIAS_ATTR = "data-popup";

/** @deprecated Use `Popover` for the common case, or import primitives directly. */
export const PopoverRoot = PopoverPrimitive.Root;
export const PopoverAnchor = PopoverPrimitive.Anchor;
export const PopoverClose = PopoverPrimitive.Close;

export const PopoverTrigger = PopoverPrimitive.Trigger;

export const PopoverContent = React.forwardRef<
  React.ComponentRef<typeof PopoverPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 12, ...props }, ref) => (
  <PopoverPrimitive.Portal>
    <PopoverPrimitive.Content
      ref={ref}
      data-slot="popover-content"
      align={align}
      sideOffset={sideOffset}
      className={cn(
        "group border-border bg-popover text-popover-foreground z-50 w-72 overflow-visible rounded-lg border p-4 shadow-md outline-none",
        "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
        "data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
        "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
        "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
        className,
      )}
      {...props}
    >
      <PopoverPrimitive.Arrow
        aria-hidden="true"
        className={cn(
          "fill-popover stroke-border absolute stroke-[1.5]",
          "group-data-[side=bottom]:-top-[6px] group-data-[side=bottom]:left-1/2 group-data-[side=bottom]:-translate-x-1/2 group-data-[side=bottom]:rotate-180",
          "group-data-[side=top]:-bottom-[6px] group-data-[side=top]:left-1/2 group-data-[side=top]:-translate-x-1/2",
          "group-data-[side=right]:top-1/2 group-data-[side=right]:-left-[9px] group-data-[side=right]:-translate-y-1/2 group-data-[side=right]:rotate-90",
          "group-data-[side=left]:top-1/2 group-data-[side=left]:-right-[9px] group-data-[side=left]:-translate-y-1/2 group-data-[side=left]:-rotate-90",
        )}
      />
      {props.children}
    </PopoverPrimitive.Content>
  </PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;

function usePopupTriggerElement(trigger: string | undefined, attribute: string) {
  const [element, setElement] = React.useState<HTMLElement | null>(null);

  React.useLayoutEffect(() => {
    if (!trigger) return;

    const selector = `[${attribute}="${CSS.escape(trigger)}"]`;

    function resolveTrigger() {
      setElement(document.querySelector<HTMLElement>(selector));
    }

    resolveTrigger();
    const observer = new MutationObserver(resolveTrigger);
    observer.observe(document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: [attribute],
    });
    return () => observer.disconnect();
  }, [trigger, attribute]);

  return trigger ? element : null;
}

export interface PopoverProps extends Omit<
  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>,
  "content" | "children" | "title"
> {
  content: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  /** Inline trigger passed as a child element. */
  children?: React.ReactElement;
  /** Matches `${POPUP_TRIGGER_ATTR}` on an external trigger element. */
  trigger?: string;
  /** Matches `data-popup` on an external trigger element. Use when the trigger is outside this component. */
  popupTrigger?: string;
  /** With `trigger`, skip auto click toggle and control open state manually. */
  manualTrigger?: boolean;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  modal?: boolean;
  contentClassName?: string;
  showClose?: boolean;
  className?: string;
}

function PopoverBody({
  title,
  description,
  content,
  showClose,
}: Pick<PopoverProps, "title" | "description" | "content" | "showClose">) {
  return (
    <>
      {(title || description || showClose) && (
        <div className="mb-3 flex items-start justify-between gap-3">
          <div className="space-y-1">
            {title ? <p className="text-sm font-medium">{title}</p> : null}
            {description ? (
              <p className="text-muted-foreground text-xs">{description}</p>
            ) : null}
          </div>
          {showClose ? (
            <PopoverClose
              aria-label="Close"
              className={cn(
                "text-muted-foreground rounded-sm outline-none",
                "hover:text-foreground focus-visible:ring-ring/50 focus-visible:ring-2",
              )}
            >
              <X className="size-4" />
            </PopoverClose>
          ) : null}
        </div>
      )}
      <div>{content}</div>
    </>
  );
}

export function Popover({
  content,
  title,
  description,
  children,
  trigger,
  popupTrigger,
  manualTrigger = false,
  open,
  defaultOpen,
  onOpenChange,
  modal,
  className,
  contentClassName,
  showClose = false,
  placement,
  align,
  sideOffset,
  alignOffset,
  ...contentProps
}: PopoverProps) {
  if (trigger && popupTrigger) {
    throw new Error("Popover accepts either `trigger` or `popupTrigger`, not both.");
  }

  const externalTrigger = trigger ?? popupTrigger;
  const triggerElement = usePopupTriggerElement(
    externalTrigger,
    popupTrigger ? POPUP_TRIGGER_ALIAS_ATTR : POPUP_TRIGGER_ATTR,
  );
  const hasInlineTrigger = Boolean(children);

  if (!hasInlineTrigger && !externalTrigger) {
    throw new Error("Popover requires `children`, `trigger`, or `popupTrigger`.");
  }

  if (hasInlineTrigger && externalTrigger) {
    throw new Error(
      "Popover accepts either `children` or an external trigger, not both.",
    );
  }

  return (
    <PopoverRoot
      open={open}
      defaultOpen={defaultOpen}
      onOpenChange={onOpenChange}
      modal={modal}
      manualTrigger={manualTrigger}
      triggerElement={hasInlineTrigger ? undefined : triggerElement}
    >
      {children ? <PopoverTrigger asChild>{children}</PopoverTrigger> : null}
      <PopoverContent
        placement={placement}
        align={align}
        sideOffset={sideOffset}
        alignOffset={alignOffset}
        className={cn(contentClassName, className)}
        {...contentProps}
      >
        <PopoverBody
          title={title}
          description={description}
          content={content}
          showClose={showClose}
        />
      </PopoverContent>
    </PopoverRoot>
  );
}

/** Default styles for a standalone popover trigger button (optional helper). */
export const popoverTriggerButtonClassName = buttonVariants({
  variant: "outline",
  size: "md",
});

Related examples

Browse the full examples gallery