Calendar
A month of days to pick from, with ranges.
On this page
A calendar is a month of days to pick from. It is an ARIA grid of buttons: the stylesheet draws every state, and the calendar behavior supplies the dates, the month buttons and the arrow keys. It has no surface of its own, so it sits in a card, or in a popover to make a date picker.
Calendar
<div class="sheet p-md"> <div class="calendar"> <div class="calendar-header"> <button type="button" class="btn btn-quiet btn-square btn-sm" aria-label="Previous month"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="m15 6-6 6 6 6" /> </svg> </button> <h3 class="calendar-title" id="cal-title" aria-live="polite"></h3> <button type="button" class="btn btn-quiet btn-square btn-sm" aria-label="Next month"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="m9 6 6 6-6 6" /> </svg> </button> </div> <div class="calendar-grid" role="grid" aria-labelledby="cal-title"></div> </div></div><script type="module"> import { attach } from '@master/ui/behaviors' attach()</script>The markup is the header and an empty grid. The behavior writes the month's name into calendar-title, and the weekday names and the weeks into calendar-grid; it opens on the month of today. Without the script the grid stays as the markup has it, so a calendar that must read before the script runs is rendered on the server with its weeks, as the range below is written, and the behavior replaces them when it attaches.
#States
Every state is an attribute or a class on the day's button. The behavior sets them; markup written by hand or on a server sets the same ones.
| State | Mark it with | Drawn as |
|---|---|---|
| Today | aria-current="date" | A dot under the number |
| Chosen | aria-selected="true" | Ink, the way a checked checkbox fills |
| Outside the month | calendar-day-outside | Muted ink |
| Unavailable | disabled | Disabled ink |
A day answers the pointer at once, like a row in a menu. Today keeps its dot when it is chosen, in the ink of the day.
#Range
A range is two ink days joined by a chip-toned band. The first day has calendar-day-range-start, the last calendar-day-range-end and the days between calendar-day-range. All of them are aria-selected="true".
This preview is markup only, with no script attached, so the marks can be read in its Code view: a whole month as the behavior writes it, with a range, today and two days that cannot be chosen.
Range
<div class="sheet p-md"> <div class="calendar"> <div class="calendar-header"> <button type="button" class="btn btn-quiet btn-square btn-sm" aria-label="Previous month"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="m15 6-6 6 6 6" /> </svg> </button> <h3 class="calendar-title" id="range-title" aria-live="polite">November 2026</h3> <button type="button" class="btn btn-quiet btn-square btn-sm" aria-label="Next month"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="m9 6 6 6-6 6" /> </svg> </button> </div> <div class="calendar-grid" role="grid" aria-labelledby="range-title"> <div class="calendar-week" role="row"> <span class="calendar-weekday" role="columnheader" aria-label="Sunday">Sun</span> <span class="calendar-weekday" role="columnheader" aria-label="Monday">Mon</span> <span class="calendar-weekday" role="columnheader" aria-label="Tuesday">Tue</span> <span class="calendar-weekday" role="columnheader" aria-label="Wednesday">Wed</span> <span class="calendar-weekday" role="columnheader" aria-label="Thursday">Thu</span> <span class="calendar-weekday" role="columnheader" aria-label="Friday">Fri</span> <span class="calendar-weekday" role="columnheader" aria-label="Saturday">Sat</span> </div> <div class="calendar-week" role="row"> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Sunday, November 1, 2026">1</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Monday, November 2, 2026">2</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Tuesday, November 3, 2026">3</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Wednesday, November 4, 2026">4</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Thursday, November 5, 2026">5</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Friday, November 6, 2026" disabled>6</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Saturday, November 7, 2026" disabled>7</button> </div> <div class="calendar-week" role="row"> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Sunday, November 8, 2026">8</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Monday, November 9, 2026">9</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Tuesday, November 10, 2026">10</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Wednesday, November 11, 2026" aria-current="date">11</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Thursday, November 12, 2026">12</button> <button type="button" class="calendar-day calendar-day-range-start" role="gridcell" tabindex="0" aria-label="Friday, November 13, 2026" aria-selected="true">13</button> <button type="button" class="calendar-day calendar-day-range" role="gridcell" tabindex="-1" aria-label="Saturday, November 14, 2026" aria-selected="true">14</button> </div> <div class="calendar-week" role="row"> <button type="button" class="calendar-day calendar-day-range" role="gridcell" tabindex="-1" aria-label="Sunday, November 15, 2026" aria-selected="true">15</button> <button type="button" class="calendar-day calendar-day-range" role="gridcell" tabindex="-1" aria-label="Monday, November 16, 2026" aria-selected="true">16</button> <button type="button" class="calendar-day calendar-day-range" role="gridcell" tabindex="-1" aria-label="Tuesday, November 17, 2026" aria-selected="true">17</button> <button type="button" class="calendar-day calendar-day-range-end" role="gridcell" tabindex="-1" aria-label="Wednesday, November 18, 2026" aria-selected="true">18</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Thursday, November 19, 2026">19</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Friday, November 20, 2026">20</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Saturday, November 21, 2026">21</button> </div> <div class="calendar-week" role="row"> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Sunday, November 22, 2026">22</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Monday, November 23, 2026">23</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Tuesday, November 24, 2026">24</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Wednesday, November 25, 2026">25</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Thursday, November 26, 2026">26</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Friday, November 27, 2026">27</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Saturday, November 28, 2026">28</button> </div> <div class="calendar-week" role="row"> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Sunday, November 29, 2026">29</button> <button type="button" class="calendar-day" role="gridcell" tabindex="-1" aria-label="Monday, November 30, 2026">30</button> <button type="button" class="calendar-day calendar-day-outside" role="gridcell" tabindex="-1" aria-label="Tuesday, December 1, 2026">1</button> <button type="button" class="calendar-day calendar-day-outside" role="gridcell" tabindex="-1" aria-label="Wednesday, December 2, 2026">2</button> <button type="button" class="calendar-day calendar-day-outside" role="gridcell" tabindex="-1" aria-label="Thursday, December 3, 2026">3</button> <button type="button" class="calendar-day calendar-day-outside" role="gridcell" tabindex="-1" aria-label="Friday, December 4, 2026">4</button> <button type="button" class="calendar-day calendar-day-outside" role="gridcell" tabindex="-1" aria-label="Saturday, December 5, 2026">5</button> </div> </div> </div></div>The band is drawn so it reads as one shape:
- Columns have no gap, so the band runs from cell to cell. Rows keep 2px apart, so each week is its own band.
- The band rounds where a week ends and the range goes on.
- At each end the band covers the half of the cell that faces the range, and the ink day sits on it as a layer of its own. The band therefore meets the day's middle, not its rounded edge.
With the range option the behavior chooses a range: the first click is its start, the second its end, and a third starts again. When the second day is the earlier one, the two are put in order.
Choosing a range
<div class="sheet p-md"> <div class="calendar" id="stay"> <div class="calendar-header"> <button type="button" class="btn btn-quiet btn-square btn-sm" aria-label="Previous month"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="m15 6-6 6 6 6" /> </svg> </button> <h3 class="calendar-title" id="stay-title" aria-live="polite"></h3> <button type="button" class="btn btn-quiet btn-square btn-sm" aria-label="Next month"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="m9 6 6 6-6 6" /> </svg> </button> </div> <div class="calendar-grid" role="grid" aria-labelledby="stay-title"></div> </div></div><script> addEventListener('DOMContentLoaded', function () { MasterUI.calendar(document.getElementById('stay'), { range: true, value: ['2026-11-13', '2026-11-18'] }) })</script><script type="module"> import { attach } from '@master/ui/behaviors' attach()</script>attach() passes no options, so this preview calls calendar itself, after attach() has run: a second call on one root replaces the first. MasterUI is the bundle a preview loads; in a project, import calendar as the behavior section shows.
#Date picker
A date picker is a field, a button and a calendar in a popover. The popover opens and closes without script and places itself against its button. The behavior draws the calendar and says which day is chosen; the page's own script writes that day into the field and hides the popover.
Date picker
<div class="field max-w(280) mx-auto"> <label for="starts">Starts</label> <div class="input-group"> <input class="input-group-field" id="starts" value="2026-11-04" readonly> <button type="button" class="btn btn-quiet btn-square" popovertarget="starts-calendar" aria-label="Choose a date"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <rect x="4" y="5" width="16" height="15" rx="2" /> <path d="M4 10h16M8 3v4M16 3v4" /> </svg> </button> <div id="starts-calendar" popover class="popover popover-end"> <div class="calendar"> <div class="calendar-header"> <button type="button" class="btn btn-quiet btn-square btn-sm" aria-label="Previous month"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="m15 6-6 6 6 6" /> </svg> </button> <h3 class="calendar-title" id="picker-title" aria-live="polite"></h3> <button type="button" class="btn btn-quiet btn-square btn-sm" aria-label="Next month"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="m9 6 6 6-6 6" /> </svg> </button> </div> <div class="calendar-grid" role="grid" aria-labelledby="picker-title"></div> </div> </div> </div></div><script> addEventListener('DOMContentLoaded', function () { var popover = document.getElementById('starts-calendar') MasterUI.calendar(popover.querySelector('.calendar'), { value: '2026-11-04' }) popover.addEventListener('change', function (event) { document.getElementById('starts').value = event.detail.value popover.hidePopover() }) })</script><script type="module"> import { attach } from '@master/ui/behaviors' attach()</script>The calendar's change event bubbles, so the listener is on the popover. Its detail.value is the day as YYYY-MM-DD, which is also the value a form expects of a date.
On a phone, or when a plain date is all the form needs, a native <input type="date"> gives the reader the picker their device already has.
#The behavior
calendar takes the root and an object of options, and returns the function that detaches it.
import { calendar } from '@master/ui/behaviors/calendar.js'const root = document.querySelector('.calendar')const picker = calendar(root, { value: '2026-11-04', min: '2026-11-01', max: '2026-12-31' })root.addEventListener('change', (event) => console.log(event.detail.value))| Option | What it sets |
|---|---|
value | The chosen day as YYYY-MM-DD, or the two ends of a range |
month | The month to show first, as YYYY-MM. By default the month of the value, or of today |
min, max | The first and the last day that can be chosen, as YYYY-MM-DD. A month button is disabled when there is no month to go to |
range | true to choose two days and every day between them |
weekStart | The day in the first column, 0 for Sunday to 6 for Saturday. By default the locale's own, and Monday where the browser cannot say |
locale | A BCP 47 tag for the names of months and days. By default the lang the calendar stands in |
disabled | A function that takes a Date and says whether that day cannot be chosen |
draw | A function that writes the grid instead of the script. It is called with the month to show, as YYYY-MM; see a grid of your own |
- What it writes. The weekday names and the weeks of the shown month, and the month's name. On the days: the date as the button's
value,aria-current="date"on today,aria-selected="true"and the range classes on what is chosen,disabledon a day that cannot be chosen, a full date asaria-label, andtabindexso one day is in the tab order. Withdraw, a template or a framework writes the days and the script only marks them. - The event. Choosing a day dispatches a bubbling
changefrom the root withdetail.value: the day asYYYY-MM-DD, or[start, end]once both ends of a range are chosen. - The value. It lives in the script, not in the markup. Read it from the returned function's
value:picker.value. In a range the end isnulluntil it is chosen. Set it to choose from outside:picker.value = '2026-11-20'shows the month of the new day and dispatches nothing. - Days. They are local and Gregorian.
#Structure
calendar-griddeclares seven columns once. Eachcalendar-weekis a row on a subgrid of them, so the weekday names and the days share the same columns without a table.calendar-weekdayis a column heading. Itsaria-labelis the full name.calendar-dayis a<button>withrole="gridcell".
#Keyboard
The behavior gives the grid the keys of a date grid. A move past the edge of the month shows the neighboring month and keeps focus on the new day. A move never lands on a day that cannot be chosen, and stops at min and max.
| Keys | Action |
|---|---|
| ArrowLeft, ArrowRight | Move focus to the day before or the day after; the other way around from right to left |
| ArrowUp, ArrowDown | Move focus to the same weekday of the week before or after |
| Home, End | Move focus to the first or the last day of the week |
| PageUp, PageDown | Show the month before or after and move focus to the same day in it, or to its last day when it is shorter |
| Shift+PageUp, Shift+PageDown | Show the same month of the year before or after and move focus to the same day in it |
| Enter, Space | Choose the day that has focus; the day is a button, so the browser turns the key into its click |
| Tab | Enters the grid at the chosen day, or at today, or at the first day of a month that holds neither; the grid is one tab stop |
#Accessibility
- The grid is
role="grid", labelled by the month's title; each week isrole="row". - One day is in the tab order: the chosen one, or today, or the first day of a month that holds neither. The rest have
tabindex="-1". - Give the title
aria-live="polite", so a change of month is announced. - On touch screens a day is 44px.
#React, Svelte and Vue
The calendar component draws the month from the same data as the behavior, so it is in the first HTML, and passes the chosen day in and out. Copy it into the project with npx master-ui add calendar; React, Svelte and Vue says how the components are built and used.
#Classes
| Class | Description |
|---|---|
calendar | The month: a header over a grid of days |
calendar-header | The row over the grid: a button to the month before, the title, a button to the month after |
calendar-title | The name of the month, centered between the two buttons; give it aria-live="polite" so a change of month is announced |
calendar-grid | The seven-column grid, with role="grid" and labelled by the title |
calendar-week | A row on the grid's columns, with role="row": the weekday names, or a week of days |
calendar-weekday | A column heading with role="columnheader"; give it the full name of the day as aria-label |
calendar-day | A day, on a button with role="gridcell". One day is in the tab order; the others have tabindex="-1". Its value is its date as YYYY-MM-DD: the behavior writes it, and reads it from days that something else wrote |
calendar-day-outside | A day of the month before or after, shown in muted ink to complete a week. With calendar-day |
calendar-day-range-start | The first day of a range: an ink day on the half of the band that faces the range. With calendar-day-range-end on the same day, a range of one day. With calendar-day |
calendar-day-range | A day between the ends of a range: a chip-toned band from cell to cell, rounded where a week ends. With calendar-day |
calendar-day-range-end | The last day of a range: an ink day on the half of the band that faces the range. With calendar-day |
#Custom properties
| Property | Default | Description |
|---|---|---|
--calendar-cell | 2.25rem | Width and height of a day; 2.75rem on touch screens |
--calendar-day-radius | var(--radius-control-sm) | Corner of a day |