Selector de fecha claymorfismo
Calendario con navegación por mes y teclado completo (flechas, Inicio, Fin, RePág, AvPág), fechas con formato local.
Octubre de 2026
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
/** Calendario para elegir una fecha, con teclado completo (flechas, Inicio, Fin, RePág, AvPág) y estilo claymorfismo. React + TypeScript, sin dependencias. */
export type SelectorFechaProps = {
value?: Date | null;
defaultValue?: Date | null;
min?: Date;
max?: Date;
/** Inicio de semana: 1 = lunes, 0 = domingo. */
weekStart?: 0 | 1;
locale?: string;
onChange?: (date: Date) => void;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcsfe-root { box-sizing: border-box; display: block; width: 100%; max-width: 340px; padding: 20px; border-radius: 32px; background: #fff3e6; color: #4a2f1d; font: 700 15px/1.2 ui-rounded, "SF Pro Rounded", "Nunito", ui-sans-serif, system-ui, sans-serif; box-shadow: inset -8px -10px 18px rgba(214, 150, 90, .25), inset 8px 10px 18px rgba(255, 255, 255, .9), 14px 18px 30px -10px rgba(180, 110, 50, .35); }
.gcsfe-root *, .gcsfe-root *::before, .gcsfe-root *::after { box-sizing: border-box; }
.gcsfe-root button { font: inherit; color: inherit; -webkit-tap-highlight-color: transparent; }
@media (prefers-reduced-motion: reduce) { .gcsfe-root *, .gcsfe-root *::before, .gcsfe-root *::after { animation: none !important; transition-duration: .01ms !important; } }
.gcsfe-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.gcsfe-title { font-size: 18px; }
.gcsfe-nav { display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 50%; cursor: pointer; background: #ffd9b3; box-shadow: inset -3px -4px 6px rgba(200, 120, 50, .35), inset 3px 4px 6px rgba(255, 255, 255, .8), 3px 5px 8px -2px rgba(180, 110, 50, .4); transition: transform .2s cubic-bezier(.34, 1.56, .64, 1); }
.gcsfe-nav:hover { transform: scale(1.08); }
.gcsfe-nav:active { transform: scale(.92); }
.gcsfe-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.gcsfe-dow { display: grid; place-items: center; height: 28px; font-size: 12px; font-weight: 800; color: #b07a4f; text-transform: uppercase; }
.gcsfe-day { position: relative; display: grid; place-items: center; aspect-ratio: 1; border: 0; border-radius: 50%; background: transparent; cursor: pointer; font-size: 14px; font-variant-numeric: tabular-nums; transition: background .2s, transform .25s cubic-bezier(.34, 1.56, .64, 1); }
.gcsfe-day:hover:not(:disabled) { background: #ffe4c7; transform: scale(1.08); }
.gcsfe-day[data-out="true"] { color: #c9a98c; }
.gcsfe-day[data-today="true"]::after { content: ""; position: absolute; bottom: 5px; width: 4px; height: 4px; border-radius: 50%; background: #e0642d; }
.gcsfe-day[aria-selected="true"] { background: #ff9a5c; color: #fff; box-shadow: inset -3px -4px 6px rgba(160, 60, 10, .4), inset 3px 4px 6px rgba(255, 255, 255, .5), 3px 6px 10px -2px rgba(224, 100, 45, .6); }
.gcsfe-day[aria-selected="true"]::after { background: #fff; }
.gcsfe-day:disabled { opacity: .3; cursor: not-allowed; }
.gcsfe-day:focus-visible, .gcsfe-nav:focus-visible { outline: 3px solid #4a2f1d; outline-offset: 2px; }`;
const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
const addDays = (date: Date, days: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
export function SelectorFecha({ value, defaultValue = null, min, max, weekStart = 1, locale = "es-MX", onChange, className = "", style }: SelectorFechaProps) {
const [inner, setInner] = useState<Date | null>(defaultValue);
const selected = value === undefined ? inner : value;
const [focused, setFocused] = useState<Date>(() => startOfDay(selected ?? new Date()));
const grid = useRef<HTMLDivElement>(null);
const today = new Date();
const first = new Date(focused.getFullYear(), focused.getMonth(), 1);
const offset = (first.getDay() - weekStart + 7) % 7;
const days = Array.from({ length: 42 }, (_, index) => addDays(first, index - offset));
const dow = Array.from({ length: 7 }, (_, index) => {
const date = addDays(new Date(2024, 0, 7), index + weekStart); // 7 ene 2024 fue domingo
return new Intl.DateTimeFormat(locale, { weekday: "short" }).format(date).slice(0, 2);
});
const blocked = (date: Date) => (min && date < startOfDay(min)) || (max && date > startOfDay(max));
function pick(date: Date) {
if (blocked(date)) return;
setInner(date);
setFocused(date);
onChange?.(date);
}
function focusDay(date: Date) {
setFocused(date);
requestAnimationFrame(() => grid.current?.querySelector<HTMLButtonElement>('[tabindex="0"]')?.focus());
}
function key(event: KeyboardEvent<HTMLDivElement>) {
const map: Record<string, Date> = {
ArrowLeft: addDays(focused, -1),
ArrowRight: addDays(focused, 1),
ArrowUp: addDays(focused, -7),
ArrowDown: addDays(focused, 7),
Home: addDays(focused, -((focused.getDay() - weekStart + 7) % 7)),
End: addDays(focused, 6 - ((focused.getDay() - weekStart + 7) % 7)),
PageUp: new Date(focused.getFullYear(), focused.getMonth() - 1, focused.getDate()),
PageDown: new Date(focused.getFullYear(), focused.getMonth() + 1, focused.getDate()),
};
if (map[event.key]) {
event.preventDefault();
focusDay(map[event.key]);
}
}
const rawMonth = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(focused);
const monthLabel = rawMonth.charAt(0).toUpperCase() + rawMonth.slice(1);
return (
<div className={`gcsfe-root ${className}`} style={style}>
<style>{CSS}</style>
<div className="gcsfe-head">
<button className="gcsfe-nav" type="button" aria-label="Mes anterior" onClick={() => setFocused(new Date(focused.getFullYear(), focused.getMonth() - 1, 1))}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m15 6-6 6 6 6" />
</svg>
</button>
<div className="gcsfe-title" aria-live="polite">
{monthLabel}
</div>
<button className="gcsfe-nav" type="button" aria-label="Mes siguiente" onClick={() => setFocused(new Date(focused.getFullYear(), focused.getMonth() + 1, 1))}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m9 6 6 6-6 6" />
</svg>
</button>
</div>
<div className="gcsfe-grid" role="grid" aria-label={monthLabel} ref={grid} onKeyDown={key}>
{dow.map((name, index) => (
<div key={index} className="gcsfe-dow" role="columnheader" aria-hidden="true">
{name}
</div>
))}
{days.map((date) => {
const out = date.getMonth() !== focused.getMonth();
const isFocus = sameDay(date, focused);
return (
<button
key={date.toISOString()}
className="gcsfe-day"
type="button"
role="gridcell"
tabIndex={isFocus ? 0 : -1}
aria-selected={selected ? sameDay(date, selected) : false}
aria-label={new Intl.DateTimeFormat(locale, { dateStyle: "full" }).format(date)}
data-out={out ? "true" : undefined}
data-today={sameDay(date, today) ? "true" : undefined}
disabled={!!blocked(date)}
onClick={() => pick(date)}
>
{date.getDate()}
</button>
);
})}
</div>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/selector-fechaEjemplo de uso de Selector de fecha claymorfismo
Después de copiar selector-fecha.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { SelectorFecha } from "./selector-fecha";
export default function Ejemplo() {
return <SelectorFecha locale="Ejemplo" />;
}Propiedades de SelectorFecha
SelectorFecha acepta 9 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
value? | Date | null | Opcional. |
defaultValue? | Date | null | Opcional. |
min? | Date | Opcional. |
max? | Date | Opcional. |
weekStart? | 0 | 1 | Inicio de semana: 1 = lunes, 0 = domingo. |
locale? | string | Opcional. |
onChange? | (date: Date) => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 139 líneas (8 KB) y no necesita @keyframes. Esto es lo que hace Selector de fecha claymorfismo por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Declara roles ARIA para lectores de pantalla: grid, columnheader, gridcell.
- Responde al teclado, no solo al ratón.
- Anima con requestAnimationFrame, sincronizado con el refresco de pantalla.
- Es un componente de cliente de Next.js: incluye la directiva "use client".