Navigation

v0.0.4
Forms

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>
Click a day to choose it, turn the month with the two buttons, or focus a day and use the arrow keys.

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.

StateMark it withDrawn as
Todayaria-current="date"A dot under the number
Chosenaria-selected="true"Ink, the way a checked checkbox fills
Outside the monthcalendar-day-outsideMuted ink
UnavailabledisabledDisabled 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>
Click a day to start a range, and another to end it.

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.

app.js
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))
OptionWhat it sets
valueThe chosen day as YYYY-MM-DD, or the two ends of a range
monthThe month to show first, as YYYY-MM. By default the month of the value, or of today
min, maxThe 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
rangetrue to choose two days and every day between them
weekStartThe 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
localeA BCP 47 tag for the names of months and days. By default the lang the calendar stands in
disabledA function that takes a Date and says whether that day cannot be chosen
drawA 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, disabled on a day that cannot be chosen, a full date as aria-label, and tabindex so one day is in the tab order. With draw, a template or a framework writes the days and the script only marks them.
  • The event. Choosing a day dispatches a bubbling change from the root with detail.value: the day as YYYY-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 is null until 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-grid declares seven columns once. Each calendar-week is a row on a subgrid of them, so the weekday names and the days share the same columns without a table.
  • calendar-weekday is a column heading. Its aria-label is the full name.
  • calendar-day is a <button> with role="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.

KeysAction
ArrowLeft, ArrowRightMove focus to the day before or the day after; the other way around from right to left
ArrowUp, ArrowDownMove focus to the same weekday of the week before or after
Home, EndMove focus to the first or the last day of the week
PageUp, PageDownShow 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+PageDownShow the same month of the year before or after and move focus to the same day in it
Enter, SpaceChoose the day that has focus; the day is a button, so the browser turns the key into its click
TabEnters 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 is role="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.

ui/calendar.tsx
'use client'import { useId, useMemo, useState, type ComponentProps } from 'react'import { calendar, calendarMonth } from '@master/ui/behaviors/calendar.js'import { useBehavior } from './use-behavior'/** A day as `YYYY-MM-DD`; in a range, its two ends, the second null until it is chosen. */export type CalendarValue = string | [string, string | null] | nullexport interface CalendarProps extends Omit<ComponentProps<'div'>, 'defaultValue'> {  value?: CalendarValue  defaultValue?: CalendarValue  onValueChange?: (value: CalendarValue) => void  /** Choose two days and every day between them. */  range?: boolean  /** The first and the last day that can be chosen, as `YYYY-MM-DD`. */  min?: string  max?: string  /** The month to show first, as `YYYY-MM`. The month of the value, or this month, by default. */  month?: string  /** The day in the first column, 0 for Sunday to 6 for Saturday. The locale's own by default. */  weekStart?: number  /** A BCP 47 tag for the names. Give it when the calendar is rendered on a server, so both sides agree. */  locale?: string  /** Says whether a day cannot be chosen. */  disabled?: (date: Date) => boolean  previousLabel?: string  nextLabel?: string}/** * A calendar. React writes the month, from the same data the behavior uses, so it is in the first HTML; * the behavior marks today, the chosen days and the day in the tab order, and asks for another month. */export function Calendar({ value, defaultValue, onValueChange, range, min, max, month, weekStart, locale, disabled, previousLabel = 'Previous month', nextLabel = 'Next month', className, ...root }: CalendarProps) {  const id = useId()  const [first] = useState(value ?? defaultValue)  const [shown, setShown] = useState(() => calendarMonth(month ?? [first].flat()[0] ?? undefined).month)  // The first value's days are marked in the first render; after that the behavior marks what is chosen.  const view = useMemo(() => calendarMonth(shown, { weekStart, locale, value: first, range }), [shown, weekStart, locale, first, range])  const ref = useBehavior(calendar, { value: (first ?? undefined) as string | string[] | undefined, range, min, max, month, weekStart, locale, disabled, draw: setShown }, { value, onValueChange })  return (    <div className={className ? `calendar ${className}` : 'calendar'} ref={ref} {...root}>      <div className="calendar-header">        <button type="button" className="btn btn-quiet btn-square btn-sm" aria-label={previousLabel}>          <svg className="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m15 6-6 6 6 6" /></svg>        </button>        <h3 className="calendar-title" id={`${id}title`} aria-live="polite">{view.title}</h3>        <button type="button" className="btn btn-quiet btn-square btn-sm" aria-label={nextLabel}>          <svg className="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>        </button>      </div>      <div className="calendar-grid" role="grid" aria-labelledby={`${id}title`}>        <div className="calendar-week" role="row">          {view.weekdays.map(weekday => <span className="calendar-weekday" role="columnheader" aria-label={weekday.name} key={weekday.name}>{weekday.short}</span>)}        </div>        {view.weeks.map(week => (          <div className="calendar-week" role="row" key={week[0]!.date}>            {week.map(day => <button type="button" className={['calendar-day', day.outside && 'calendar-day-outside', day.range].filter(Boolean).join(' ')} role="gridcell" tabIndex={day.tab ? 0 : -1} value={day.date} aria-selected={day.selected || undefined} aria-label={day.label} key={day.date}>{day.text}</button>)}          </div>        ))}      </div>    </div>  )}
ui/Calendar.svelte
<script lang="ts" module>  import type { HTMLAttributes } from 'svelte/elements'  /** A day as `YYYY-MM-DD`; in a range, its two ends, the second null until it is chosen. */  export type CalendarValue = string | [string, string | null] | null  export interface CalendarProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {    /** What is chosen. Bind it, or leave it out and the pattern keeps its own. */    value?: CalendarValue    onValueChange?: (value: CalendarValue) => void    /** Choose two days and every day between them. */    range?: boolean    /** The first and the last day that can be chosen, as `YYYY-MM-DD`. */    min?: string    max?: string    /** The month to show first, as `YYYY-MM`. The month of the value, or this month, by default. */    month?: string    /** The day in the first column, 0 for Sunday to 6 for Saturday. The locale's own by default. */    weekStart?: number    /** A BCP 47 tag for the names. Give it when the calendar is rendered on a server, so both sides agree. */    locale?: string    /** Says whether a day cannot be chosen. */    disabled?: (date: Date) => boolean    previousLabel?: string    nextLabel?: string  }</script><script lang="ts">  import { untrack } from 'svelte'  import { calendar, calendarMonth } from '@master/ui/behaviors/calendar.js'  import { behavior } from './behavior.svelte.js'  let { value = $bindable(), onValueChange, range, min, max, month, weekStart, locale, disabled, previousLabel = 'Previous month', nextLabel = 'Next month', class: className, ...root }: CalendarProps = $props()  const id = $props.id()  const first = untrack(() => value)  let shown = $state(untrack(() => calendarMonth(month ?? [first].flat()[0] ?? undefined).month))  // The first value's days are marked in the first render; after that the behavior marks what is chosen.  const view = $derived(calendarMonth(shown, { weekStart, locale, value: first, range }))  const attach = behavior(calendar, () => ({ value: (first ?? undefined) as string | string[] | undefined, range, min, max, month, weekStart, locale, disabled, draw: (next: string) => { shown = next } }), {    value: () => value,    change: (next) => { value = next; onValueChange?.(next) },  })</script><!--  @component  A calendar. Svelte writes the month, from the same data the behavior uses, so it is in the first HTML;  the behavior marks today, the chosen days and the day in the tab order, and asks for another month.--><div class={['calendar', className]} {@attach attach} {...root}>  <div class="calendar-header">    <button type="button" class="btn btn-quiet btn-square btn-sm" aria-label={previousLabel}>      <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 6-6 6 6 6" /></svg>    </button>    <h3 class="calendar-title" id="{id}title" aria-live="polite">{view.title}</h3>    <button type="button" class="btn btn-quiet btn-square btn-sm" aria-label={nextLabel}>      <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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="{id}title">    <div class="calendar-week" role="row">      {#each view.weekdays as weekday (weekday.name)}        <span class="calendar-weekday" role="columnheader" aria-label={weekday.name}>{weekday.short}</span>      {/each}    </div>    {#each view.weeks as week (week[0]!.date)}      <div class="calendar-week" role="row">        {#each week as day (day.date)}          <button type="button" class={['calendar-day', day.outside && 'calendar-day-outside', day.range]} role="gridcell" tabindex={day.tab ? 0 : -1} value={day.date} aria-selected={day.selected || undefined} aria-label={day.label}>{day.text}</button>        {/each}      </div>    {/each}  </div></div>
ui/Calendar.vue
<script lang="ts">/** A day as `YYYY-MM-DD`; in a range, its two ends, the second null until it is chosen. */export type CalendarValue = string | [string, string | null] | null</script><script setup lang="ts">import { computed, shallowRef, useId, useTemplateRef } from 'vue'import { calendar, calendarMonth } from '@master/ui/behaviors/calendar.js'import { useBehavior } from './use-behavior'defineOptions({ inheritAttrs: false })const { range, min, max, month, weekStart, locale, disabled, previousLabel = 'Previous month', nextLabel = 'Next month' } = defineProps<{  /** Choose two days and every day between them. */  range?: boolean  /** The first and the last day that can be chosen, as `YYYY-MM-DD`. */  min?: string  max?: string  /** The month to show first, as `YYYY-MM`. The month of the value, or this month, by default. */  month?: string  /** The day in the first column, 0 for Sunday to 6 for Saturday. The locale's own by default. */  weekStart?: number  /** A BCP 47 tag for the names. Give it when the calendar is rendered on a server, so both sides agree. */  locale?: string  /** Says whether a day cannot be chosen. */  disabled?: (date: Date) => boolean  previousLabel?: string  nextLabel?: string}>()/** The chosen day, or the two ends of a range: `v-model`. */const model = defineModel<CalendarValue>()/* * A calendar. Vue writes the month, from the same data the behavior uses, so it is in the first HTML; * the behavior marks today, the chosen days and the day in the tab order, and asks for another month. */const id = useId()const first = model.valueconst shown = shallowRef(calendarMonth(month ?? [first].flat()[0] ?? undefined).month)// The first value's days are marked in the first render; after that the behavior marks what is chosen.const view = computed(() => calendarMonth(shown.value, { weekStart, locale, value: first, range }))const root = useTemplateRef<HTMLDivElement>('root')useBehavior(root, calendar, () => ({  value: (first ?? undefined) as string | string[] | undefined,  range,  min,  max,  month,  weekStart,  locale,  disabled,  draw: (next: string) => { shown.value = next },}), model)</script><template>  <div class="calendar" ref="root" v-bind="$attrs">    <div class="calendar-header">      <button type="button" class="btn btn-quiet btn-square btn-sm" :aria-label="previousLabel">        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 6-6 6 6 6" /></svg>      </button>      <h3 class="calendar-title" :id="`${id}-title`" aria-live="polite">{{ view.title }}</h3>      <button type="button" class="btn btn-quiet btn-square btn-sm" :aria-label="nextLabel">        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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="`${id}-title`">      <div class="calendar-week" role="row">        <span v-for="weekday in view.weekdays" :key="weekday.name" class="calendar-weekday" role="columnheader" :aria-label="weekday.name">{{ weekday.short }}</span>      </div>      <div v-for="week in view.weeks" :key="week[0]!.date" class="calendar-week" role="row">        <button v-for="day in week" :key="day.date" type="button" class="calendar-day" :class="[{ 'calendar-day-outside': day.outside }, day.range]" role="gridcell" :tabindex="day.tab ? 0 : -1" :value="day.date" :aria-selected="day.selected || undefined" :aria-label="day.label">{{ day.text }}</button>      </div>    </div>  </div></template>

#Classes

ClassDescription
calendarThe month: a header over a grid of days
calendar-headerThe row over the grid: a button to the month before, the title, a button to the month after
calendar-titleThe name of the month, centered between the two buttons; give it aria-live="polite" so a change of month is announced
calendar-gridThe seven-column grid, with role="grid" and labelled by the title
calendar-weekA row on the grid's columns, with role="row": the weekday names, or a week of days
calendar-weekdayA column heading with role="columnheader"; give it the full name of the day as aria-label
calendar-dayA 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-outsideA day of the month before or after, shown in muted ink to complete a week. With calendar-day
calendar-day-range-startThe 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-rangeA 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-endThe last day of a range: an ink day on the half of the band that faces the range. With calendar-day

#Custom properties

PropertyDefaultDescription
--calendar-cell2.25remWidth and height of a day; 2.75rem on touch screens
--calendar-day-radiusvar(--radius-control-sm)Corner of a day

#Source

@master/ui/components/calendar.css
/* * Calendar: a month of days to pick from, as an ARIA grid of buttons. * The stylesheet draws every state and leaves the dates and the keys to the script that fills it: today is * aria-current="date" and carries a dot, a chosen day is aria-selected and fills with ink the way a checkbox * does, and a range is a chip-toned band between two ink days. Weeks are rows on a subgrid, so the seven * columns are declared once. It has no surface of its own: put it in a popover for a date picker, or a card. */@layer components {  .calendar {    --calendar-cell: 2.25rem;    --calendar-day-radius: var(--radius-control-sm);    display: inline-grid;    gap: .5rem;    min-width: 0;    color: var(--color-text-strong);    font: 400 .8125rem/1 var(--font-family-sans);    font-feature-settings: inherit;    font-variant-numeric: tabular-nums;    user-select: none;    -webkit-user-select: none;  }  /* Previous, the month, next. */  .calendar-header {    display: flex;    align-items: center;    justify-content: space-between;    gap: .5rem;  }  .calendar-title {    flex: 1 1 0;    margin: 0;    color: var(--color-text-strong);    font: var(--font-weight-control) .875rem/1.4 var(--font-family-sans);    font-feature-settings: inherit;    text-align: center;  }  /* Seven columns; rows keep 2px apart so a range reads as one band per week. */  .calendar-grid {    display: grid;    grid-template-columns: repeat(7, var(--calendar-cell));    row-gap: 2px;  }  /* A row of the grid: the weekday names, or a week of days, on the grid's own columns. */  .calendar-week {    display: grid;    grid-column: 1 / -1;    grid-template-columns: subgrid;  }  .calendar-weekday {    display: grid;    place-items: center;    height: 1.75rem;    color: var(--color-text-muted);    font-size: .71875rem;    font-weight: 400;  }  /* A day: a button in a grid cell. */  .calendar-day {    position: relative;    isolation: isolate;    display: grid;    place-items: center;    width: 100%;    height: var(--calendar-cell);    margin: 0;    padding: 0;    border: 0;    border-radius: calc(var(--calendar-day-radius) * var(--corner-scale));    corner-shape: var(--corner-shape);    background: transparent;    color: inherit;    font: inherit;    font-feature-settings: inherit;    appearance: none;    -webkit-tap-highlight-color: transparent;    cursor: pointer;    touch-action: manipulation;    font-variant-numeric: tabular-nums;    /* A day answers at once, like a row in a menu. */    @media (hover: hover) {      &:hover:not(:disabled, [aria-disabled="true"], [aria-selected="true"], [aria-pressed="true"]) {        background: var(--color-surface-chip);      }    }    /* Pressed: a step deeper than the pointer's tone. */    &:active:not(:disabled, [aria-disabled="true"], [aria-selected="true"], [aria-pressed="true"]) {      background: var(--color-surface-inset-hover);    }    /* Today keeps its place in the month with a dot under the number. */    &[aria-current="date"] {      font-weight: var(--font-weight-control);      &::after {        content: '';        position: absolute;        bottom: .3125rem;        left: calc(50% - 1.5px);        width: 3px;        height: 3px;        border-radius: 999px;        background: currentColor;      }    }    /* A day of the month before or after, shown to complete a week. */    &.calendar-day-outside {      color: var(--color-text-muted);    }    /* Chosen: ink, with the line of light a checked checkbox has. */    &:is([aria-selected="true"], [aria-pressed="true"]) {      background: var(--color-surface-inverse);      color: var(--color-text-inverse);      box-shadow: var(--shadow-ink-mark);    }    /* Inside a range: a chip-toned band that runs from cell to cell, rounded where a week ends. */    &.calendar-day-range {      border-radius: 0;      background: var(--color-surface-chip);      color: var(--color-text-strong);      box-shadow: none;      &:first-child {        border-start-start-radius: calc(var(--calendar-day-radius) * var(--corner-scale));        border-end-start-radius: calc(var(--calendar-day-radius) * var(--corner-scale));      }      &:last-child {        border-start-end-radius: calc(var(--calendar-day-radius) * var(--corner-scale));        border-end-end-radius: calc(var(--calendar-day-radius) * var(--corner-scale));      }    }    /*     * The ends of a range: 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, so the band meets the day's middle and not its rounded edge.     */    &:is(.calendar-day-range-start, .calendar-day-range-end) {      --calendar-band: to right;      border-radius: 0;      background: linear-gradient(var(--calendar-band), transparent 50%, var(--color-surface-chip) 0);      color: var(--color-text-inverse);      box-shadow: none;      &::before {        content: '';        position: absolute;        inset: 0;        z-index: -1;        border-radius: calc(var(--calendar-day-radius) * var(--corner-scale));        corner-shape: var(--corner-shape);        background: var(--color-surface-inverse);        box-shadow: var(--shadow-ink-mark);      }      &:dir(rtl) {        --calendar-band: to left;      }    }    &.calendar-day-range-end {      --calendar-band: to left;      &:dir(rtl) {        --calendar-band: to right;      }    }    /* A range of one day has no band, and a range that ends a week does not run past it. */    &.calendar-day-range-start:is(.calendar-day-range-end, :last-child), &.calendar-day-range-end:first-child {      background: none;    }    &:focus-visible {      @apply --focus-ring;      z-index: 1;    }    &:is(:disabled, [aria-disabled="true"]) {      color: var(--color-text-disabled);      cursor: not-allowed;    }    @media (forced-colors: active) {      &:is([aria-selected="true"], [aria-pressed="true"], .calendar-day-range-start, .calendar-day-range-end) {        background: Highlight;        color: HighlightText;        forced-color-adjust: none;      }      &.calendar-day-range {        outline: 1px solid Highlight;        outline-offset: -1px;      }      &::before {        display: none;      }    }  }  @media (pointer: coarse) {    .calendar {      --calendar-cell: 2.75rem;    }  }}