Agents (llms.txt)
Octocat

Calendar

A component for selecting a date or date range with month and year navigation controls

S
M
T
W
T
F
S
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

// biome-ignore-all lint/a11y/useSemanticElements: WAI-ARIA grid pattern via role attributes; the layout uses CSS grid which doesn't compose well with <table>.
// biome-ignore-all lint/a11y/useFocusableInteractive: rows in a WAI-ARIA grid are structural (cells are the focusable elements); adding tabIndex to rows would be incorrect.
import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react/dist/ssr';
import { add, isSameDay, isSameMonth } from 'date-fns';
import { useMemo, useState } from 'react';
import { IconButton } 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 }))}
          previousLabel="Previous 12 years"
          nextLabel="Next 12 years"
        >
          <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 }))}
          previousLabel="Previous year"
          nextLabel="Next year"
        >
          <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 }))}
          previousLabel="Previous month"
          nextLabel="Next month"
        >
          <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
                role="grid"
                aria-label={`Years ${viewDate.getFullYear() - 5} to ${viewDate.getFullYear() + 6}`}
                className="grid size-full grid-cols-3 grid-rows-4 p-0.5"
              >
                {Array.from({ length: 4 }, (_, rowIdx) => (
                  <div role="row" className="contents" key={rowIdx}>
                    {Array.from({ length: 3 }, (_, colIdx) => {
                      const i = rowIdx * 3 + colIdx;
                      const year = viewDate.getFullYear() - 5 + i;
                      const date = new Date(year, viewDate.getMonth(), 1);
                      return (
                        <YearMonthButton
                          key={i}
                          role="gridcell"
                          className="h-full"
                          onClick={() => {
                            setViewDate(date);
                            setView('months');
                          }}
                        >
                          {year}
                        </YearMonthButton>
                      );
                    })}
                  </div>
                ))}
              </div>
            )}
            {view === 'months' && (
              <div
                role="grid"
                aria-label={`Months in ${viewDate.getFullYear()}`}
                className="grid size-full grid-cols-3 grid-rows-4 p-0.5"
              >
                {Array.from({ length: 4 }, (_, rowIdx) => (
                  <div role="row" className="contents" key={rowIdx}>
                    {Array.from({ length: 3 }, (_, colIdx) => {
                      const i = rowIdx * 3 + colIdx;
                      const date = new Date(viewDate.getFullYear(), i, 1);
                      return (
                        <YearMonthButton
                          key={i}
                          role="gridcell"
                          className="h-full"
                          onClick={() => {
                            setViewDate(date);
                            setView('days');
                          }}
                        >
                          {date.toLocaleDateString(locale, { month: 'short' })}
                        </YearMonthButton>
                      );
                    })}
                  </div>
                ))}
              </div>
            )}
          </div>
        )}

        <div
          role="grid"
          aria-label={viewDate.toLocaleDateString(locale, {
            month: 'long',
            year: 'numeric',
          })}
          className="pb-0.5"
        >
          <div role="row" className="grid grid-cols-7">
            {Array.from({ length: 7 }, (_, i) => {
              const weekday = new Date(2024, 0, (i + startWeekOn) % 7);
              return (
                <div
                  key={i}
                  role="columnheader"
                  aria-label={weekday.toLocaleDateString(locale, {
                    weekday: 'long',
                  })}
                  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 role="row" 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"
                    role="gridcell"
                    key={ii}
                    aria-label={day.toLocaleDateString(locale, {
                      dateStyle: 'full',
                    })}
                    aria-selected={isSelected || undefined}
                    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-(length:--ring-width)'
                      )}
                    >
                      {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>
    </div>
  );
};

interface CalendarHeaderProps {
  onPrevious: () => void;
  onNext: () => void;
  previousLabel?: string;
  nextLabel?: string;
  children: React.ReactNode;
}

const CalendarHeader = ({
  onPrevious,
  onNext,
  previousLabel = 'Previous',
  nextLabel = 'Next',
  children,
}: CalendarHeaderProps) => {
  return (
    <div className="flex items-center justify-between p-1.5 font-medium text-sm">
      <IconButton
        variant="outline"
        onClick={onPrevious}
        aria-label={previousLabel}
      >
        <CaretLeftIcon />
      </IconButton>
      {children}
      <IconButton variant="outline" onClick={onNext} aria-label={nextLabel}>
        <CaretRightIcon />
      </IconButton>
    </div>
  );
};

const HeaderTextButton = ({
  children,
  className,
  ...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
  return (
    <button
      type="button"
      className={cn(
        'focus-visible:ring-(length:--ring-width) cursor-pointer rounded-sm font-medium text-foreground/80 outline-none ring-ring transition hover:text-foreground focus-visible:text-foreground',
        className
      )}
      {...props}
    >
      {children}
    </button>
  );
};

const YearMonthButton = ({
  children,
  className,
  ...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
  return (
    <button
      type="button"
      className={cn(
        'focus-visible:ring-(length:--ring-width) 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',
        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
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
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
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
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
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