Docs/Components/Calendar

Calendar

A date picker component with support for single date, date range, and multiple dates selection.

January 2026
Su
Mo
Tu
We
Th
Fr
Sa

Installation

Install the component Calendar in your project using the CLI.

Calendar.tsx

pnpm dlx behsseui@latest add Calendar

Install 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"
2
3import { useState, useMemo, useCallback } from "react"
4import { cn } from "@/lib/utils"
5import ChevronLeft from "@/ui/icons/ChevronLeft"
6import ChevronRight from "@/ui/icons/ChevronRight"
7
8// Types
9export type DateRange = {
10 from: Date | null
11 to: Date | null
12}
13
14interface CalendarBaseProps {
15 disabled?: Date[] | ((date: Date) => boolean)
16 booked?: Date[]
17 min?: Date
18 max?: Date
19 className?: string
20 showOutsideDays?: boolean
21 weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6
22}
23
24interface CalendarSingleProps extends CalendarBaseProps {
25 mode?: "single"
26 selected?: Date | null
27 defaultSelected?: Date | null
28 onSelect?: (date: Date | null) => void
29}
30
31interface CalendarRangeProps extends CalendarBaseProps {
32 mode: "range"
33 selected?: DateRange | null
34 defaultSelected?: DateRange | null
35 onSelect?: (range: DateRange | null) => void
36}
37
38interface CalendarMultipleProps extends CalendarBaseProps {
39 mode: "multiple"
40 selected?: Date[]
41 defaultSelected?: Date[]
42 onSelect?: (dates: Date[]) => void
43}
44
45type CalendarProps = CalendarSingleProps | CalendarRangeProps | CalendarMultipleProps
46
47// Utility functions
48const getDaysInMonth = (year: number, month: number) => {
49 return new Date(year, month + 1, 0).getDate()
50}
51
52const getFirstDayOfMonth = (year: number, month: number) => {
53 return new Date(year, month, 1).getDay()
54}
55
56const isSameDay = (date1: Date, date2: Date) => {
57 return (
58 date1.getFullYear() === date2.getFullYear() &&
59 date1.getMonth() === date2.getMonth() &&
60 date1.getDate() === date2.getDate()
61 )
62}
63
64const isDateInRange = (date: Date, from: Date | null, to: Date | null) => {
65 if (!from || !to) return false
66 const time = date.getTime()
67 return time >= from.getTime() && time <= to.getTime()
68}
69
70const isDateDisabled = (
71 date: Date,
72 disabled?: Date[] | ((date: Date) => boolean),
73 booked?: Date[],
74 min?: Date,
75 max?: Date
76) => {
77 if (min && date < min) return true
78 if (max && date > max) return true
79
80 if (booked?.some(d => isSameDay(d, date))) return true
81
82 if (typeof disabled === "function") {
83 return disabled(date)
84 }
85
86 if (Array.isArray(disabled)) {
87 return disabled.some(d => isSameDay(d, date))
88 }
89
90 return false
91}
92
93const 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]
98
99export 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 } = props
112
113 // Get defaultSelected based on mode
114 const defaultSelected = "defaultSelected" in props ? props.defaultSelected : undefined
115
116 // Internal state for uncontrolled mode
117 const [internalSingleDate, setInternalSingleDate] = useState<Date | null>(
118 mode === "single" ? (defaultSelected as Date | null) ?? null : null
119 )
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 )
126
127 // Determine if controlled or uncontrolled
128 const isControlled = controlledSelected !== undefined
129
130 // Get the actual selected value (controlled or uncontrolled)
131 const selected = isControlled
132 ? controlledSelected
133 : mode === "single"
134 ? internalSingleDate
135 : mode === "range"
136 ? internalRange
137 : internalMultiple
138
139 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)
143
144 // Reorder days based on weekStartsOn
145 const orderedDays = useMemo(() => {
146 const days = [...DAYS_SHORT]
147 const before = days.splice(0, weekStartsOn)
148 return [...days, ...before]
149 }, [weekStartsOn])
150
151 // Generate calendar days
152 const calendarDays = useMemo(() => {
153 const daysInMonth = getDaysInMonth(currentYear, currentMonth)
154 const firstDay = getFirstDayOfMonth(currentYear, currentMonth)
155 const adjustedFirstDay = (firstDay - weekStartsOn + 7) % 7
156
157 const days: { date: Date; isCurrentMonth: boolean }[] = []
158
159 // Previous month days
160 const prevMonth = currentMonth === 0 ? 11 : currentMonth - 1
161 const prevYear = currentMonth === 0 ? currentYear - 1 : currentYear
162 const daysInPrevMonth = getDaysInMonth(prevYear, prevMonth)
163
164 for (let i = adjustedFirstDay - 1; i >= 0; i--) {
165 days.push({
166 date: new Date(prevYear, prevMonth, daysInPrevMonth - i),
167 isCurrentMonth: false,
168 })
169 }
170
171 // Current month days
172 for (let i = 1; i <= daysInMonth; i++) {
173 days.push({
174 date: new Date(currentYear, currentMonth, i),
175 isCurrentMonth: true,
176 })
177 }
178
179 // Next month days
180 const nextMonth = currentMonth === 11 ? 0 : currentMonth + 1
181 const nextYear = currentMonth === 11 ? currentYear + 1 : currentYear
182 const remainingDays = 42 - days.length // 6 rows × 7 days
183
184 for (let i = 1; i <= remainingDays; i++) {
185 days.push({
186 date: new Date(nextYear, nextMonth, i),
187 isCurrentMonth: false,
188 })
189 }
190
191 return days
192 }, [currentMonth, currentYear, weekStartsOn])
193
194 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])
202
203 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])
211
212 const handleDateClick = useCallback((date: Date) => {
213 if (isDateDisabled(date, disabled, booked, min, max)) return
214
215 if (mode === "single") {
216 // Update internal state (for uncontrolled mode)
217 if (!isControlled) {
218 setInternalSingleDate(date)
219 }
220 // Call onSelect callback if provided
221 (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))
225
226 let newSelection: Date[]
227 if (isAlreadySelected) {
228 newSelection = currentSelected.filter(d => !isSameDay(d, date))
229 } else {
230 newSelection = [...currentSelected, date]
231 }
232
233 // Update internal state (for uncontrolled mode)
234 if (!isControlled) {
235 setInternalMultiple(newSelection)
236 }
237 // Call onSelect callback if provided
238 (onSelect as ((dates: Date[]) => void) | undefined)?.(newSelection)
239 } else if (mode === "range") {
240 const range = (selected as DateRange) || { from: null, to: null }
241
242 let newRange: DateRange
243 if (!range.from || (range.from && range.to)) {
244 // Start new range
245 newRange = { from: date, to: null }
246 } else {
247 // Complete range
248 if (date < range.from) {
249 newRange = { from: date, to: range.from }
250 } else {
251 newRange = { from: range.from, to: date }
252 }
253 }
254
255 // Update internal state (for uncontrolled mode)
256 if (!isControlled) {
257 setInternalRange(newRange)
258 }
259 // Call onSelect callback if provided
260 (onSelect as ((range: DateRange | null) => void) | undefined)?.(newRange)
261 }
262 }, [mode, selected, onSelect, disabled, booked, min, max, isControlled])
263
264 const isSelected = useCallback((date: Date) => {
265 if (!selected) return false
266
267 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 DateRange
273 if (range.from && isSameDay(date, range.from)) return true
274 if (range.to && isSameDay(date, range.to)) return true
275 return false
276 }
277
278 return false
279 }, [mode, selected])
280
281 const isInRange = useCallback((date: Date) => {
282 if (mode !== "range") return false
283
284 const range = selected as DateRange
285 if (!range?.from) return false
286
287 // If we have both from and to, check if date is between
288 if (range.to) {
289 return isDateInRange(date, range.from, range.to) && !isSameDay(date, range.from) && !isSameDay(date, range.to)
290 }
291
292 // If only from and we're hovering, show preview range
293 if (hoverDate) {
294 const start = range.from < hoverDate ? range.from : hoverDate
295 const end = range.from < hoverDate ? hoverDate : range.from
296 return isDateInRange(date, start, end) && !isSameDay(date, range.from) && !isSameDay(date, hoverDate)
297 }
298
299 return false
300 }, [mode, selected, hoverDate])
301
302 const isRangeStart = useCallback((date: Date) => {
303 if (mode !== "range") return false
304 const range = selected as DateRange
305 return range?.from ? isSameDay(date, range.from) : false
306 }, [mode, selected])
307
308 const isRangeEnd = useCallback((date: Date) => {
309 if (mode !== "range") return false
310 const range = selected as DateRange
311 return range?.to ? isSameDay(date, range.to) : false
312 }, [mode, selected])
313
314 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 <button
319 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>
326
327 <div className="font-medium text-sm">
328 {MONTHS[currentMonth]} {currentYear}
329 </div>
330
331 <button
332 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>
340
341 {/* Days of week */}
342 <div className="grid grid-cols-7 mb-2">
343 {orderedDays.map((day) => (
344 <div
345 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>
352
353 {/* 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)
363
364 if (!showOutsideDays && !isCurrentMonth) {
365 return <div key={index} className="h-8 w-8" />
366 }
367
368 return (
369 <button
370 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}
399

Usages

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!

January 2026
Su
Mo
Tu
We
Th
Fr
Sa

Default.tsx

<Calendar />

Date Range

Select a range of dates (from - to). No external state needed.

January 2026
Su
Mo
Tu
We
Th
Fr
Sa

Date Range.tsx

<Calendar mode="range" />

Multiple Dates

Select multiple individual dates.

January 2026
Su
Mo
Tu
We
Th
Fr
Sa

Multiple Dates.tsx

<Calendar mode="multiple" />

With Disabled Dates

Disable specific dates or use a function to determine disabled dates.

January 2026
Su
Mo
Tu
We
Th
Fr
Sa

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.

January 2026
Su
Mo
Tu
We
Th
Fr
Sa

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.

January 2026
Su
Mo
Tu
We
Th
Fr
Sa

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.

January 2026
Su
Mo
Tu
We
Th
Fr
Sa

With Default Selected.tsx

// Today's date is pre-selected
<Calendar defaultSelected={new Date()} />

Range with Default

Set an initial date range.

January 2026
Su
Mo
Tu
We
Th
Fr
Sa

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.

January 2026
Su
Mo
Tu
We
Th
Fr
Sa

Controlled Mode.tsx

const [date, setDate] = useState<Date | null>(null)

<Calendar
  selected={date}
  onSelect={setDate}
/>