← Componentes

Calendar

Celdas cuadradas de 6px, no círculos — misma decisión que Badge y Avatar. El mes y el año del encabezado son clickeables — abren un selector en vez de obligarte a apretar "siguiente" treinta veces para llegar a 1994.

Playground

LMXJVSD

17 de agosto de 2026

<Calendar value={date} onValueChange={setDate} />

Mes y año seleccionables

Clickear el nombre del mes abre una grilla de 12; clickear el año abre una lista larga (rango por defecto: cien años atrás, diez adelante — o el que marquen minDate/maxDate) que se autoscrollea al año visible al abrir. Mismo mecanismo de clic-afuera / Esc que ButtonGroupDropdown y el de Breadcrumb.

El gesto al cambiar de mes

La grilla siempre ocupa seis semanas, aunque el mes tenga cinco — así navegar no hace saltar el alto. Al cambiar de mes, los días entran con un fade corto (200ms), sin desplazamiento direccional: un calendario es para escanear fechas rápido, no para protagonizar una transición. El día de hoy queda marcado con un hairline dorado; el seleccionado, con relleno sólido — la misma distinción entre "esto es un dato" y "esto es tu elección" que ya separa a Avatar's status de su fallback.

Rango de fechas

minDate / maxDate deshabilitan los días fuera de rango — siguen ahí, apagados, en vez de desaparecer (rompería la forma de la grilla mes a mes).

LMXJVSD
const hoy = new Date();
const enDosSemanas = new Date();
enDosSemanas.setDate(hoy.getDate() + 14);

<Calendar minDate={hoy} maxDate={enDosSemanas} />
{/* fuera de rango: deshabilitado, no oculto — la fecha sigue ahí, solo no es una opción hoy */}

Código

"use client";

import * as React from "react";
import { cn } from "./lib/cn";
import { EASE } from "./lib/motion";
import { useDismiss } from "./lib/use-dismiss";

// nombres de mes/día vía Intl en vez de arrays fijos — así el calendario
// habla el idioma que le pasen (locale prop) en lugar de estar
// hardcodeado en español para cualquier consumidor multilenguaje
function getMonthNames(locale: string) {
  const fmt = new Intl.DateTimeFormat(locale, { month: "long" });
  return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2000, i, 1)));
}

// 2024-01-01 es lunes — punto de partida fijo para generar la semana en
// el mismo orden (lunes primero) que usa getMonthGrid
function getWeekdayLabels(locale: string) {
  const narrow = new Intl.DateTimeFormat(locale, { weekday: "narrow" });
  const long = new Intl.DateTimeFormat(locale, { weekday: "long" });
  return Array.from({ length: 7 }, (_, i) => {
    const d = new Date(2024, 0, 1 + i);
    return { short: narrow.format(d), name: long.format(d) };
  });
}

function stripTime(d: Date) {
  return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
}

function isSameDay(a?: Date, b?: Date) {
  return Boolean(a && b && stripTime(a) === stripTime(b));
}

function isOutOfRange(date: Date, minDate?: Date, maxDate?: Date) {
  const t = stripTime(date);
  if (minDate && t < stripTime(minDate)) return true;
  if (maxDate && t > stripTime(maxDate)) return true;
  return false;
}

// grilla fija de 6 semanas — el mes más corto y el más largo ocupan el
// mismo alto, así navegar no hace saltar el layout
function getMonthGrid(year: number, month: number) {
  const firstWeekday = (new Date(year, month, 1).getDay() + 6) % 7; // lunes=0 … domingo=6
  const daysInMonth = new Date(year, month + 1, 0).getDate();
  const daysInPrevMonth = new Date(year, month, 0).getDate();

  const cells: { date: Date; outside: boolean }[] = [];
  for (let i = firstWeekday - 1; i >= 0; i--) {
    cells.push({ date: new Date(year, month - 1, daysInPrevMonth - i), outside: true });
  }
  for (let d = 1; d <= daysInMonth; d++) {
    cells.push({ date: new Date(year, month, d), outside: false });
  }
  let next = 1;
  while (cells.length < 42) {
    cells.push({ date: new Date(year, month + 1, next), outside: true });
    next++;
  }
  return cells;
}

function ChevronLeftIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M15 6l-6 6 6 6" />
    </svg>
  );
}

function ChevronRightIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M9 6l6 6-6 6" />
    </svg>
  );
}

function ChevronDownIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M6 9l6 6 6-6" />
    </svg>
  );
}

const popoverPanelClass = cn(
  "absolute left-0 top-full z-20 mt-2 origin-top-left rounded-md border border-border bg-background p-1 transition-all duration-150",
  EASE,
);

const headerTriggerClass = cn(
  "flex items-center gap-1 rounded-sm px-1.5 py-1 text-sm font-medium text-foreground outline-none transition-colors duration-150",
  "hover:text-primary focus-visible:ring-2 focus-visible:ring-primary",
);

// scrollbar propia, delgada y en los mismos tonos de la paleta — la
// del navegador por defecto (gris, gruesa, sin radio) desentona
const thinScrollbarClass = cn(
  "[scrollbar-width:thin] [scrollbar-color:hsl(var(--kenza-color-border))_transparent]",
  "[&::-webkit-scrollbar]:w-1.5",
  "[&::-webkit-scrollbar-track]:bg-transparent",
  "[&::-webkit-scrollbar-thumb]:rounded-full",
  "[&::-webkit-scrollbar-thumb]:bg-border",
  "[&::-webkit-scrollbar-thumb:hover]:bg-muted-foreground",
);

export interface CalendarProps {
  value?: Date;
  defaultValue?: Date;
  onValueChange?: (date: Date) => void;
  minDate?: Date;
  maxDate?: Date;
  className?: string;
  /** idioma para nombres de mes/día y aria-labels — default "es" para no
   * romper consumidores existentes que no lo pasan */
  locale?: string;
  previousMonthLabel?: string;
  nextMonthLabel?: string;
}

export function Calendar({
  value,
  defaultValue,
  onValueChange,
  minDate,
  maxDate,
  className,
  locale = "es",
  previousMonthLabel = "Mes anterior",
  nextMonthLabel = "Mes siguiente",
}: CalendarProps) {
  const today = React.useMemo(() => new Date(), []);
  const monthNames = React.useMemo(() => getMonthNames(locale), [locale]);
  const weekdayLabels = React.useMemo(() => getWeekdayLabels(locale), [locale]);
  const [uncontrolledValue, setUncontrolledValue] = React.useState<Date | undefined>(defaultValue);
  const isControlled = value !== undefined;
  const selected = isControlled ? value : uncontrolledValue;

  const [visibleYear, setVisibleYear] = React.useState(() => (selected ?? today).getFullYear());
  const [visibleMonth, setVisibleMonth] = React.useState(() => (selected ?? today).getMonth());

  const selectDate = (date: Date) => {
    if (!isControlled) setUncontrolledValue(date);
    onValueChange?.(date);
  };

  const goToMonth = (year: number, month: number) => {
    // month puede salirse de 0–11 a propósito (mes - 1 / mes + 1) — Date
    // normaliza el año solo, no hace falta calcularlo a mano aquí
    const normalized = new Date(year, month, 1);
    setVisibleYear(normalized.getFullYear());
    setVisibleMonth(normalized.getMonth());
  };

  // el grid entra con un fade sutil al cambiar de mes — no un slide
  // direccional: un calendario se usa para escanear fechas rápido, no
  // para protagonizar una transición
  const monthKey = `${visibleYear}-${visibleMonth}`;
  const [gridVisible, setGridVisible] = React.useState(true);
  const prevMonthKeyRef = React.useRef(monthKey);
  React.useLayoutEffect(() => {
    if (prevMonthKeyRef.current === monthKey) return;
    prevMonthKeyRef.current = monthKey;
    setGridVisible(false);
    const raf = requestAnimationFrame(() => setGridVisible(true));
    return () => cancelAnimationFrame(raf);
  }, [monthKey]);

  const [monthPickerOpen, setMonthPickerOpen] = React.useState(false);
  const [yearPickerOpen, setYearPickerOpen] = React.useState(false);
  const monthPickerRef = React.useRef<HTMLDivElement>(null);
  const yearPickerRef = React.useRef<HTMLDivElement>(null);
  const yearListRef = React.useRef<HTMLUListElement>(null);
  useDismiss(monthPickerOpen, React.useCallback(() => setMonthPickerOpen(false), []), monthPickerRef);
  useDismiss(yearPickerOpen, React.useCallback(() => setYearPickerOpen(false), []), yearPickerRef);

  React.useEffect(() => {
    if (!yearPickerOpen) return;
    yearListRef.current?.querySelector('[data-current="true"]')?.scrollIntoView({ block: "center" });
  }, [yearPickerOpen]);

  const yearRangeStart = minDate ? minDate.getFullYear() : today.getFullYear() - 100;
  const yearRangeEnd = maxDate ? maxDate.getFullYear() : today.getFullYear() + 10;
  const years = React.useMemo(
    () => Array.from({ length: yearRangeEnd - yearRangeStart + 1 }, (_, i) => yearRangeStart + i),
    [yearRangeStart, yearRangeEnd],
  );

  const cells = React.useMemo(() => getMonthGrid(visibleYear, visibleMonth), [visibleYear, visibleMonth]);

  return (
    <div className={cn("w-fit rounded-md border border-border bg-background p-4", className)}>
      <div className="flex items-center justify-between gap-2">
        <div className="flex items-center gap-1">
          <div ref={monthPickerRef} className="relative">
            <button
              type="button"
              onClick={() => setMonthPickerOpen((o) => !o)}
              aria-haspopup="menu"
              aria-expanded={monthPickerOpen}
              className={headerTriggerClass}
            >
              {monthNames[visibleMonth]}
              <ChevronDownIcon className="h-3.5 w-3.5 text-muted-foreground" />
            </button>
            <div
              role="menu"
              className={cn(
                popoverPanelClass,
                "grid w-56 grid-cols-3 gap-1",
                monthPickerOpen ? "pointer-events-auto scale-100 opacity-100" : "pointer-events-none scale-95 opacity-0",
              )}
            >
              {monthNames.map((name, i) => (
                <button
                  key={name}
                  type="button"
                  role="menuitem"
                  onClick={() => {
                    goToMonth(visibleYear, i);
                    setMonthPickerOpen(false);
                  }}
                  className={cn(
                    "rounded-sm px-2 py-1.5 text-xs transition-colors duration-150 hover:bg-muted",
                    i === visibleMonth ? "bg-primary/15 text-primary" : "text-foreground",
                  )}
                >
                  {name.slice(0, 3)}
                </button>
              ))}
            </div>
          </div>

          <div ref={yearPickerRef} className="relative">
            <button
              type="button"
              onClick={() => setYearPickerOpen((o) => !o)}
              aria-haspopup="menu"
              aria-expanded={yearPickerOpen}
              className={headerTriggerClass}
            >
              {visibleYear}
              <ChevronDownIcon className="h-3.5 w-3.5 text-muted-foreground" />
            </button>
            <div
              role="menu"
              className={cn(
                popoverPanelClass,
                thinScrollbarClass,
                "max-h-48 w-24 overflow-y-auto",
                yearPickerOpen ? "pointer-events-auto scale-100 opacity-100" : "pointer-events-none scale-95 opacity-0",
              )}
            >
              <ul ref={yearListRef} className="flex flex-col">
                {years.map((y) => (
                  <li key={y}>
                    <button
                      type="button"
                      role="menuitem"
                      data-current={y === visibleYear || undefined}
                      onClick={() => {
                        goToMonth(y, visibleMonth);
                        setYearPickerOpen(false);
                      }}
                      className={cn(
                        "w-full rounded-sm px-2 py-1.5 text-left text-xs transition-colors duration-150 hover:bg-muted",
                        y === visibleYear ? "bg-primary/15 text-primary" : "text-foreground",
                      )}
                    >
                      {y}
                    </button>
                  </li>
                ))}
              </ul>
            </div>
          </div>
        </div>

        <div className="flex items-center gap-1">
          <button
            type="button"
            aria-label={previousMonthLabel}
            onClick={() => goToMonth(visibleYear, visibleMonth - 1)}
            className="flex h-7 w-7 items-center justify-center rounded-sm text-muted-foreground outline-none transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary"
          >
            <ChevronLeftIcon className="h-4 w-4" />
          </button>
          <button
            type="button"
            aria-label={nextMonthLabel}
            onClick={() => goToMonth(visibleYear, visibleMonth + 1)}
            className="flex h-7 w-7 items-center justify-center rounded-sm text-muted-foreground outline-none transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary"
          >
            <ChevronRightIcon className="h-4 w-4" />
          </button>
        </div>
      </div>

      <table className={cn("mt-4 border-collapse transition-opacity duration-200", EASE, gridVisible ? "opacity-100" : "opacity-0")}>
        <thead>
          <tr>
            {weekdayLabels.map((weekday, i) => (
              <th key={i} scope="col" className="h-8 w-9 text-xs font-medium text-muted-foreground" title={weekday.name}>
                {weekday.short}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {Array.from({ length: 6 }, (_, row) => (
            <tr key={row}>
              {cells.slice(row * 7, row * 7 + 7).map(({ date, outside }) => {
                const isSelected = isSameDay(date, selected);
                const isToday = isSameDay(date, today);
                const disabled = isOutOfRange(date, minDate, maxDate);
                return (
                  <td key={date.toISOString()} className="p-0 text-center">
                    <button
                      type="button"
                      disabled={disabled}
                      aria-current={isToday ? "date" : undefined}
                      aria-selected={isSelected}
                      aria-label={date.toLocaleDateString(locale, { day: "numeric", month: "long", year: "numeric" })}
                      onClick={() => selectDate(date)}
                      className={cn(
                        "h-9 w-9 rounded-md text-sm outline-none transition-colors duration-150",
                        "focus-visible:ring-2 focus-visible:ring-primary",
                        "disabled:pointer-events-none disabled:opacity-30",
                        outside && "text-muted-foreground/50",
                        !outside && !isSelected && "text-foreground",
                        !isSelected && "hover:bg-muted",
                        isToday && !isSelected && "border border-primary/40",
                        isSelected && "bg-primary font-medium text-primary-foreground",
                      )}
                    >
                      {date.getDate()}
                    </button>
                  </td>
                );
              })}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Código real de packages/ui/src/calendar.tsx. Sin dependencias de fechas (nada de date-fns/dayjs) — la aritmética de meses/años ya la resuelve el Date nativo.

Uso

import { Calendar } from "./components/ui/calendar";

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

<Calendar value={date} onValueChange={setDate} />

API

PropTipoDefault
valueDate
defaultValueDate
onValueChange(date: Date) => void
minDateDate
maxDateDate

Un solo componente, no un grupo de piezas — a diferencia de Alert o Dialog, la grilla de días no tiene partes que un consumidor necesite componer distinto caso a caso.