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
| L | M | M | J | V | S | D |
|---|---|---|---|---|---|---|
| 27 | 28 | 29 | 30 | 31 | 1 | 2 |
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 29 | 30 |
| 31 | 1 | 2 | 3 | 4 | 5 | 6 |
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.
The value is an ISO string
Section titled “The value is an ISO string”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.
The three labels are required
Section titled “The three labels are required”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.
The locale decides what Intl can decide
Section titled “The locale decides what Intl can decide”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.
Selection
Section titled “Selection”| L | M | X | J | V | S | D |
|---|---|---|---|---|---|---|
| 27 | 28 | 29 | 30 | 31 | 1 | 2 |
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 29 | 30 |
| 31 | 1 | 2 | 3 | 4 | 5 | 6 |
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.
Ranges
Section titled “Ranges”| L | M | X | J | V | S | D |
|---|---|---|---|---|---|---|
| 27 | 28 | 29 | 30 | 31 | 1 | 2 |
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 29 | 30 |
| 31 | 1 | 2 | 3 | 4 | 5 | 6 |
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.
Levels
Section titled “Levels”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.
| L | M | X | J | V | S | D |
|---|---|---|---|---|---|---|
| 27 | 28 | 29 | 30 | 31 | 1 | 2 |
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 29 | 30 |
| 31 | 1 | 2 | 3 | 4 | 5 | 6 |
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>Several months, bounds and blocked days
Section titled “Several months, bounds and blocked days”| 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.
Keyboard
Section titled “Keyboard”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.
Why there is no name
Section titled “Why there is no name”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.
Styling contract
Section titled “Styling contract”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.
Custom properties
Section titled “Custom properties”| 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.
Accessibility
Section titled “Accessibility”- 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
Intlfrom the locale, plus its state fromaria-selected,aria-disabledandaria-current="date". Today isaria-current, not adata-todayattribute: 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 insiderole="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.cssbeside 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 becomesCanvasText, the range band becomes a dottedCanvasTextoutline, blocked days turnGrayText, 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 inCanvasText. - 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 and don’t
Section titled “Do and don’t”Do
| L | M | X | J | V | S | D |
|---|---|---|---|---|---|---|
| 27 | 28 | 29 | 30 | 31 | 1 | 2 |
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 29 | 30 |
| 31 | 1 | 2 | 3 | 4 | 5 | 6 |
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
| L | M | X | J | V | S | D |
|---|---|---|---|---|---|---|
| 27 | 28 | 29 | 30 | 31 | 1 | 2 |
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 29 | 30 |
| 31 | 1 | 2 | 3 | 4 | 5 | 6 |
A birth date. Nobody wants to page through decades twelve at a time — in a DatePicker the year is four keystrokes.