Calendar
A component for selecting a date or date range with month and year navigation controls
S
M
T
W
T
F
S
'use client';
import { useState } from 'react';
import { Calendar } from '@/components/calendar';
export default function CalendarPreview() {
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
return <Calendar value={selectedDate} onDateChange={setSelectedDate} />;
} Dependencies
Source Code
'use client';
import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react';
import { add, format, isSameDay, isSameMonth } from 'date-fns';
import { useMemo, useState } from 'react';
import { Button } from '@/components/button';
import { cn } from '@/lib/utils/classnames';
/**
* Generate a matrix of dates for a given month
* Dates from previous and next month are included and marked as negative numbers.
*
* @param year The year
* @param month The month (1-12)
* @param options Configuration options
* @returns Matrix of dates. Each row is a week, each column is a day.
*/
const generateMonth = (
year: number,
month: number,
options: {
onlyCurrentMonthRows?: boolean;
startWeekOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
} = {}
): readonly (readonly number[])[] => {
const date = new Date(year, month - 1);
const startWeekOn = options.startWeekOn ?? 1; // Default to Monday (1)
const firstWeekDay = (date.getDay() - startWeekOn + 7) % 7;
const daysInMonth = new Date(year, month, 0).getDate();
const daysInPrevMonth = new Date(year, month - 1, 0).getDate();
const generateDay = (index: number): number => {
const currentDay = index - firstWeekDay + 1;
if (currentDay <= 0) {
return -(daysInPrevMonth + currentDay);
} else if (currentDay > daysInMonth) {
return -(currentDay - daysInMonth);
}
return currentDay;
};
const generateWeek = (weekIndex: number): readonly number[] =>
Array.from({ length: 7 }, (_, i) => generateDay(weekIndex * 7 + i));
const matrix = Array.from({ length: 6 }, (_, i) => generateWeek(i));
return options.onlyCurrentMonthRows
? matrix.filter((week) => week.some((day) => day > 0))
: matrix;
};
interface CalendarCommonProps extends React.ComponentPropsWithRef<'div'> {
getIsDisabled?: (date: Date) => boolean;
startWeekOn?: 0 | 2 | 1 | 3 | 4 | 5 | 6;
locale?: Intl.LocalesArgument;
}
interface CalendarSingleProps extends CalendarCommonProps {
value: Date | null;
onDateChange: (date: Date) => void;
mode?: 'single';
}
interface CalendarRangeProps extends CalendarCommonProps {
value: [Date, Date] | null;
onDateChange: (dates: [Date, Date]) => void;
mode: 'range';
}
type CalendarProps = CalendarSingleProps | CalendarRangeProps;
type CalendarView = 'days' | 'months' | 'years';
const sortDates = (dates: [Date, Date]): [Date, Date] => {
return [...dates].sort((a, b) => a.getTime() - b.getTime()) as [Date, Date];
};
const Calendar = ({
mode,
onDateChange,
value,
getIsDisabled = () => false,
startWeekOn = 0,
locale = 'en',
className,
...props
}: CalendarProps) => {
const [view, setView] = useState<CalendarView>('days');
const [viewDate, setViewDate] = useState<Date>(
mode === 'range' ? value?.[0] || new Date() : value || new Date()
);
// used to track a date range while it's being selected
const [transientRange, setTransientRange] = useState<[Date, Date] | null>(
null
);
const [startDate, endDate] = useMemo(() => {
if (transientRange) {
return sortDates(transientRange);
}
if (!value) {
return [null, null];
}
if (mode === 'range') {
return sortDates(value);
}
return sortDates([value, value]);
}, [mode, value, transientRange]);
const month = useMemo(() => {
const matrix = generateMonth(
viewDate.getFullYear(),
viewDate.getMonth() + 1,
{
startWeekOn,
}
);
return matrix.map((row, i) =>
row.map((day) => {
const year = viewDate.getFullYear();
const month = viewDate.getMonth();
const date = new Date(year, month, 1);
if (i === 0 && day < 0) {
date.setMonth(date.getMonth() - 1);
} else if (day < 0) {
date.setMonth(date.getMonth() + 1);
}
date.setDate(Math.abs(day));
return date;
})
);
}, [viewDate, startWeekOn]);
const handleDaySelect = (date: Date) => {
// not a range selection
if (mode !== 'range') {
return onDateChange(date);
}
// start a range selection
if (!transientRange) {
return setTransientRange([date, date]);
}
// end selection
setTransientRange(null);
return onDateChange(sortDates([transientRange[0], date]));
};
const handleDayHover = (date: Date) => {
if (mode !== 'range' || !transientRange) return;
setTransientRange([transientRange[0], date]);
};
return (
<div className={cn('bg-background', className)} {...props}>
{view === 'years' && (
<CalendarHeader
onPrevious={() => setViewDate((prev) => add(prev, { years: -12 }))}
onNext={() => setViewDate((prev) => add(prev, { years: 12 }))}
>
<HeaderTextButton
className="flex items-center gap-1"
onClick={() => setView('days')}
>
<span>{viewDate.getFullYear() - 5}</span>
<span>{'–'}</span>
<span>{viewDate.getFullYear() + 6}</span>
</HeaderTextButton>
</CalendarHeader>
)}
{view === 'months' && (
<CalendarHeader
onPrevious={() => setViewDate((prev) => add(prev, { years: -1 }))}
onNext={() => setViewDate((prev) => add(prev, { years: 1 }))}
>
<HeaderTextButton onClick={() => setView('years')}>
{viewDate.getFullYear()}
</HeaderTextButton>
</CalendarHeader>
)}
{view === 'days' && (
<CalendarHeader
onPrevious={() => setViewDate((prev) => add(prev, { months: -1 }))}
onNext={() => setViewDate((prev) => add(prev, { months: 1 }))}
>
<div className="flex items-center gap-1 text-sm">
<HeaderTextButton onClick={() => setView('months')}>
{viewDate.toLocaleDateString(locale, { month: 'long' })}
</HeaderTextButton>
<HeaderTextButton onClick={() => setView('years')}>
{viewDate.getFullYear()}
</HeaderTextButton>
</div>
</CalendarHeader>
)}
<div className="relative">
{(view === 'years' || view === 'months') && (
<div className="absolute inset-0 z-10 bg-background p-1">
{view === 'years' && (
<div className="grid size-full grid-cols-3 grid-rows-4 p-0.5">
{Array.from({ length: 12 }, (_, i) => {
const year = viewDate.getFullYear() - 5 + i;
const date = new Date(year, viewDate.getMonth(), 1);
return (
<YearMonthButton
key={i}
className="h-full"
onClick={() => {
setViewDate(date);
setView('months');
}}
>
{year}
</YearMonthButton>
);
})}
</div>
)}
{view === 'months' && (
<div className="grid size-full grid-cols-3 grid-rows-4 p-0.5">
{Array.from({ length: 12 }, (_, i) => {
const date = new Date(viewDate.getFullYear(), i, 1);
return (
<YearMonthButton
key={i}
className="h-full"
onClick={() => {
setViewDate(date);
setView('days');
}}
>
{date.toLocaleDateString(locale, { month: 'short' })}
</YearMonthButton>
);
})}
</div>
)}
</div>
)}
<div className="grid grid-cols-7">
{Array.from({ length: 7 }, (_, i) => {
const weekday = new Date(2024, 0, (i + startWeekOn) % 7);
return (
<div
key={i}
className="flex h-9 w-full min-w-9 items-center justify-center text-foreground-secondary text-sm"
>
{weekday.toLocaleDateString(locale, { weekday: 'narrow' })}
</div>
);
})}
</div>
{month.map((row, i) => (
<div className="grid grid-cols-7" key={i}>
{row.map((day, ii) => {
const hasValue = !!startDate && !!endDate;
const isStartDate = hasValue && isSameDay(day, startDate);
const isEndDate = hasValue && isSameDay(day, endDate);
const isSelected = isStartDate || isEndDate;
const isInRange =
hasValue &&
day > startDate &&
day < endDate &&
!isSameDay(day, startDate) &&
!isSameDay(day, endDate);
const isToday = isSameDay(day, new Date());
return (
<button
type="button"
key={ii}
aria-label={format(day, 'yyyy-MM-dd')}
data-other-month={!isSameMonth(day, viewDate) || undefined}
data-start-date={isStartDate || undefined}
data-end-date={isEndDate || undefined}
data-selected={isSelected || undefined}
data-in-range={isInRange || undefined}
data-today={isToday || undefined}
disabled={getIsDisabled(day)}
className={cn(
'group relative isolate flex h-9 w-full min-w-9 cursor-pointer items-center justify-center text-foreground outline-none disabled:pointer-events-none disabled:opacity-30'
)}
onClick={() => handleDaySelect(day)}
onMouseEnter={() => handleDayHover(day)}
>
{/* Date */}
{/* Styles are in an element inside because there's a small margin around the squares but the hover needs to account for the entire square to avoid flashing of hovered items */}
<div
className={cn(
// base
'z-10 flex size-8 items-center justify-center rounded-lg border border-transparent font-medium text-foreground/80 text-sm tabular-nums ring-ring transition-shadow',
// hover
'group-hover:bg-foreground/5 group-hover:text-foreground',
// selected
'group-data-selected:bg-accent group-data-selected:text-accent-foreground group-hover:group-data-selected:text-accent-foreground',
// other month
'group-data-other-month:text-foreground-secondary',
// focus
'group-focus-visible:ring-4'
)}
>
{day.getDate()}
</div>
{/* Today marker */}
{isToday && (
<div
aria-hidden
className={cn(
'absolute bottom-1.5 left-1/2 z-20 h-0.5 w-1.5 -translate-x-1/2 rounded-md bg-foreground',
isSelected && 'bg-accent-foreground'
)}
/>
)}
{/* Date range background */}
<div
aria-hidden
className={cn(
'invisible absolute inset-x-0 inset-y-0.5 z-0 bg-background-secondary',
'group-data-in-range:visible',
'group-data-start-date:visible group-data-start-date:left-1/2',
'group-data-end-date:visible group-data-end-date:right-1/2'
)}
/>
</button>
);
})}
</div>
))}
</div>
</div>
);
};
interface CalendarHeaderProps {
onPrevious: () => void;
onNext: () => void;
children: React.ReactNode;
}
const CalendarHeader = ({
onPrevious,
onNext,
children,
}: CalendarHeaderProps) => {
return (
<div className="flex items-center justify-between p-1.5 font-medium text-sm">
<Button variant="outline" square onClick={onPrevious}>
<CaretLeftIcon />
</Button>
{children}
<Button variant="outline" square onClick={onNext}>
<CaretRightIcon />
</Button>
</div>
);
};
const HeaderTextButton = ({
children,
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
return (
<button
type="button"
className={cn(
'cursor-pointer rounded-sm font-medium text-foreground/80 outline-none ring-ring transition hover:text-foreground focus-visible:text-foreground focus-visible:ring-4',
className
)}
{...props}
>
{children}
</button>
);
};
const YearMonthButton = ({
children,
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
return (
<button
type="button"
className={cn(
'h-8 w-full cursor-pointer rounded-lg font-medium text-foreground/80 text-sm outline-none ring-ring transition hover:bg-foreground/5 hover:text-foreground focus-visible:text-foreground focus-visible:ring-4',
className
)}
{...props}
>
{children}
</button>
);
};
export { Calendar }; Features
- Multiple Modes: Support for single date and date range selection
- Flexible Week Start: Configurable first day of the week
- Internationalization: Full locale support through Intl API
- Date Constraints: Ability to disable specific dates
- Navigation Controls: Month and year navigation with buttons
API Reference
Extends the div element.
Single
When mode="single"{:tsx} (or when ommited).
| Prop | Default | Type | Description |
|---|---|---|---|
mode | "single" | "single" | Sets the calendar to single date selection mode. |
value * | - | Date | The currently selected date. |
onDateChange * | - | (date: Date) => void | Callback fired when a date is selected. |
Range
When mode="range"{:tsx}.
| Prop | Default | Type | Description |
|---|---|---|---|
mode * | - | "range" | Sets the calendar to date range selection mode. |
value * | - | [Date, Date] | The currently selected date range (start and end dates). |
onDateChange * | - | (dates: [Date, Date]) => void | Callback fired when a date range is selected. |
Common
Applies to both mode="single"{:tsx} and mode="range"{:tsx}.
| Prop | Default | Type | Description |
|---|---|---|---|
startWeekOn | 0 | 0123456 | The day the week starts on (0 = Sunday, 1 = Monday, etc.). |
locale | "en" | Intl.LocalesArgument | The locale to use for formatting dates and weekday names. |
getIsDisabled | - | (date: Date) => boolean | Function to determine if a date should be disabled. |
Examples
Single Date
Basic usage with single date selection.
S
M
T
W
T
F
S
'use client';
import { useState } from 'react';
import { Calendar } from '@/components/calendar';
export default function CalendarPreview() {
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
return <Calendar value={selectedDate} onDateChange={setSelectedDate} />;
} Date range
Selecting a range of dates.
S
M
T
W
T
F
S
'use client';
import { useState } from 'react';
import { Calendar } from '@/components/calendar';
export default function CalendarRangePreview() {
const [dateRange, setDateRange] = useState<[Date, Date]>([
new Date(Date.now() - 5 * 24 * 60 * 60 * 1000),
new Date(),
]);
return (
<Calendar mode="range" value={dateRange} onDateChange={setDateRange} />
);
} Start week on Monday
Configuring the calendar to start weeks on Monday.
M
T
W
T
F
S
S
'use client';
import { useState } from 'react';
import { Calendar } from '@/components/calendar';
export default function CalendarStartDayPreview() {
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
return (
<Calendar
value={selectedDate}
onDateChange={setSelectedDate}
startWeekOn={1} // Start on Monday
/>
);
} Disable future dates
Using getIsDisabled to prevent selecting future dates.
S
M
T
W
T
F
S
'use client';
import { useState } from 'react';
import { Calendar } from '@/components/calendar';
export default function CalendarDisableFuturePreview() {
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
return (
<Calendar
value={selectedDate}
onDateChange={setSelectedDate}
getIsDisabled={(date: Date) => date > new Date()}
/>
);
} Locale
Using a different locale for date formatting.
D
S
T
Q
Q
S
S
'use client';
import { useState } from 'react';
import { Calendar } from '@/components/calendar';
export default function CalendarLocalePreview() {
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
return (
<Calendar value={selectedDate} onDateChange={setSelectedDate} locale="pt" />
);
} Best Practices
-
Date Handling:
- Always validate dates before passing them to the component
- Consider timezone implications when handling dates
- Use consistent date formatting throughout your application
-
Accessibility:
- Provide clear instructions for date selection
- Consider adding helper text for date format requirements
- Ensure error messages are clear when invalid dates are selected
-
Mobile Considerations:
- Test touch interactions for date selection
- Ensure sufficient spacing between dates for touch targets
- Consider using native date pickers on mobile devices
-
Validation:
- Implement clear validation rules using getIsDisabled
- Show visual feedback for invalid date selections
- Consider date ranges that make sense for your use case
Previous
Button
Next
Checkbox