Calendar
A date picker component with support for single date, date range, and multiple dates selection.
Installation
Install the component Calendar in your project using the CLI.
Calendar.tsx
pnpm dlx behsseui@latest add CalendarInstall the component manually.
Create a ui folder at the root of the project, then a component folder inside it, and finally a Calendar.tsx file in that folder.
Copy and paste the following code into your project.
ui/components/Calendar.tsx
1"use client"23import { useState, useMemo, useCallback } from "react"4import { cn } from "@/lib/utils"5import ChevronLeft from "@/ui/icons/ChevronLeft"6import ChevronRight from "@/ui/icons/ChevronRight"78// Types9export type DateRange = {10 from: Date | null11 to: Date | null12}1314interface CalendarBaseProps {15 disabled?: Date[] | ((date: Date) => boolean)16 booked?: Date[]17 min?: Date18 max?: Date19 className?: string20 showOutsideDays?: boolean21 weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 622}2324interface CalendarSingleProps extends CalendarBaseProps {25 mode?: "single"26 selected?: Date | null27 defaultSelected?: Date | null28 onSelect?: (date: Date | null) => void29}3031interface CalendarRangeProps extends CalendarBaseProps {32 mode: "range"33 selected?: DateRange | null34 defaultSelected?: DateRange | null35 onSelect?: (range: DateRange | null) => void36}3738interface CalendarMultipleProps extends CalendarBaseProps {39 mode: "multiple"40 selected?: Date[]41 defaultSelected?: Date[]42 onSelect?: (dates: Date[]) => void43}4445type CalendarProps = CalendarSingleProps | CalendarRangeProps | CalendarMultipleProps4647// Utility functions48const getDaysInMonth = (year: number, month: number) => {49 return new Date(year, month + 1, 0).getDate()50}5152const getFirstDayOfMonth = (year: number, month: number) => {53 return new Date(year, month, 1).getDay()54}5556const isSameDay = (date1: Date, date2: Date) => {57 return (58 date1.getFullYear() === date2.getFullYear() &&59 date1.getMonth() === date2.getMonth() &&60 date1.getDate() === date2.getDate()61 )62}6364const isDateInRange = (date: Date, from: Date | null, to: Date | null) => {65 if (!from || !to) return false66 const time = date.getTime()67 return time >= from.getTime() && time <= to.getTime()68}6970const isDateDisabled = (71 date: Date,72 disabled?: Date[] | ((date: Date) => boolean),73 booked?: Date[],74 min?: Date,75 max?: Date76) => {77 if (min && date < min) return true78 if (max && date > max) return true7980 if (booked?.some(d => isSameDay(d, date))) return true8182 if (typeof disabled === "function") {83 return disabled(date)84 }8586 if (Array.isArray(disabled)) {87 return disabled.some(d => isSameDay(d, date))88 }8990 return false91}9293const DAYS_SHORT = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]94const MONTHS = [95 "January", "February", "March", "April", "May", "June",96 "July", "August", "September", "October", "November", "December"97]9899export function Calendar(props: CalendarProps) {100 const {101 mode = "single",102 selected: controlledSelected,103 onSelect,104 disabled,105 booked,106 min,107 max,108 className,109 showOutsideDays = true,110 weekStartsOn = 0,111 } = props112113 // Get defaultSelected based on mode114 const defaultSelected = "defaultSelected" in props ? props.defaultSelected : undefined115116 // Internal state for uncontrolled mode117 const [internalSingleDate, setInternalSingleDate] = useState<Date | null>(118 mode === "single" ? (defaultSelected as Date | null) ?? null : null119 )120 const [internalRange, setInternalRange] = useState<DateRange>(121 mode === "range" ? (defaultSelected as DateRange | null) ?? { from: null, to: null } : { from: null, to: null }122 )123 const [internalMultiple, setInternalMultiple] = useState<Date[]>(124 mode === "multiple" ? (defaultSelected as Date[]) ?? [] : []125 )126127 // Determine if controlled or uncontrolled128 const isControlled = controlledSelected !== undefined129130 // Get the actual selected value (controlled or uncontrolled)131 const selected = isControlled132 ? controlledSelected133 : mode === "single"134 ? internalSingleDate135 : mode === "range"136 ? internalRange137 : internalMultiple138139 const today = new Date()140 const [currentMonth, setCurrentMonth] = useState(today.getMonth())141 const [currentYear, setCurrentYear] = useState(today.getFullYear())142 const [hoverDate, setHoverDate] = useState<Date | null>(null)143144 // Reorder days based on weekStartsOn145 const orderedDays = useMemo(() => {146 const days = [...DAYS_SHORT]147 const before = days.splice(0, weekStartsOn)148 return [...days, ...before]149 }, [weekStartsOn])150151 // Generate calendar days152 const calendarDays = useMemo(() => {153 const daysInMonth = getDaysInMonth(currentYear, currentMonth)154 const firstDay = getFirstDayOfMonth(currentYear, currentMonth)155 const adjustedFirstDay = (firstDay - weekStartsOn + 7) % 7156157 const days: { date: Date; isCurrentMonth: boolean }[] = []158159 // Previous month days160 const prevMonth = currentMonth === 0 ? 11 : currentMonth - 1161 const prevYear = currentMonth === 0 ? currentYear - 1 : currentYear162 const daysInPrevMonth = getDaysInMonth(prevYear, prevMonth)163164 for (let i = adjustedFirstDay - 1; i >= 0; i--) {165 days.push({166 date: new Date(prevYear, prevMonth, daysInPrevMonth - i),167 isCurrentMonth: false,168 })169 }170171 // Current month days172 for (let i = 1; i <= daysInMonth; i++) {173 days.push({174 date: new Date(currentYear, currentMonth, i),175 isCurrentMonth: true,176 })177 }178179 // Next month days180 const nextMonth = currentMonth === 11 ? 0 : currentMonth + 1181 const nextYear = currentMonth === 11 ? currentYear + 1 : currentYear182 const remainingDays = 42 - days.length // 6 rows × 7 days183184 for (let i = 1; i <= remainingDays; i++) {185 days.push({186 date: new Date(nextYear, nextMonth, i),187 isCurrentMonth: false,188 })189 }190191 return days192 }, [currentMonth, currentYear, weekStartsOn])193194 const goToPreviousMonth = useCallback(() => {195 if (currentMonth === 0) {196 setCurrentMonth(11)197 setCurrentYear(y => y - 1)198 } else {199 setCurrentMonth(m => m - 1)200 }201 }, [currentMonth])202203 const goToNextMonth = useCallback(() => {204 if (currentMonth === 11) {205 setCurrentMonth(0)206 setCurrentYear(y => y + 1)207 } else {208 setCurrentMonth(m => m + 1)209 }210 }, [currentMonth])211212 const handleDateClick = useCallback((date: Date) => {213 if (isDateDisabled(date, disabled, booked, min, max)) return214215 if (mode === "single") {216 // Update internal state (for uncontrolled mode)217 if (!isControlled) {218 setInternalSingleDate(date)219 }220 // Call onSelect callback if provided221 (onSelect as ((date: Date | null) => void) | undefined)?.(date)222 } else if (mode === "multiple") {223 const currentSelected = (selected as Date[]) || []224 const isAlreadySelected = currentSelected.some(d => isSameDay(d, date))225226 let newSelection: Date[]227 if (isAlreadySelected) {228 newSelection = currentSelected.filter(d => !isSameDay(d, date))229 } else {230 newSelection = [...currentSelected, date]231 }232233 // Update internal state (for uncontrolled mode)234 if (!isControlled) {235 setInternalMultiple(newSelection)236 }237 // Call onSelect callback if provided238 (onSelect as ((dates: Date[]) => void) | undefined)?.(newSelection)239 } else if (mode === "range") {240 const range = (selected as DateRange) || { from: null, to: null }241242 let newRange: DateRange243 if (!range.from || (range.from && range.to)) {244 // Start new range245 newRange = { from: date, to: null }246 } else {247 // Complete range248 if (date < range.from) {249 newRange = { from: date, to: range.from }250 } else {251 newRange = { from: range.from, to: date }252 }253 }254255 // Update internal state (for uncontrolled mode)256 if (!isControlled) {257 setInternalRange(newRange)258 }259 // Call onSelect callback if provided260 (onSelect as ((range: DateRange | null) => void) | undefined)?.(newRange)261 }262 }, [mode, selected, onSelect, disabled, booked, min, max, isControlled])263264 const isSelected = useCallback((date: Date) => {265 if (!selected) return false266267 if (mode === "single") {268 return isSameDay(date, selected as Date)269 } else if (mode === "multiple") {270 return (selected as Date[]).some(d => isSameDay(d, date))271 } else if (mode === "range") {272 const range = selected as DateRange273 if (range.from && isSameDay(date, range.from)) return true274 if (range.to && isSameDay(date, range.to)) return true275 return false276 }277278 return false279 }, [mode, selected])280281 const isInRange = useCallback((date: Date) => {282 if (mode !== "range") return false283284 const range = selected as DateRange285 if (!range?.from) return false286287 // If we have both from and to, check if date is between288 if (range.to) {289 return isDateInRange(date, range.from, range.to) && !isSameDay(date, range.from) && !isSameDay(date, range.to)290 }291292 // If only from and we're hovering, show preview range293 if (hoverDate) {294 const start = range.from < hoverDate ? range.from : hoverDate295 const end = range.from < hoverDate ? hoverDate : range.from296 return isDateInRange(date, start, end) && !isSameDay(date, range.from) && !isSameDay(date, hoverDate)297 }298299 return false300 }, [mode, selected, hoverDate])301302 const isRangeStart = useCallback((date: Date) => {303 if (mode !== "range") return false304 const range = selected as DateRange305 return range?.from ? isSameDay(date, range.from) : false306 }, [mode, selected])307308 const isRangeEnd = useCallback((date: Date) => {309 if (mode !== "range") return false310 const range = selected as DateRange311 return range?.to ? isSameDay(date, range.to) : false312 }, [mode, selected])313314 return (315 <div className={cn("p-3 bg-background border border-border rounded-lg w-fit", className)}>316 {/* Header */}317 <div className="flex items-center justify-between mb-4">318 <button319 type="button"320 onClick={goToPreviousMonth}321 className="p-1.5 hover:bg-accent rounded-md transition-colors"322 aria-label="Previous month"323 >324 <ChevronLeft className="h-4 w-4" />325 </button>326327 <div className="font-medium text-sm">328 {MONTHS[currentMonth]} {currentYear}329 </div>330331 <button332 type="button"333 onClick={goToNextMonth}334 className="p-1.5 hover:bg-accent rounded-md transition-colors"335 aria-label="Next month"336 >337 <ChevronRight className="h-4 w-4" />338 </button>339 </div>340341 {/* Days of week */}342 <div className="grid grid-cols-7 mb-2">343 {orderedDays.map((day) => (344 <div345 key={day}346 className="h-8 w-8 flex items-center justify-center text-xs font-medium text-muted-foreground"347 >348 {day}349 </div>350 ))}351 </div>352353 {/* Calendar grid */}354 <div className="grid grid-cols-7">355 {calendarDays.map(({ date, isCurrentMonth }, index) => {356 const isDisabled = isDateDisabled(date, disabled, booked, min, max)357 const isBooked = booked?.some(d => isSameDay(d, date))358 const isToday = isSameDay(date, today)359 const selected = isSelected(date)360 const inRange = isInRange(date)361 const rangeStart = isRangeStart(date)362 const rangeEnd = isRangeEnd(date)363364 if (!showOutsideDays && !isCurrentMonth) {365 return <div key={index} className="h-8 w-8" />366 }367368 return (369 <button370 key={index}371 type="button"372 disabled={isDisabled}373 onClick={() => handleDateClick(date)}374 onMouseEnter={() => mode === "range" && setHoverDate(date)}375 onMouseLeave={() => mode === "range" && setHoverDate(null)}376 className={cn(377 "h-8 w-8 text-sm rounded-md transition-colors relative",378 "focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",379 !isCurrentMonth && "text-muted-foreground/50",380 isCurrentMonth && !selected && !isDisabled && "hover:bg-accent",381 isToday && !selected && "text-primary",382 selected && "bg-primary text-primary-foreground hover:bg-primary/90",383 inRange && "bg-accent",384 rangeStart && "rounded-r-none",385 rangeEnd && "rounded-l-none",386 inRange && !rangeStart && !rangeEnd && "rounded-none",387 isDisabled && "opacity-50 cursor-not-allowed",388 isBooked && "line-through decoration-2"389 )}390 >391 {date.getDate()}392 </button>393 )394 })}395 </div>396 </div>397 )398}399Usages
Different variants and use cases for the Calendar component.
Default
A basic calendar for single date selection. Works without any state management - just render it!
Default.tsx
<Calendar />Date Range
Select a range of dates (from - to). No external state needed.
Date Range.tsx
<Calendar mode="range" />Multiple Dates
Select multiple individual dates.
Multiple Dates.tsx
<Calendar mode="multiple" />With Disabled Dates
Disable specific dates or use a function to determine disabled dates.
With Disabled Dates.tsx
// Disable the 15th and 20th of the current month
const now = new Date()
const year = now.getFullYear()
const month = now.getMonth()
<Calendar
disabled={[
new Date(year, month, 15),
new Date(year, month, 20),
]}
/>With Booked Dates
Show dates as booked (strikethrough) and make them unselectable.
With Booked Dates.tsx
// Mark dates as booked (10th, 11th, 12th)
const now = new Date()
const year = now.getFullYear()
const month = now.getMonth()
<Calendar
booked={[
new Date(year, month, 10),
new Date(year, month, 11),
new Date(year, month, 12),
]}
/>With Min and Max
Restrict selection to a specific date range.
With Min and Max.tsx
// Only allow selection between 5th and 25th of current month
const now = new Date()
const year = now.getFullYear()
const month = now.getMonth()
<Calendar
min={new Date(year, month, 5)}
max={new Date(year, month, 25)}
/>With Default Selected
Set an initial selected date without controlling the state.
With Default Selected.tsx
// Today's date is pre-selected
<Calendar defaultSelected={new Date()} />Range with Default
Set an initial date range.
Range with Default.tsx
// Pre-select a range from 10th to 15th of current month
const now = new Date()
const year = now.getFullYear()
const month = now.getMonth()
<Calendar
mode="range"
defaultSelected={{
from: new Date(year, month, 10),
to: new Date(year, month, 15),
}}
/>Controlled Mode
For full control, pass selected and onSelect props.
Controlled Mode.tsx
const [date, setDate] = useState<Date | null>(null)
<Calendar
selected={date}
onSelect={setDate}
/>