Octocat

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

  1. Date Handling:

    • Always validate dates before passing them to the component
    • Consider timezone implications when handling dates
    • Use consistent date formatting throughout your application
  2. 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
  3. Mobile Considerations:

    • Test touch interactions for date selection
    • Ensure sufficient spacing between dates for touch targets
    • Consider using native date pickers on mobile devices
  4. 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