shadimage
rawkitui
uicalendarsource: ok · hit

Calendar

Keyboard-friendly month calendar with selection and date constraints.

npx shadcn add "https://rawkitui.zerodegree.tech/r/calendar.json"
free

Where it goes

kanbancalendar

Interaction

dropdown

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Files1 · source cached 10/9/2026, 7:48:36 AM

components/ui/calendar.tsx
'use client'

import * as React from 'react'
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'

export type CalendarRangeValue = { from: Date; to?: Date }

export interface CalendarProps {
  value?: Date | CalendarRangeValue
  defaultValue?: Date | CalendarRangeValue
  onValueChange?: (date: Date | CalendarRangeValue | undefined) => void
  mode?: 'single' | 'range'
  month?: Date
  defaultMonth?: Date
  onMonthChange?: (month: Date) => void
  minDate?: Date
  maxDate?: Date
  disabled?: (date: Date) => boolean
  showOutsideDays?: boolean
  showMonthYearPicker?: boolean
  className?: string
}

const weekdays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']

const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate())
const isSameDay = (left?: Date, right?: Date) => Boolean(left && right && startOfDay(left).getTime() === startOfDay(right).getTime())
const isBefore = (date: Date, boundary?: Date) => Boolean(boundary && startOfDay(date) < startOfDay(boundary))
const isAfter = (date: Date, boundary?: Date) => Boolean(boundary && startOfDay(date) > startOfDay(boundary))

const isRangeValue = (value: unknown): value is CalendarRangeValue =>
  Boolean(value && typeof value === 'object' && 'from' in value)

export function Calendar({
  value: controlledValue,
  defaultValue,
  onValueChange,
  mode = 'single',
  month: controlledMonth,
  defaultMonth,
  onMonthChange,
  minDate,
  maxDate,
  disabled,
  showOutsideDays = true,
  showMonthYearPicker = true,
  className,
}: CalendarProps) {
  const [internalValue, setInternalValue] = React.useState<Date | CalendarRangeValue | undefined>(defaultValue)
  const [visibleMonth, setVisibleMonth] = React.useState<Date>(() => {
    const base = defaultMonth ?? (defaultValue && !isRangeValue(defaultValue) ? defaultValue : new Date())
    return new Date(base.getFullYear(), base.getMonth(), 1)
  })
  const [rangeSelecting, setRangeSelecting] = React.useState(false)
  const [showPicker, setShowPicker] = React.useState(false)

  const value = controlledValue ?? internalValue
  const month = controlledMonth ?? visibleMonth
  const firstDay = new Date(month.getFullYear(), month.getMonth(), 1)
  const firstGridDay = new Date(month.getFullYear(), month.getMonth(), 1 - firstDay.getDay())
  const days = Array.from({ length: 42 }, (_, index) => new Date(firstGridDay.getFullYear(), firstGridDay.getMonth(), firstGridDay.getDate() + index))

  const emit = (next: Date | CalendarRangeValue | undefined) => {
    setInternalValue(next)
    onValueChange?.(next)
  }

  const changeMonth = (offset: number) => {
    const nextMonth = new Date(month.getFullYear(), month.getMonth() + offset, 1)
    setVisibleMonth(nextMonth)
    onMonthChange?.(nextMonth)
  }

  const selectDate = (date: Date) => {
    if (mode === 'range') {
      const current = isRangeValue(value) ? value : undefined
      if (!current || (current.from && current.to) || rangeSelecting) {
        // Start a new range
        emit({ from: date })
        setRangeSelecting(true)
      } else {
        if (isBefore(date, current.from)) {
          emit({ from: date, to: current.from })
        } else {
          emit({ from: current.from, to: date })
        }
        setRangeSelecting(false)
      }
      return
    }

    const nextValue = isSameDay(date, value as Date) ? undefined : date
    emit(nextValue)
  }

  const isInRange = (date: Date): boolean => {
    if (mode !== 'range') return false
    const range = isRangeValue(value) ? value : undefined
    if (!range?.from || !range.to) return false
    return !isBefore(date, range.from) && !isAfter(date, range.to)
  }

  const pickMonth = (index: number) => {
    const next = new Date(month.getFullYear(), index, 1)
    setVisibleMonth(next)
    onMonthChange?.(next)
    setShowPicker(false)
  }

  const pickYear = (year: number) => {
    const next = new Date(year, month.getMonth(), 1)
    setVisibleMonth(next)
    onMonthChange?.(next)
  }

  const pickerYear = month.getFullYear()

  return (
    <div className={cn('w-full max-w-sm rounded-2xl bg-rk-surface p-4 font-sans rk-border rk-shadow-md', className)}>
      <div className="mb-4 flex items-center justify-between gap-3">
        {showMonthYearPicker ? (
          <div className="relative flex items-center gap-1">
            <button
              type="button"
              aria-label="Select month and year"
              aria-haspopup="listbox"
              aria-expanded={showPicker}
              onClick={() => setShowPicker((s) => !s)}
              className="flex items-center gap-1.5 rounded-full bg-rk-canvas px-3 py-1.5 font-display text-lg font-black rk-border-sm rk-shadow-sm hover:bg-rk-primary focus:outline-none focus:ring-4 focus:ring-black focus:ring-offset-2 cursor-pointer"
            >
              <span aria-live="polite">
                {month.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })}
              </span>
              <ChevronDown className={cn('h-4 w-4 stroke-[3] transition-transform', showPicker && 'rotate-180')} aria-hidden="true" />
            </button>

            {showPicker && (
              <div className="absolute left-0 top-full z-20 mt-2 rounded-2xl bg-rk-surface rk-border rk-shadow-lg p-3 font-sans" role="listbox" aria-label="Month and year picker">
                <div className="mb-2 flex items-center justify-between gap-2">
                  <button type="button" aria-label="Previous year" onClick={() => pickYear(pickerYear - 1)} className="flex h-7 w-7 items-center justify-center rounded-full rk-border-sm rk-shadow-xs hover:bg-rk-primary focus:outline-none focus:ring-2 focus:ring-black cursor-pointer">
                    <ChevronLeft className="h-3.5 w-3.5 stroke-[3]" aria-hidden="true" />
                  </button>
                  <span className="font-display text-sm font-black">{pickerYear}</span>
                  <button type="button" aria-label="Next year" onClick={() => pickYear(pickerYear + 1)} className="flex h-7 w-7 items-center justify-center rounded-full rk-border-sm rk-shadow-xs hover:bg-rk-primary focus:outline-none focus:ring-2 focus:ring-black cursor-pointer">
                    <ChevronRight className="h-3.5 w-3.5 stroke-[3]" aria-hidden="true" />
                  </button>
                </div>
                <div className="grid grid-cols-3 gap-1.5">
                  {monthNames.map((name, index) => (
                    <button
                      key={name}
                      type="button"
                      role="option"
                      aria-selected={month.getMonth() === index}
                      onClick={() => pickMonth(index)}
                      className={cn(
                        'rounded-xl px-2 py-1.5 text-xs font-extrabold transition-colors focus:outline-none focus:ring-2 focus:ring-black cursor-pointer',
                        month.getMonth() === index ? 'bg-rk-primary rk-border-sm rk-shadow-xs' : 'hover:bg-rk-canvas'
                      )}
                    >
                      {name.slice(0, 3)}
                    </button>
                  ))}
                </div>
              </div>
            )}
          </div>
        ) : (
          <h2 className="font-display text-lg font-black" aria-live="polite">
            {month.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })}
          </h2>
        )}
        <div className="flex gap-2">
          <button type="button" aria-label="Previous month" onClick={() => changeMonth(-1)} className="flex h-9 w-9 items-center justify-center rounded-full bg-rk-canvas rk-border-sm rk-shadow-sm hover:bg-rk-primary focus:outline-none focus:ring-4 focus:ring-black focus:ring-offset-2 cursor-pointer">
            <ChevronLeft className="h-4 w-4 stroke-[3]" aria-hidden="true" />
          </button>
          <button type="button" aria-label="Next month" onClick={() => changeMonth(1)} className="flex h-9 w-9 items-center justify-center rounded-full bg-rk-canvas rk-border-sm rk-shadow-sm hover:bg-rk-primary focus:outline-none focus:ring-4 focus:ring-black focus:ring-offset-2 cursor-pointer">
            <ChevronRight className="h-4 w-4 stroke-[3]" aria-hidden="true" />
          </button>
        </div>
      </div>
      <div role="grid" aria-label={month.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })} className="grid grid-cols-7 gap-1">
        {weekdays.map((day) => <div key={day} role="columnheader" className="py-1 text-center font-mono text-[10px] font-black uppercase text-rk-ink/50">{day}</div>)}
        {days.map((date) => {
          const outside = date.getMonth() !== month.getMonth()
          const unavailable = isBefore(date, minDate) || isAfter(date, maxDate) || disabled?.(date)
          const isDisabled = unavailable || (outside && !showOutsideDays)
          const range = isRangeValue(value) ? value : undefined
          const isRangeStart = range?.from ? isSameDay(date, range.from) : false
          const isRangeEnd = range?.to ? isSameDay(date, range.to) : false
          const inRange = isInRange(date)
          return (
            <button
              key={date.toISOString()}
              type="button"
              role="gridcell"
              aria-label={date.toLocaleDateString(undefined, { dateStyle: 'full' })}
              aria-selected={mode === 'single' ? isSameDay(date, value as Date) : isRangeStart}
              aria-current={isSameDay(date, new Date()) ? 'date' : undefined}
              disabled={isDisabled}
              onClick={() => selectDate(date)}
              className={cn(
                'aspect-square rounded-xl p-1 text-sm font-extrabold transition-colors focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:pointer-events-none disabled:opacity-25',
                outside ? 'text-rk-ink/30' : 'text-rk-ink hover:bg-rk-mint',
                inRange && 'bg-rk-mint rounded-none',
                isRangeStart && 'bg-rk-primary rk-border-sm rk-shadow-sm rounded-l-xl',
                isRangeEnd && 'bg-rk-primary rk-border-sm rk-shadow-sm rounded-r-xl',
                mode === 'single' && isSameDay(date, value as Date) && 'bg-rk-primary rk-border-sm rk-shadow-sm text-rk-ink'
              )}
            >
              {date.getDate()}
            </button>
          )
        })}
      </div>
    </div>
  )
}

More from rawkitui

Browse all components in this library →