ATOM
Components (registry)Data display

Calendar

A month grid to pick a date (or range) with keyboard support. Scheduling and date filters.

Preview

Editorial

<!-- F12c editorial — non-derivable only. Review: Karen. -->

## Ejemplos

Single date pick:

```tsx import { Calendar } from '@/components/atoms/Calendar';

<Calendar mode="single" selected={day} onSelect={setDay} /> ```

Range for filters:

```tsx <Calendar mode="range" rangeFrom={from} rangeTo={to} onRangeSelect={(a, b) => { setFrom(a); setTo(b); }} /> ```

## Accesibilidad

- Wire `onSelect` vs `onRangeSelect` to match `mode`. `disabledDates` is a predicate, not a list. - Pair with a labeled field when the calendar is the control for a form value.

## Cuándo no usar

- Typing a known ISO date quickly → `Input` with validation. - Time-of-day without a date → a time control, not the month grid alone.

## Criterio de uso

- Usa modo `single` para una fecha y `range` para periodos; el label del campo debe explicar qué representa la selección. - Deshabilita fechas inválidas con una regla de negocio estable y comunica restricciones antes de que el usuario explore todo el mes. - Conserva el contexto de mes y año mientras se navega; la selección no debe depender sólo del color del día.

## Gotchas

- `disabledDates` es un predicate; no pases una lista esperando que el componente la interprete automáticamente. - Los controles de navegación del mes necesitan nombres accesibles y el calendario debe seguir siendo usable con teclado.

Uso

import { Calendar } from '@/components/atoms/Calendar';

<Calendar mode="single" selected={day} onSelect={setDay} />

Props

PropTipoDefaultRango / opcionesWhatHow
modeselectsingle`single`, `range`Selection model: one day or a from–to range.single for pickers; range for booking/filters. Wire onSelect vs onRangeSelect accordingly. Default: single.

Gotchas

  • react

    Controlled via selected / rangeFrom+rangeTo. disabledDates is a predicate (date)=>boolean — not a list prop.

  • a11y

    Month nav uses IconButtons; keep adjacent text labels for the field that owns the calendar.

Anatomía CSS

<div class="calendar">
  <span class="calendar__day"></span>
  <span class="calendar__day--disabled"></span>
  <span class="calendar__day--in-range"></span>
  <span class="calendar__day--outside"></span>
  <span class="calendar__day--range-end"></span>
  <span class="calendar__day--range-start"></span>
  <span class="calendar__day--selected"></span>
  <span class="calendar__day--today"></span>
  <span class="calendar__grid"></span>
  <span class="calendar__head"></span>
  <span class="calendar__nav"></span>
  <span class="calendar__nav-btn"></span>
  <span class="calendar__row"></span>
  <span class="calendar__title"></span>
</div>
ClasePropósito
calendarroot
calendar__dayelement
calendar__day--disabledelement
calendar__day--in-rangeelement
calendar__day--outsideelement
calendar__day--range-endelement
calendar__day--range-startelement
calendar__day--selectedelement
calendar__day--todayelement
calendar__gridelement
calendar__headelement
calendar__navelement
calendar__nav-btnelement
calendar__rowelement
calendar__titleelement

Tokens resueltos

Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.

VariantePropValorToken
allfg#525252muted.foreground
allbg#f5f5f5accent
allbordernone(unparsed)
allhover-bg#262626primary.hover
allhover-fg#171717accent.foreground

Animaciones

PropiedadDuraciónEasing
background-colorvar(--duration-100)var(--easing-in-out)

CSS mínimo funcional

Autocontenido: sin imports ni tokens. Para previews y prototipos — en producción se consume el CSS del DS (@atom-uikit/css/components.css</code>, <code>@atom-uikit/tokens/tokens.css).

/* -------------------------------------------------------------------------
   Calendar

   Date picker grid. Month navigation, day selection.
   Modes: single, range.

   Parts: .calendar, .calendar__nav, .calendar__nav-btn,
          .calendar__title, .calendar__grid, .calendar__head,
          .calendar__row, .calendar__day
   ------------------------------------------------------------------------- */

/* ---- Container ---- */

.calendar {
  display: inline-flex;
  flex-direction: column;
  gap: 8px;
  padding: 12px;
  font-family: 'inter tight', -apple-system, blinkmacsystemfont, 'segoe ui', roboto, helvetica, arial, sans-serif, ui-sans-serif, system-ui, sans-serif;
  font-size: 12.8px;
}

/* ---- Navigation ---- */

.calendar__nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.calendar__title {
  font-size: 12.8px;
  font-weight: 600;
  color: #0a0a0a;
}

/* ---- Grid ---- */

.calendar__grid {
  border-collapse: collapse;
}

.calendar__head {
  font-size: 10.24px;
  font-weight: 500;
  color: #525252;
  text-align: center;
  padding-bottom: 8px;
  width: 36px;
}

/* ---- Day cell ---- */

.calendar__day {
  width: 36px;
  height: 36px;
  padding: 0;
  border: none;
  border-radius: 8px;
  background: none;
  color: #0a0a0a;
  font-family: inherit;
  font-size: 12.8px;
  font-weight: 400;
  line-height: 1;
  text-align: center;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: background-color 100ms cubic-bezier(0.4, 0, 0.2, 1);
}

.calendar__day:hover:not(.calendar__day--disabled):not(.calendar__day--selected) {
  background-color: #f5f5f5;
  color: #171717;
}

.calendar__day:focus-visible {
  outline: 2px solid var(--focus-ring-color);
  outline-offset: -2px;
}

/* Outside month */
.calendar__day--outside {
  color: #525252;
  opacity: 0.5;
}

/* Today */
.calendar__day--today {
  font-weight: 600;
  color: #0a0a0a;
  box-shadow: inset 0 -2px 0 #0a0a0a;
}

/* Selected */
.calendar__day--selected {
  background-color: #0a0a0a;
  color: #fafafa;
  font-weight: 500;
}

.calendar__day--selected:hover {
  background-color: #262626;
}

/* Range: start + end */
.calendar__day--range-start {
  border-radius: 8px 0 0 8px;
}

.calendar__day--range-end {
  border-radius: 0 8px 8px 0;
}

.calendar__day--range-start.calendar__day--range-end {
  border-radius: 8px;
}

/* Range: middle */
.calendar__day--in-range {
  background-color: #f5f5f5;
  color: #171717;
  border-radius: 0;
}

/* Disabled */
.calendar__day--disabled {
  color: #525252;
  opacity: 0.3;
  cursor: not-allowed;
  pointer-events: none;
}

/* ---- Reduced motion ---- */

@media (prefers-reduced-motion: reduce) {
  .calendar__day {
    transition-duration: 0ms;
  }
}

Codigo fuente

components-react
components/atoms/Calendar.tsx
import { useState, useMemo, useCallback } from 'react';
import { IconButton } from './IconButton';

function cn(...classes: (string | false | undefined | null)[]) {
  return classes.filter(Boolean).join(' ');
}

const ChevronLeft = () => (
  <svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <path d="M15 18l-6-6 6-6" />
  </svg>
);

const ChevronRight = () => (
  <svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <path d="M9 18l6-6-6-6" />
  </svg>
);

/* ---- Helpers ---- */

const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
const MONTHS = [
  'January', 'February', 'March', 'April', 'May', 'June',
  'July', 'August', 'September', 'October', 'November', 'December',
];

function isSameDay(a: Date, b: Date) {
  return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}

function isInRange(day: Date, from: Date | null, to: Date | null) {
  if (!from || !to) return false;
  const t = day.getTime();
  return t > from.getTime() && t < to.getTime();
}

function getDaysGrid(year: number, month: number): Date[] {
  const first = new Date(year, month, 1);
  const startDay = first.getDay();
  const days: Date[] = [];
  for (let i = -startDay; i < 42 - startDay; i++) {
    days.push(new Date(year, month, i + 1));
  }
  return days;
}

/* ---- Component ---- */

export type CalendarProps = {
  mode?: 'single' | 'range';
  selected?: Date | null;
  rangeFrom?: Date | null;
  rangeTo?: Date | null;
  onSelect?: (date: Date) => void;
  onRangeSelect?: (from: Date | null, to: Date | null) => void;
  disabledDates?: (date: Date) => boolean;
  className?: string;
};

export function Calendar({
  mode = 'single',
  selected = null,
  rangeFrom = null,
  rangeTo = null,
  onSelect,
  onRangeSelect,
  disabledDates,
  className,
}: CalendarProps) {
  const today = useMemo(() => new Date(), []);
  const initial = selected || rangeFrom || today;
  const [viewYear, setViewYear] = useState(initial.getFullYear());
  const [viewMonth, setViewMonth] = useState(initial.getMonth());

  const days = useMemo(() => getDaysGrid(viewYear, viewMonth), [viewYear, viewMonth]);

  const prevMonth = useCallback(() => {
    if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1); }
    else setViewMonth((m) => m - 1);
  }, [viewMonth]);

  const nextMonth = useCallback(() => {
    if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1); }
    else setViewMonth((m) => m + 1);
  }, [viewMonth]);

  // Range selection state
  const [rangeStep, setRangeStep] = useState<'from' | 'to'>(rangeFrom && !rangeTo ? 'to' : 'from');

  const handleDayClick = (day: Date) => {
    if (mode === 'single') {
      onSelect?.(day);
    } else {
      if (rangeStep === 'from') {
        onRangeSelect?.(day, null);
        setRangeStep('to');
      } else {
        if (rangeFrom && day.getTime() < rangeFrom.getTime()) {
          onRangeSelect?.(day, null);
          setRangeStep('to');
        } else {
          onRangeSelect?.(rangeFrom, day);
          setRangeStep('from');
        }
      }
    }
  };

  const weeks: Date[][] = [];
  for (let i = 0; i < days.length; i += 7) {
    weeks.push(days.slice(i, i + 7));
  }

  return (
    <div className={cn('calendar', className)}>
      <div className="calendar__nav">
        <IconButton
          variant="tertiary"
          size="xs"
          icon={<ChevronLeft />}
          aria-label="Previous month"
          onClick={prevMonth}
        />
        <span className="calendar__title">
          {MONTHS[viewMonth]} {viewYear}
        </span>
        <IconButton
          variant="tertiary"
          size="xs"
          icon={<ChevronRight />}
          aria-label="Next month"
          onClick={nextMonth}
        />
      </div>
      <table className="calendar__grid" role="grid">
        <thead>
          <tr>
            {DAYS.map((d) => (
              <th key={d} className="calendar__head">{d}</th>
            ))}
          </tr>
        </thead>
        <tbody>
          {weeks.map((week, wi) => (
            <tr key={wi}>
              {week.map((day, di) => {
                const isOutside = day.getMonth() !== viewMonth;
                const isToday = isSameDay(day, today);
                const isDisabled = disabledDates?.(day) ?? false;

                let isSelected = false;
                let isRangeStart = false;
                let isRangeEnd = false;
                let isInRangeDay = false;

                if (mode === 'single' && selected) {
                  isSelected = isSameDay(day, selected);
                } else if (mode === 'range') {
                  if (rangeFrom) isRangeStart = isSameDay(day, rangeFrom);
                  if (rangeTo) isRangeEnd = isSameDay(day, rangeTo);
                  isSelected = isRangeStart || isRangeEnd;
                  isInRangeDay = isInRange(day, rangeFrom, rangeTo);
                }

                return (
                  <td key={di} style={{ padding: 0 }}>
                    <button
                      type="button"
                      className={cn(
                        'calendar__day',
                        isOutside && 'calendar__day--outside',
                        isToday && 'calendar__day--today',
                        isSelected && 'calendar__day--selected',
                        isRangeStart && 'calendar__day--range-start',
                        isRangeEnd && 'calendar__day--range-end',
                        isInRangeDay && 'calendar__day--in-range',
                        isDisabled && 'calendar__day--disabled',
                      )}
                      disabled={isDisabled}
                      onClick={() => handleDayClick(day)}
                      tabIndex={isOutside ? -1 : 0}
                      aria-selected={isSelected || undefined}
                    >
                      {day.getDate()}
                    </button>
                  </td>
                );
              })}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

On this page

Detalles

Publicado14 de mayo de 2026
Categoriadata-display
Lectura...
Visitas...
Ayuda?Slack

Componente

Calendar

Source

components-react / cssDisponible via MCP: atom_uikit_source("calendar")
Abrir en Storybook