Skip to content

Calendar

A month, inline, that commits one date or two. It is a real table carrying the APG grid pattern — one roving tab stop, arrow keys in two dimensions, a cell that announces its own long date — and every name on it comes from Intl, so the month names, the weekday initials and the first column of the week are the reader's, not a translation file's. The value is a plain ISO string with no time zone in it, and the arithmetic runs on Temporal when the engine has it.

  • Stable
  • React 19
  • APG grid
  • ISO strings
  • Zero dependencies
LMMJVSD
272829303112
3456789
10111213141516
17181920212223
24252627282930
31123456

agosto de 2026

<Calendar
locale="es-EC"
onValueChange={setDate}
labels={{
previous: 'Mes anterior',
next: 'Mes siguiente',
today: 'Hoy',
}}
/>

Calendar is the surface, not the field. It has no label, no hint slot, no name and no react-hook-form wiring — see Why there is no name. Reach for it when the month itself is the interface; reach for DatePicker when a date is one row of a form.

mode The value
"single" (the default) "2026-08-21", or null
"range" { start, end }, each half one of those strings or null

There is never a time zone in it. The string is exactly what Temporal.PlainDate#toString() produces, so Temporal.PlainDate.from(value) costs nothing, and composing an instant stays where the zone is actually known — the DatePicker page has that story in full, polyfill and daylight saving included.

The arithmetic runs on globalThis.Temporal when the engine provides it and on UTC-safe Date maths when it does not. The two agree exactly, because a plain date has no instant to disagree about, and the library ships no runtime dependency either way.

A date prop that is not YYYY-MM-DD is ignored, never thrown on. "2026-8-21", a toISOString() stamp, "21/08/2026" — in value, defaultValue, defaultMonth, min or max — reads as if the prop were absent (a controlled value as null, a range one half at a time), and one console warning names every prop it ignored.

value / defaultValue / onValueChange are the usual trio: pass value and the component is controlled, pass defaultValue and it owns its own state. onValueChange fires only on a commit — a click, Enter or Space — never from hover.

previous, next and today are required in the type, not optional strings with English defaults. It is ComboBox’s rationale: the two nav buttons are icon-only, so those strings are their whole accessible name, and optional-and-undefaulted would make the default configuration fail an audit.

Everything else the calendar says comes from Intl: the month names, the weekday initials and their abbr long forms, the long label on every cell, the title, the day numbers and the years. There is no translation file and no fourth label.

locale is a BCP 47 tag. Left out, the platform’s is resolved — pass it explicitly under SSR, or the server and the browser can render two different sets of names.

It settles the month and weekday names and their capitalisation, the shape of every cell’s long label, the digits (ar-EG numbers its days and years in Arabic-Indic, like its labels) and the first column of the week, through Intl.Locale’s week info. weekStart overrides that with 1 … 7, 1 = Monday, Temporal’s own dayOfWeek numbering. Chrome reads the week info from 99 and Safari from 17; where it is missing — Safari 16.4 at the floor — the fallback is Monday.

It never picks the calendar: the grid is Gregorian, so every name on it is Gregorian too, even for a locale whose own calendar is another (fa-IR, th-TH, ja-JP-u-ca-japanese).

Today is read in the browser. The server renders no ring and no tab stop from its own clock, and the browser’s day lands right after hydration, so the two never disagree about it. The month shown with no value is still today’s on whichever clock renders first: under SSR, pass defaultMonth for the same reason you pass locale.

LMXJVSD
272829303112
3456789
10111213141516
17181920212223
24252627282930
31123456

A single month, hand-written: today is the ring on the 12th, the selection the fill on the 21st, and the 5th is blocked.

Code
<div class="wayra-calendar" data-color="primary" data-size="md" data-months="1" data-level="day">
<div class="wayra-calendar-header">
<button class="wayra-calendar-nav" type="button" data-direction="previous" aria-label="Mes anterior"></button>
<button class="wayra-calendar-title" type="button">agosto de 2026</button>
<button class="wayra-calendar-nav" type="button" data-direction="next" aria-label="Mes siguiente"></button>
</div>
<div class="wayra-calendar-months">
<table class="wayra-calendar-month" role="grid" aria-label="agosto de 2026">
<thead>
<tr><th class="wayra-calendar-weekday" scope="col" abbr="lunes">L</th><th class="wayra-calendar-weekday" scope="col" abbr="martes">M</th><th class="wayra-calendar-weekday" scope="col" abbr="miércoles">X</th><th class="wayra-calendar-weekday" scope="col" abbr="jueves">J</th><th class="wayra-calendar-weekday" scope="col" abbr="viernes">V</th><th class="wayra-calendar-weekday" scope="col" abbr="sábado">S</th><th class="wayra-calendar-weekday" scope="col" abbr="domingo">D</th></tr>
</thead>
<tbody>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="lunes, 27 de julio de 2026" data-outside="true">27</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="martes, 28 de julio de 2026" data-outside="true">28</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="miércoles, 29 de julio de 2026" data-outside="true">29</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="jueves, 30 de julio de 2026" data-outside="true">30</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="viernes, 31 de julio de 2026" data-outside="true">31</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="sábado, 1 de agosto de 2026">1</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="domingo, 2 de agosto de 2026">2</td></tr>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="lunes, 3 de agosto de 2026">3</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="martes, 4 de agosto de 2026">4</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-disabled="true" aria-label="miércoles, 5 de agosto de 2026">5</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="jueves, 6 de agosto de 2026">6</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="viernes, 7 de agosto de 2026">7</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="sábado, 8 de agosto de 2026">8</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="domingo, 9 de agosto de 2026">9</td></tr>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="lunes, 10 de agosto de 2026">10</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="martes, 11 de agosto de 2026">11</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-current="date" aria-label="miércoles, 12 de agosto de 2026">12</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="jueves, 13 de agosto de 2026">13</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="viernes, 14 de agosto de 2026">14</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="sábado, 15 de agosto de 2026">15</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="domingo, 16 de agosto de 2026">16</td></tr>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="lunes, 17 de agosto de 2026">17</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="martes, 18 de agosto de 2026">18</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="miércoles, 19 de agosto de 2026">19</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="jueves, 20 de agosto de 2026">20</td><td class="wayra-calendar-day" role="gridcell" tabindex="0" aria-selected="true" aria-label="viernes, 21 de agosto de 2026">21</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="sábado, 22 de agosto de 2026">22</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="domingo, 23 de agosto de 2026">23</td></tr>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="lunes, 24 de agosto de 2026">24</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="martes, 25 de agosto de 2026">25</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="miércoles, 26 de agosto de 2026">26</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="jueves, 27 de agosto de 2026">27</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="viernes, 28 de agosto de 2026">28</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="sábado, 29 de agosto de 2026">29</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="domingo, 30 de agosto de 2026">30</td></tr>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="lunes, 31 de agosto de 2026">31</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="martes, 1 de septiembre de 2026" data-outside="true">1</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="miércoles, 2 de septiembre de 2026" data-outside="true">2</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="jueves, 3 de septiembre de 2026" data-outside="true">3</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="viernes, 4 de septiembre de 2026" data-outside="true">4</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="sábado, 5 de septiembre de 2026" data-outside="true">5</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="domingo, 6 de septiembre de 2026" data-outside="true">6</td></tr>
</tbody>
</table>
</div>
<div class="wayra-calendar-footer">
<button class="wayra-calendar-today" type="button">Hoy</button>
</div>
</div>

Today is a ring, the selection a fill. The ring is a border that exists in every state and is transparent until it is needed, so today never moves the grid by a pixel and forced colors always has a box to paint. The selection is the role colour with its -content on top — the pair every theme has to declare, so the contrast is guaranteed rather than measured.

A day spilling in from a neighbouring month is dimmed with the muted grey and stays clickable; what tells it apart from a blocked day is the strike-through, never the shade. A blocked day is colour plus a line through it, because a signal that is only chromatic is not a signal.

LMXJVSD
272829303112
3456789
10111213141516
17181920212223
24252627282930
31123456

mode="range": the ends keep their outer corners, the middles square off, and the whole span reads as one bar.

Code
<div class="wayra-calendar" data-color="primary" data-size="md" data-months="1" data-level="day" data-range="true">
<div class="wayra-calendar-header">
<button class="wayra-calendar-nav" type="button" data-direction="previous" aria-label="Mes anterior"></button>
<button class="wayra-calendar-title" type="button">agosto de 2026</button>
<button class="wayra-calendar-nav" type="button" data-direction="next" aria-label="Mes siguiente"></button>
</div>
<div class="wayra-calendar-months">
<table class="wayra-calendar-month" role="grid" aria-multiselectable="true" aria-label="agosto de 2026">
<thead>
<tr><th class="wayra-calendar-weekday" scope="col" abbr="lunes">L</th><th class="wayra-calendar-weekday" scope="col" abbr="martes">M</th><th class="wayra-calendar-weekday" scope="col" abbr="miércoles">X</th><th class="wayra-calendar-weekday" scope="col" abbr="jueves">J</th><th class="wayra-calendar-weekday" scope="col" abbr="viernes">V</th><th class="wayra-calendar-weekday" scope="col" abbr="sábado">S</th><th class="wayra-calendar-weekday" scope="col" abbr="domingo">D</th></tr>
</thead>
<tbody>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="lunes, 27 de julio de 2026" data-outside="true">27</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="martes, 28 de julio de 2026" data-outside="true">28</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="miércoles, 29 de julio de 2026" data-outside="true">29</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="jueves, 30 de julio de 2026" data-outside="true">30</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="viernes, 31 de julio de 2026" data-outside="true">31</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="sábado, 1 de agosto de 2026">1</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="domingo, 2 de agosto de 2026">2</td></tr>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="lunes, 3 de agosto de 2026">3</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="martes, 4 de agosto de 2026">4</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="miércoles, 5 de agosto de 2026">5</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="jueves, 6 de agosto de 2026">6</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="viernes, 7 de agosto de 2026">7</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="sábado, 8 de agosto de 2026">8</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="domingo, 9 de agosto de 2026">9</td></tr>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="lunes, 10 de agosto de 2026">10</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="martes, 11 de agosto de 2026">11</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-current="date" aria-label="miércoles, 12 de agosto de 2026">12</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="jueves, 13 de agosto de 2026">13</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="viernes, 14 de agosto de 2026">14</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="sábado, 15 de agosto de 2026">15</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="domingo, 16 de agosto de 2026">16</td></tr>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="0" aria-selected="true" aria-label="lunes, 17 de agosto de 2026" data-range-start="true" data-in-range="true">17</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="true" aria-label="martes, 18 de agosto de 2026" data-in-range="true">18</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="true" aria-label="miércoles, 19 de agosto de 2026" data-in-range="true">19</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="true" aria-label="jueves, 20 de agosto de 2026" data-in-range="true">20</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="true" aria-label="viernes, 21 de agosto de 2026" data-in-range="true">21</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="true" aria-label="sábado, 22 de agosto de 2026" data-in-range="true">22</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="true" aria-label="domingo, 23 de agosto de 2026" data-in-range="true">23</td></tr>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="true" aria-label="lunes, 24 de agosto de 2026" data-range-end="true" data-in-range="true">24</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="martes, 25 de agosto de 2026">25</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="miércoles, 26 de agosto de 2026">26</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="jueves, 27 de agosto de 2026">27</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="viernes, 28 de agosto de 2026">28</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="sábado, 29 de agosto de 2026">29</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="domingo, 30 de agosto de 2026">30</td></tr>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="lunes, 31 de agosto de 2026">31</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="martes, 1 de septiembre de 2026" data-outside="true">1</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="miércoles, 2 de septiembre de 2026" data-outside="true">2</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="jueves, 3 de septiembre de 2026" data-outside="true">3</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="viernes, 4 de septiembre de 2026" data-outside="true">4</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="sábado, 5 de septiembre de 2026" data-outside="true">5</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="domingo, 6 de septiembre de 2026" data-outside="true">6</td></tr>
</tbody>
</table>
</div>
<div class="wayra-calendar-footer">
<button class="wayra-calendar-today" type="button">Hoy</button>
</div>
</div>

mode="range" changes the value to { start, end } and puts data-range on the root. The first pick sets start and leaves end at null; the second fills end, ordered so start is never after it whichever way round you clicked; a third pick starts again.

Between the first pick and the second, the span under the pointer previews as a wash of the role colour — 16% of it — and that preview never calls onValueChange. Once both ends are committed the two ends wear the solid fill and the days between keep the wash, with square corners in the middle and the outer corners kept on the ends: one continuous bar, not seven separate chips. Every day of the span is aria-selected="true" on a grid that is aria-multiselectable — it announces the whole range — and the paint tells the ends from the middle.

The title is a button, and it dives: days → months → years, one level per press, with the shared chevron rotating 180° to say it is now the way back out. Escape inside a level returns to the days — and only inside a level, so a Calendar living in a DatePicker panel still closes that panel on the first press.

The year level. Twelve options in the same box the month occupied — both views stay mounted and the platform hides one, so diving in and back never resizes anything.

Code
<div class="wayra-calendar" data-color="primary" data-size="md" data-months="1" data-level="year">
<div class="wayra-calendar-header">
<button class="wayra-calendar-nav" type="button" data-direction="previous" aria-label="Mes anterior"></button>
<button class="wayra-calendar-title" type="button">1980 – 1991</button>
<button class="wayra-calendar-nav" type="button" data-direction="next" aria-label="Mes siguiente"></button>
</div>
<div class="wayra-calendar-months" hidden>
<table class="wayra-calendar-month" role="grid" aria-label="1980 – 1991">
<thead>
<tr><th class="wayra-calendar-weekday" scope="col" abbr="lunes">L</th><th class="wayra-calendar-weekday" scope="col" abbr="martes">M</th><th class="wayra-calendar-weekday" scope="col" abbr="miércoles">X</th><th class="wayra-calendar-weekday" scope="col" abbr="jueves">J</th><th class="wayra-calendar-weekday" scope="col" abbr="viernes">V</th><th class="wayra-calendar-weekday" scope="col" abbr="sábado">S</th><th class="wayra-calendar-weekday" scope="col" abbr="domingo">D</th></tr>
</thead>
<tbody>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="lunes, 27 de julio de 2026" data-outside="true">27</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="martes, 28 de julio de 2026" data-outside="true">28</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="miércoles, 29 de julio de 2026" data-outside="true">29</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="jueves, 30 de julio de 2026" data-outside="true">30</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="viernes, 31 de julio de 2026" data-outside="true">31</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="sábado, 1 de agosto de 2026">1</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="domingo, 2 de agosto de 2026">2</td></tr>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="lunes, 3 de agosto de 2026">3</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="martes, 4 de agosto de 2026">4</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="miércoles, 5 de agosto de 2026">5</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="jueves, 6 de agosto de 2026">6</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="viernes, 7 de agosto de 2026">7</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="sábado, 8 de agosto de 2026">8</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="domingo, 9 de agosto de 2026">9</td></tr>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="lunes, 10 de agosto de 2026">10</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="martes, 11 de agosto de 2026">11</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="miércoles, 12 de agosto de 2026">12</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="jueves, 13 de agosto de 2026">13</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="viernes, 14 de agosto de 2026">14</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="sábado, 15 de agosto de 2026">15</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="domingo, 16 de agosto de 2026">16</td></tr>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="lunes, 17 de agosto de 2026">17</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="martes, 18 de agosto de 2026">18</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="miércoles, 19 de agosto de 2026">19</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="jueves, 20 de agosto de 2026">20</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="viernes, 21 de agosto de 2026">21</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="sábado, 22 de agosto de 2026">22</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="domingo, 23 de agosto de 2026">23</td></tr>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="lunes, 24 de agosto de 2026">24</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="martes, 25 de agosto de 2026">25</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="miércoles, 26 de agosto de 2026">26</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="jueves, 27 de agosto de 2026">27</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="viernes, 28 de agosto de 2026">28</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="sábado, 29 de agosto de 2026">29</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="domingo, 30 de agosto de 2026">30</td></tr>
<tr><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="lunes, 31 de agosto de 2026">31</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="martes, 1 de septiembre de 2026" data-outside="true">1</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="miércoles, 2 de septiembre de 2026" data-outside="true">2</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="jueves, 3 de septiembre de 2026" data-outside="true">3</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="viernes, 4 de septiembre de 2026" data-outside="true">4</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="sábado, 5 de septiembre de 2026" data-outside="true">5</td><td class="wayra-calendar-day" role="gridcell" tabindex="-1" aria-selected="false" aria-label="domingo, 6 de septiembre de 2026" data-outside="true">6</td></tr>
</tbody>
</table>
</div>
<div class="wayra-calendar-options" role="group">
<button class="wayra-calendar-option" type="button" tabindex="-1">1980</button>
<button class="wayra-calendar-option" type="button" tabindex="-1">1981</button>
<button class="wayra-calendar-option" type="button" tabindex="-1">1982</button>
<button class="wayra-calendar-option" type="button" tabindex="-1">1983</button>
<button class="wayra-calendar-option" type="button" tabindex="-1">1984</button>
<button class="wayra-calendar-option" type="button" tabindex="-1">1985</button>
<button class="wayra-calendar-option" type="button" tabindex="-1">1986</button>
<button class="wayra-calendar-option" type="button" tabindex="0" data-selected="true" aria-current="true">1987</button>
<button class="wayra-calendar-option" type="button" tabindex="-1">1988</button>
<button class="wayra-calendar-option" type="button" tabindex="-1">1989</button>
<button class="wayra-calendar-option" type="button" tabindex="-1">1990</button>
<button class="wayra-calendar-option" type="button" tabindex="-1">1991</button>
</div>
<div class="wayra-calendar-footer">
<button class="wayra-calendar-today" type="button">Hoy</button>
</div>
</div>
Prop What it does
months How many months are painted side by side, starting at the current view. Reflected as data-months. Above one, the days spilling in from a neighbour are hidden rather than repeated — they keep their box, so the weeks stay aligned.
defaultMonth Which month is shown while there is no value, as any ISO date inside it: "2026-08-21" and "2026-08-01" mean the same month. Ignored once a value decides the view.
min / max ISO bounds. Cells outside are announced aria-disabled, refuse selection, and stop keyboard movement rather than scrolling past it.
isDateDisabled (iso: string) => boolean, for what neither bound can express — weekends, holidays, a booked day. Called for every painted cell, so keep it cheap.

The Hoy button in the footer takes the view and the focus back to today. It does not select it: getting somewhere and choosing something are two different acts.

One roving tab stop, on the focused date — never on a spilled-in day. The three header buttons and the footer button are ordinary tab stops of their own.

Key On the days On a level
← / → ±1 day, mirrored under dir="rtl" as the APG grid pattern asks ±1 option
↑ / ↓ ±7 days ±3 options, which is the row
Home / End The two ends of the week First / last option
PageUp / PageDown ±1 month; with Shift, ±1 year —
Enter / Space Selects Dives one level down
Escape Left alone, so a panel holding the calendar can close Back to the days

The view follows the focus: walking off the end of a month pages to the next one, and a value arriving from outside brings the view with it.

Calendar does not wire itself to react-hook-form and takes no name. It is a selection surface, and the field is DatePicker — which is this component in a popover with a masked input in front of it, and which does all the form wiring on name alone.

If you need an inline calendar inside a form, drive it as a controlled component and write the value yourself:

const { control, setValue } = useFormContext();
const salida = useWatch({ control, name: 'salida', exact: true });
<Calendar
value={salida || null}
onValueChange={(iso) => setValue('salida', iso ?? '', { shouldDirty: true })}
labels={LABELS}
/>

exact: true and a field-scoped subscription rather than watch('salida') — the same rule every field in this library follows, because watch subscribes the component that owns useForm, not the one that renders the calendar.

The root class plus its data-* attributes are the public API, and the stylesheet is written against them: the previews on this page are hand-written markup, drawn by the same rules the component’s own output is.

Attribute Values
data-color the eight roles; primary when omitted
data-size xs, sm, md, lg, xl
data-months the count, always present
data-level day, month or year
data-range "true" while mode="range"; absent otherwise
data-dir "rtl" inside a right-to-left context; absent otherwise

data-dir is the component’s own reading of the direction, which turns the two month arrows: :dir() is Chrome 120, above the 111 floor. Hand-written right-to-left markup writes it itself.

On a cell: aria-selected, aria-disabled, aria-current="date", and data-outside, data-range-start, data-range-end, data-in-range — all four presence attributes. On a level option: data-selected and data-current for the paint, and, while the level is showing, aria-current — "true" on the option in view, "date" on today’s. State is ARIA where ARIA has a word for it, and a data-* attribute only where it does not.

The parts are .wayra-calendar-header, -nav (carrying data-direction, either previous or next), -title, -months, -month (the role="grid" table), -weekday, -day, -options (role="group"), -option, -footer, -today and -status — a role="status" that is visually hidden and carries the announcements.

unstyled drops the design-system classes — the root’s and every part’s — and keeps every data-*, role and aria-* attribute. className merges after the root class.

CalendarProps is a union of two shapes discriminated by mode, extending ComponentProps<"div"> minus color, defaultValue and onChange.

Prop Type Default Description
mode "single" | "range" "single" Picks the shape of the value.
value string | null or DateRange — Controlled. null (or two nulls) is the empty selection.
defaultValue the same — What is chosen on the first render while nothing controls it.
onValueChange (value) => void — Fires on a commit only, never from hover.
months number 1 Months side by side. Reflected as data-months.
defaultMonth string today Which month is shown while there is no value. Pass it under SSR.
locale string the platform’s Every name, order, digit and capitalisation.
weekStart 1 … 7 the locale’s, else 1 1 = Monday, Temporal’s numbering.
min / max string — ISO bounds; keyboard movement stops there.
isDateDisabled (iso: string) => boolean — Per-cell veto.
labels { previous, next, today } — required The three strings Intl cannot supply.
color WayraColor "primary" Today’s ring, the selection fill, the range band.
size "xs" | "sm" | "md" | "lg" | "xl" "md" The shared control ladder, so a calendar lines up with the Button beside it.
unstyled boolean false Drops the design-system classes, keeps the attributes.
className, ref, and every other div prop Land on the root.

DateRange is { start: string \| null; end: string \| null }, exported from the package and shared with DatePicker.

Property Default What it does
--wayra-calendar-cell max(24px, calc(var(--_wayra-control-height) * 0.9)) The size of one day cell, and the height of one month or year option. Above the floor it tracks the shared control ladder, so a calendar and a field of the same size share a rhythm; the 24px floor is WCAG 2.5.8’s target size.

That is the only public knob this component adds. The rest of what it paints comes from tokens the README already documents and Theming explains — --wayra-radius-box and --wayra-border for the box, --wayra-size-field for the ladder every step is a multiple of, --wayra-color-base-100 and --wayra-color-base-content for the surface, and --wayra-tone / --wayra-tone-content set on the element for a colour outside the eight roles.

Everything else it reads is --_wayra-*: private, unregistered, and free to change in a patch — including the width of today’s ring and the 16% of the range preview.

  • The APG grid pattern, on a real <table>. role="grid", role="gridcell", one roving tab stop, arrow keys in two dimensions. A real table is the only element whose rows and column headers a screen reader can walk, and the <th scope="col" abbr> carries the long weekday name behind the single-letter one you see.
  • Every cell announces its own long date — “jueves, 21 de agosto de 2026” — built by Intl from the locale, plus its state from aria-selected, aria-disabled and aria-current="date". Today is aria-current, not a data-today attribute: it is a real semantic and the styling hangs off it.
  • Paging and picking are announced from a role="status" that is the last child, and outside every grid — a live region inside role="grid" would be a child the role does not allow. It starts holding the title it already shows, because a live region’s first content is never announced anyway.
  • The focus ring on a cell is an inward outline, not the shared halo every other component gets. A cell has neighbours on all four sides, so a halo would be drawn over them; the exemption is listed in states.css beside List’s and Table’s.
  • The 24px floor is enforced in the cell size itself, so even size="xs" clears WCAG 2.5.8.
  • Forced colors: the selection turns Highlight / HighlightText, today’s ring becomes CanvasText, the range band becomes a dotted CanvasText outline, blocked days turn GrayText, and the focus outline is repainted last so nothing covers it. The month arrows and the title’s chevron are gradients, which forced colors would erase, so they opt out and draw in CanvasText.
  • A press washes one step past the hover, 16% against 8%, on a day, an option and the header and Today buttons. A neighbour month’s day takes the full text colour while pressed: the muted gray is 3.92:1 on that wash, under the 4.5:1 it clears on the hover.
  • Reduced motion zeroes the duration variable the hover fills and the title chevron both read. Nothing here animates layout.

Do

LMXJVSD
272829303112
3456789
10111213141516
17181920212223
24252627282930
31123456

Inline when the month is the context: a stay, an availability grid, a shift roster. Two ends read as one bar, and the whole decision is on screen.

Don't

A birth date. Nobody wants to page through decades twelve at a time — in a DatePicker the year is four keystrokes.