Ir al contenido
Evaluar mi proyecto
Componentes · FormulariosDisponible

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.

selector-fecha.tsx139 líneas
Descargar
"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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/selector-fecha

Ejemplo 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.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
value?Date | nullOpcional.
defaultValue?Date | nullOpcional.
min?DateOpcional.
max?DateOpcional.
weekStart?0 | 1Inicio de semana: 1 = lunes, 0 = domingo.
locale?stringOpcional.
onChange?(date: Date) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)