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
| L | M | X | J | V | S | D |
|---|---|---|---|---|---|---|
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).
| L | M | X | J | V | S | D |
|---|---|---|---|---|---|---|
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
| Prop | Tipo | Default |
|---|---|---|
| value | Date | — |
| defaultValue | Date | — |
| onValueChange | (date: Date) => void | — |
| minDate | Date | — |
| maxDate | Date | — |
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.