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.

Date Picker

Select a date or date range.

Installation

$ pnpm dlx shadcn@latest add @cobalt/date-picker

Source

"use client";

import { RiCalendarLine } from "@remixicon/react";
import { cn } from "cn";
import {
  addDays,
  endOfMonth,
  endOfYear,
  differenceInCalendarDays,
  isSameDay,
  format,
  startOfMonth,
  startOfYear,
  subMonths,
  subYears,
} from "date-fns";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import type { DateRange } from "react-day-picker";

import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";

interface CommonProps {
  disabled?: boolean;
  className?: string;
  popoverClassName?: string;
  "aria-label"?: string;
}
type DatePickerProps = CommonProps & {
  /**
   * `field` fills its container at input height, for forms. `filled` does the
   * same with Input's borderless fill, for forms of Inputs and TimeFields.
   */
  variant?: "default" | "field" | "filled";
  id?: string;
  value?: Date;
  defaultValue?: Date;
  onChange?: (value: Date | undefined) => void;
};
type DateRangePickerProps = CommonProps & {
  value?: DateRange;
  defaultValue?: DateRange;
  onChange?: (value: DateRange | undefined) => void;
};

const triggerClass =
  "inline-flex items-center gap-0.5 rounded-[10px] border border-border bg-popover p-2 text-sm font-medium text-foreground shadow-[shadow:var(--shadow-control,0_1px_2px_0_rgb(0_0_0/0.05))] outline-none transition-[background-color,border-color,box-shadow] duration-150 hover:bg-[var(--surface-hover,var(--muted))] hover:border-[var(--border-hover,var(--border))] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground disabled:shadow-none motion-reduce:transition-none";
const fieldTriggerClass =
  "h-9 w-full justify-start gap-1.5 px-2.5 py-0 leading-5 [&_svg]:size-4 [&_span]:px-0";
const filledTriggerClass =
  "rounded-[10px] border-0 bg-[var(--surface-strong,var(--muted))] font-normal shadow-none ring-2 ring-transparent ring-inset hover:bg-[var(--surface-strong,var(--muted))] hover:ring-[var(--border-hover,var(--border))] focus-visible:ring-2 focus-visible:ring-[var(--foreground-subtle,var(--ring))] focus-visible:ring-offset-0 disabled:bg-[var(--input-disabled-surface,var(--muted))] [&_svg]:text-[color:var(--foreground-subtle,var(--muted-foreground))]";
const popupClass =
  "w-auto gap-0 rounded-[24px] bg-[var(--surface-subtle,var(--muted))] p-0 shadow-[shadow:var(--shadow-dropdown,0_1px_1px_0_rgb(0_0_0/0.04),0_4px_4px_0_rgb(0_0_0/0.02))] ring-0 origin-top transition-[opacity,scale,filter] duration-150 ease-out data-starting-style:scale-95 data-starting-style:opacity-0 data-starting-style:blur-[2px] data-ending-style:scale-95 data-ending-style:opacity-0 data-ending-style:blur-[2px] motion-reduce:transition-none";

// Opened from the keyboard, focus lands on the day button (the selected day, or
// today) so the arrow keys move between days. Pointer opens keep the default.
const focusDayWhenKeyboard = (openType: string) =>
  openType === "keyboard"
    ? (document.querySelector<HTMLElement>(
        '[data-slot="popover-content"] button[data-day][tabindex="0"]'
      ) ?? undefined)
    : undefined;

function DateChip({
  date,
  label,
  onCommit,
}: {
  date: Date;
  label: string;
  onCommit: (date: Date) => void;
}) {
  const formatted = format(date, "dd/MM/yyyy");
  const [text, setText] = React.useState(formatted);
  React.useEffect(() => setText(formatted), [formatted]);
  function commit() {
    const match = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(text.trim());
    if (match) {
      const day = Number(match[1]),
        month = Number(match[2]),
        year = Number(match[3]);
      const next = new Date(year, month - 1, day);
      if (
        next.getFullYear() === year &&
        next.getMonth() === month - 1 &&
        next.getDate() === day
      ) {
        onCommit(next);
        return;
      }
    }
    setText(formatted);
  }
  return (
    <input
      aria-label={label}
      dir="ltr"
      inputMode="numeric"
      value={text}
      onChange={(e) => setText(e.target.value)}
      onBlur={commit}
      onKeyDown={(e) => {
        if (e.key === "Enter") {
          e.currentTarget.blur();
        }
        if (e.key === "Escape") {
          setText(formatted);
        }
      }}
      className="w-[104px] rounded-[10px] border border-border bg-popover px-2 py-2 text-sm font-medium text-foreground shadow-[shadow:var(--shadow-control,0_1px_2px_0_rgb(0_0_0/0.05))] outline-none transition-colors duration-100 ease-out focus-visible:border-ring motion-reduce:transition-none"
    />
  );
}

function DatePicker({
  variant = "default",
  id,
  value,
  defaultValue,
  onChange,
  disabled,
  className,
  popoverClassName,
  "aria-label": label = "Date",
}: DatePickerProps) {
  const [internal, setInternal] = React.useState(defaultValue);
  const selected = value === undefined ? internal : value;
  const [pending, setPending] = React.useState(selected);
  const [open, setOpen] = React.useState(false);
  return (
    <Popover
      open={open}
      onOpenChange={(next) => {
        if (next) {
          setPending(selected);
        }
        setOpen(next);
      }}
    >
      <PopoverTrigger
        id={id}
        disabled={disabled}
        className={cn(
          triggerClass,
          variant !== "default" && fieldTriggerClass,
          variant === "filled" && filledTriggerClass,
          className
        )}
      >
        <RiCalendarLine className="size-5 shrink-0" aria-hidden />
        <span className="px-1 whitespace-nowrap">
          {selected ? format(selected, "MMM d, yyyy") : "Select date"}
        </span>
      </PopoverTrigger>
      <PopoverContent
        align={variant === "default" ? "end" : "start"}
        aria-label={label}
        initialFocus={focusDayWhenKeyboard}
        className={cn(popupClass, popoverClassName)}
      >
        <div className="flex flex-col p-2 pb-3">
          <Calendar
            mode="single"
            showOutsideDays={false}
            selected={pending}
            onSelect={setPending}
            defaultMonth={pending}
          />
          <div className="flex items-center justify-between gap-4 px-4 pt-3">
            <div>
              {pending && (
                <DateChip date={pending} label="Date" onCommit={setPending} />
              )}
            </div>
            <div className="flex gap-2.5">
              <Button variant="outline" onClick={() => setOpen(false)}>
                Cancel
              </Button>
              <Button
                disabled={!pending}
                onClick={() => {
                  if (value === undefined) {
                    setInternal(pending);
                  }
                  onChange?.(pending);
                  setOpen(false);
                }}
              >
                Apply
              </Button>
            </div>
          </div>
        </div>
      </PopoverContent>
    </Popover>
  );
}

function DateRangePicker({
  value,
  defaultValue,
  onChange,
  disabled,
  className,
  popoverClassName,
  "aria-label": label = "Date range",
}: DateRangePickerProps) {
  const [internal, setInternal] = React.useState(defaultValue);
  const selected = value === undefined ? internal : value;
  const [pending, setPending] = React.useState(selected);
  const [open, setOpen] = React.useState(false);
  const reduceMotion = useReducedMotion();
  const now = new Date();
  const presets = [
    ["Today", now, now],
    ["Yesterday", addDays(now, -1), addDays(now, -1)],
    ["Last week", addDays(now, -7), addDays(now, -1)],
    ["This month", startOfMonth(now), endOfMonth(now)],
    [
      "Last month",
      startOfMonth(subMonths(now, 1)),
      endOfMonth(subMonths(now, 1)),
    ],
    ["This year", startOfYear(now), endOfYear(now)],
    ["Last year", startOfYear(subYears(now, 1)), endOfYear(subYears(now, 1))],
    ["All time", subYears(now, 10), now],
  ] as const;
  return (
    <Popover
      open={open}
      onOpenChange={(next) => {
        if (next) {
          setPending(selected);
        }
        setOpen(next);
      }}
    >
      <PopoverTrigger
        disabled={disabled}
        className={cn(triggerClass, className)}
      >
        <RiCalendarLine className="size-5 shrink-0" aria-hidden />
        <span className="px-1 whitespace-nowrap">
          {selected?.from && selected.to
            ? `${format(selected.from, "MMM d, yyyy")} - ${format(selected.to, "MMM d, yyyy")}`
            : "Select date range"}
        </span>
      </PopoverTrigger>
      <PopoverContent
        align="end"
        aria-label={label}
        initialFocus={focusDayWhenKeyboard}
        className={cn(
          popupClass,
          "max-w-[calc(100vw-16px)] overflow-auto",
          popoverClassName
        )}
      >
        <div className="flex gap-3">
          <div className="flex w-[118px] shrink-0 flex-col gap-1.5 pt-4 pl-4">
            {presets.map(([name, from, to]) => (
              <button
                key={name}
                type="button"
                onClick={() => setPending({ from, to })}
                aria-pressed={
                  !!pending?.from &&
                  !!pending.to &&
                  isSameDay(pending.from, from) &&
                  isSameDay(pending.to, to)
                }
                className="relative isolate w-full cursor-pointer rounded-[10px] px-2 py-1.5 text-start text-sm font-medium text-foreground transition-colors duration-150 ease-[ease] before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] aria-pressed:before:bg-[var(--surface-strong,var(--muted))] hover:not-aria-pressed:bg-[var(--surface-strong,var(--muted))] motion-reduce:transition-none"
              >
                {name}
              </button>
            ))}
          </div>
          <div className="flex flex-col pt-2 pr-2 pb-3">
            <Calendar
              mode="range"
              showOutsideDays={false}
              selected={pending}
              onSelect={setPending}
              defaultMonth={pending?.from}
              numberOfMonths={2}
            />
            <div className="flex items-center justify-between gap-4 pt-3 pr-4">
              <div className="flex items-center gap-2.5">
                <div className="flex items-center gap-[5px]">
                  {pending?.from && (
                    <DateChip
                      date={pending.from}
                      label="Start date"
                      onCommit={(from) =>
                        setPending({
                          from,
                          to:
                            pending.to && pending.to < from ? from : pending.to,
                        })
                      }
                    />
                  )}
                  {pending?.to && (
                    <>
                      <span>–</span>
                      <DateChip
                        date={pending.to}
                        label="End date"
                        onCommit={(to) =>
                          setPending({
                            from:
                              pending.from && pending.from > to
                                ? to
                                : pending.from,
                            to,
                          })
                        }
                      />
                    </>
                  )}
                </div>
                <AnimatePresence>
                  {pending?.from && pending.to && (
                    <motion.span
                      key="range-summary"
                      initial={reduceMotion ? false : { opacity: 0, y: -12 }}
                      animate={{ opacity: 1, y: 0 }}
                      exit={
                        reduceMotion
                          ? { opacity: 1, y: 0 }
                          : { opacity: 0, y: -12 }
                      }
                      transition={
                        reduceMotion
                          ? { duration: 0 }
                          : { duration: 0.25, ease: [0.34, 1.2, 0.64, 1] }
                      }
                      className="rounded-[12px] bg-[var(--surface-strong,var(--muted))] px-2 py-2 text-sm font-medium whitespace-nowrap text-[var(--date-picker-summary-foreground,var(--muted-foreground))]"
                      aria-live="polite"
                    >
                      {(() => {
                        const days =
                          differenceInCalendarDays(pending.to, pending.from) +
                          1;
                        return `${days} day${days === 1 ? "" : "s"} selected`;
                      })()}
                    </motion.span>
                  )}
                </AnimatePresence>
              </div>
              <div className="flex gap-2.5">
                <Button variant="outline" onClick={() => setOpen(false)}>
                  Cancel
                </Button>
                <Button
                  disabled={!pending?.from || !pending.to}
                  onClick={() => {
                    if (value === undefined) {
                      setInternal(pending);
                    }
                    onChange?.(pending);
                    setOpen(false);
                  }}
                >
                  Apply
                </Button>
              </div>
            </div>
          </div>
        </div>
      </PopoverContent>
    </Popover>
  );
}

export { DatePicker, DateRangePicker };
export type { DatePickerProps, DateRangePickerProps };