# Date Picker

> Date picker and standalone calendar with keyboard navigation, locales and disabled dates.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/date-picker
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/date-picker.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/date-picker-demo.tsx"
import { DatePicker } from "@/components/date-picker"

export default function DatePickerDemo() {
  return (
    <div className="flex h-[26rem] flex-col gap-2">
      <span className="text-sm font-medium">Launch date</span>
      <DatePicker defaultValue={new Date(2026, 10, 18)} defaultOpen />
    </div>
  )
}
```

## Source

```tsx title="components/date-picker.tsx"
"use client"

import * as React from "react"
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react"

import { cn } from "@/lib/utils"

const sameDay = (a: Date | null | undefined, b: Date | null | undefined) =>
  !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()

const addDays = (date: Date, days: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + days)
const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate())

// "Today" is only known in the browser: prerendered HTML has no today marker,
// and hydration fills it in without a mismatch.
let cachedToday: Date | null = null
const getToday = () => {
  const now = startOfDay(new Date())
  if (!sameDay(cachedToday, now)) cachedToday = now
  return cachedToday
}
const subscribeToday = () => () => {}
const useToday = () => React.useSyncExternalStore(subscribeToday, getToday, () => null)

export interface CalendarProps extends Omit<React.ComponentProps<"div">, "onSelect" | "defaultValue"> {
  value?: Date | null
  defaultValue?: Date | null
  onSelect?: (date: Date) => void
  /** 0 = Sunday, 1 = Monday */
  weekStartsOn?: 0 | 1
  /** BCP 47 locale for month and weekday names */
  locale?: string
  /** Days before today can't be picked */
  disablePast?: boolean
  isDateDisabled?: (date: Date) => boolean
}

export function Calendar({
  value,
  defaultValue = null,
  onSelect,
  weekStartsOn = 1,
  locale = "en-US",
  disablePast = false,
  isDateDisabled,
  className,
  ...props
}: CalendarProps) {
  const [internal, setInternal] = React.useState(defaultValue)
  const selected = value !== undefined ? value : internal
  const today = useToday()
  const [focusedState, setFocused] = React.useState<Date | null>(() => (selected ? startOfDay(selected) : null))
  // Without a value the month is unknown until today is; the grid stays empty for that first paint
  const focused = focusedState ?? today
  const grid = React.useRef<HTMLDivElement>(null)
  const moved = React.useRef(false)

  const month = focused ? new Date(focused.getFullYear(), focused.getMonth(), 1) : null
  const offset = month ? (month.getDay() - weekStartsOn + 7) % 7 : 0
  const days = Array.from({ length: 42 }, (_, i) => (month ? addDays(month, i - offset) : null))
  const weekdays = Array.from({ length: 7 }, (_, i) =>
    new Intl.DateTimeFormat(locale, { weekday: "narrow" }).format(new Date(2024, 0, 7 + weekStartsOn + i))
  )
  const title = month ? new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month) : ""
  const disabled = (date: Date) => (disablePast && !!today && date < today) || !!isDateDisabled?.(date)

  React.useEffect(() => {
    // Only follow keyboard focus, so the first render doesn't steal focus
    if (!moved.current) return
    grid.current?.querySelector<HTMLButtonElement>('[data-focused="true"]')?.focus()
  }, [focused])

  const move = (date: Date) => {
    moved.current = true
    setFocused(date)
  }

  const select = (date: Date) => {
    if (disabled(date)) return
    setInternal(date)
    setFocused(date)
    onSelect?.(date)
  }

  const onKeyDown = (event: React.KeyboardEvent) => {
    if (!focused) return
    const steps: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }
    if (event.key in steps) move(addDays(focused, steps[event.key]))
    else if (event.key === "PageUp") move(new Date(focused.getFullYear(), focused.getMonth() - 1, focused.getDate()))
    else if (event.key === "PageDown") move(new Date(focused.getFullYear(), focused.getMonth() + 1, focused.getDate()))
    else if (event.key === "Home") move(addDays(focused, -((focused.getDay() - weekStartsOn + 7) % 7)))
    else if (event.key === "End") move(addDays(focused, 6 - ((focused.getDay() - weekStartsOn + 7) % 7)))
    else return
    event.preventDefault()
  }

  const shiftMonth = (delta: number) => month && setFocused(new Date(month.getFullYear(), month.getMonth() + delta, 1))

  return (
    <div className={cn("w-72 rounded-xl border bg-popover p-3 text-popover-foreground shadow-sm", className)} {...props}>
      <div className="mb-2 flex items-center justify-between">
        <button
          type="button"
          onClick={() => shiftMonth(-1)}
          aria-label="Previous month"
          className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
        >
          <ChevronLeft className="size-4" />
        </button>
        <p aria-live="polite" className="text-sm font-medium">
          {title}
        </p>
        <button
          type="button"
          onClick={() => shiftMonth(1)}
          aria-label="Next month"
          className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
        >
          <ChevronRight className="size-4" />
        </button>
      </div>
      <div role="grid" aria-label={title} ref={grid} onKeyDown={onKeyDown}>
        <div role="row" className="grid grid-cols-7">
          {weekdays.map((day, i) => (
            <span key={i} role="columnheader" className="flex h-8 items-center justify-center text-xs text-muted-foreground">
              {day}
            </span>
          ))}
        </div>
        {Array.from({ length: 6 }, (_, week) => (
          <div key={week} role="row" className="grid grid-cols-7">
            {days.slice(week * 7, week * 7 + 7).map((date, i) => {
              if (!date || !month) return <div key={i} role="gridcell" className="h-9" />
              const outside = date.getMonth() !== month.getMonth()
              const isSelected = sameDay(date, selected)
              const isFocused = sameDay(date, focused)
              return (
                <div key={date.toISOString()} role="gridcell" aria-selected={isSelected} className="flex justify-center p-0.5">
                  <button
                    type="button"
                    data-focused={isFocused}
                    tabIndex={isFocused ? 0 : -1}
                    disabled={disabled(date)}
                    onClick={() => select(date)}
                    aria-label={new Intl.DateTimeFormat(locale, { dateStyle: "full" }).format(date)}
                    className={cn(
                      "relative flex size-8 items-center justify-center rounded-md text-sm tabular-nums transition-colors outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-30",
                      outside && "text-muted-foreground",
                      isSelected && "bg-primary font-medium text-primary-foreground hover:bg-primary",
                      sameDay(date, today) && !isSelected && "font-semibold text-brand"
                    )}
                  >
                    {date.getDate()}
                    {sameDay(date, today) && (
                      <span className={cn("absolute bottom-1 size-1 rounded-full", isSelected ? "bg-primary-foreground" : "bg-brand")} />
                    )}
                  </button>
                </div>
              )
            })}
          </div>
        ))}
      </div>
    </div>
  )
}

export interface DatePickerProps extends Omit<CalendarProps, "onSelect" | "onChange"> {
  onChange?: (date: Date) => void
  placeholder?: string
  /** Opens on first render without moving focus, handy for previews */
  defaultOpen?: boolean
}

export function DatePicker({
  value,
  defaultValue = null,
  onChange,
  placeholder = "Pick a date",
  locale = "en-US",
  defaultOpen = false,
  className,
  ...props
}: DatePickerProps) {
  const [internal, setInternal] = React.useState(defaultValue)
  const selected = value !== undefined ? value : internal
  const [open, setOpen] = React.useState(defaultOpen)
  const root = React.useRef<HTMLDivElement>(null)
  const trigger = React.useRef<HTMLButtonElement>(null)

  React.useEffect(() => {
    if (!open) return
    const onPointerDown = (event: PointerEvent) => {
      if (!root.current?.contains(event.target as Node)) setOpen(false)
    }
    // Escape from inside is handled below; this covers an open calendar without focus (defaultOpen)
    const onDocumentKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape" && !root.current?.contains(document.activeElement)) setOpen(false)
    }
    document.addEventListener("pointerdown", onPointerDown)
    document.addEventListener("keydown", onDocumentKeyDown)
    return () => {
      document.removeEventListener("pointerdown", onPointerDown)
      document.removeEventListener("keydown", onDocumentKeyDown)
    }
  }, [open])

  return (
    <div
      ref={root}
      className={cn("relative w-72", className)}
      onKeyDown={(event) => {
        if (event.key === "Escape" && open) {
          setOpen(false)
          trigger.current?.focus()
        }
      }}
    >
      <button
        ref={trigger}
        type="button"
        aria-haspopup="dialog"
        aria-expanded={open}
        onClick={() => setOpen((o) => !o)}
        className="flex h-10 w-full items-center gap-2 rounded-lg border bg-background px-3 text-left text-sm shadow-xs transition-[border-color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/20"
      >
        <CalendarDays className="size-4 text-muted-foreground" />
        <span className={cn("flex-1 truncate", !selected && "text-muted-foreground")}>
          {selected ? new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(selected) : placeholder}
        </span>
      </button>
      {open && (
        <div role="dialog" aria-label={placeholder} className="absolute top-full left-0 z-20 mt-1.5 animate-in fade-in zoom-in-95 slide-in-from-top-1">
          <Calendar
            value={selected}
            locale={locale}
            onSelect={(date) => {
              setInternal(date)
              onChange?.(date)
              setOpen(false)
              trigger.current?.focus()
            }}
            className="shadow-lg"
            {...props}
          />
        </div>
      )}
    </div>
  )
}
```
