HTML Calendars: 16 Examples, From Date Inputs to CSS Grid

Most pages that ask for a calendar only need a date field, and the browser ships one that submits the same yyyy-mm-dd value in every locale. A full month grid with navigation and events is a different story.

Below are 16 HTML calendar examples to try and copy, then a guide to date inputs, grids, keyboard support and .ics files.

By Bogdan Sandu · Updated October 27, 2026

Native date inputs, styled

The browser draws the picker, handles the keyboard and always submits an ISO value. These three demos style the field around it and add the rules the attributes cannot express, such as a minimum stay or closed weekends.

Date, month, week and datetime-local fields

  • input type=date
  • min / max
  • ::-webkit-calendar-picker-indicator

Export timesheets

Northgate Studio payroll, 2026

Four native types with min and max, styled with one rule set. The small line under each field prints the value the form would submit, which stays in ISO format whatever the visitor locale shows. Desktop Firefox and Safari have no month or week control, so those two fields fall back to text inputs, and the placeholder and pattern attributes keep that fallback usable.

View code
.ca-nf {
  box-sizing: border-box;
  width: min(100%, 580px);
  padding: 24px;
  border: 1px solid #e1e5ee;
  border-radius: 18px;
  background: #fff;
  color: #14171f;
  box-shadow: 0 12px 32px rgba(20, 23, 31, .08);
}
.ca-nf__title {
  margin: 0;
  font-size: 19px;
  font-weight: 700;
}
.ca-nf__sub {
  margin: 2px 0 20px;
  color: #535b6b;
  font-size: 14px;
}
.ca-nf__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
  gap: 18px 16px;
}
.ca-nf__field {
  display: grid;
  gap: 6px;
  min-width: 0;
}
.ca-nf__field label {
  font-size: 14px;
  font-weight: 600;
}
.ca-nf__field input {
  box-sizing: border-box;
  width: 100%;
  min-width: 0;
  height: 44px;
  margin: 0;
  padding: 0 12px;
  border: 1.5px solid #c5ccd8;
  border-radius: 10px;
  background: #fff;
  color: #14171f;
  font: inherit;
  font-size: 15px;
  color-scheme: light;
  accent-color: #0f766e;
  transition: border-color .15s ease;
}
.ca-nf__field input::-webkit-date-and-time-value {
  text-align: left;
}
.ca-nf__field input:hover {
  border-color: #8a94a6;
}
.ca-nf__field input:focus-visible {
  border-color: #0f766e;
  outline: 3px solid rgba(15, 118, 110, .3);
  outline-offset: 1px;
}
.ca-nf__field input::-webkit-calendar-picker-indicator {
  padding: 5px;
  border-radius: 6px;
  cursor: pointer;
  opacity: .7;
}
.ca-nf__field input::-webkit-calendar-picker-indicator:hover {
  background-color: #e3f2ef;
  opacity: 1;
}
@supports selector(:user-invalid) {
  .ca-nf__field input:user-invalid {
    border-color: #c92a2a;
  }
}
.ca-nf__val {
  color: #4b5565;
  font: 12px/1.4 ui-monospace, Consolas, monospace;
}
.ca-nf__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px 16px;
  margin-top: 22px;
}
.ca-nf__btn {
  min-height: 44px;
  padding: 0 20px;
  border: 0;
  border-radius: 10px;
  background: #0f766e;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .15s ease;
}
.ca-nf__btn:hover {
  background: #115e59;
}
.ca-nf__btn:focus-visible {
  outline: 3px solid #0f766e;
  outline-offset: 3px;
}
.ca-nf__status {
  margin: 0;
  color: #0f5b54;
  font-size: 14px;
}

Check-in and check-out pair

  • two date inputs
  • dynamic min
  • showPicker()

$148 per night

Larchwood Cabins, Lake Ruva

Stay dates

You will not be charged yet

Native HTML has no range control, so the pair is linked by script. Every change to check-in moves the check-out min to the next day and pushes an earlier check-out forward, and the summary counts nights with Math.round so a daylight saving shift cannot break the count. Clicking anywhere in the field calls showPicker() where it exists, wrapped in try and catch because it throws without a user gesture.

View code
.ca-sr {
  box-sizing: border-box;
  width: min(100%, 380px);
  container-type: inline-size;
  padding: 24px;
  border: 1px solid #e4e0db;
  border-radius: 20px;
  background: #fff;
  color: #1f1b16;
  box-shadow: 0 18px 40px rgba(31, 27, 22, .1);
}
.ca-sr__head {
  margin-bottom: 18px;
}
.ca-sr__price {
  margin: 0;
  font-size: 15px;
}
.ca-sr__price b {
  font-size: 24px;
}
.ca-sr__place {
  margin: 2px 0 0;
  color: #5c544b;
  font-size: 14px;
}
.ca-sr__dates {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  margin: 0;
  padding: 0;
  border: 1.5px solid #b9b1a7;
  border-radius: 14px;
  overflow: hidden;
}
.ca-sr__legend {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.ca-sr__cell {
  display: grid;
  gap: 2px;
  min-width: 0;
  padding: 10px 12px 8px;
}
.ca-sr__cell + .ca-sr__cell {
  border-left: 1.5px solid #b9b1a7;
}
.ca-sr__cell:focus-within {
  outline: 2px solid #1f1b16;
  outline-offset: -2px;
  border-radius: 12px;
}
.ca-sr__cell label {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .02em;
}
.ca-sr__cell input {
  box-sizing: border-box;
  width: 100%;
  min-width: 0;
  height: 30px;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: #1f1b16;
  font: inherit;
  font-size: 15px;
  color-scheme: light;
  cursor: pointer;
}
.ca-sr__cell input::-webkit-date-and-time-value {
  text-align: left;
}
.ca-sr__cell input:focus {
  outline: none;
}
.ca-sr__cell input::-webkit-calendar-picker-indicator {
  margin: 0;
  padding: 2px;
  cursor: pointer;
}
@container (max-width: 310px) {
  .ca-sr__cell {
    padding: 10px 8px 8px;
  }
  .ca-sr__cell input {
    font-size: 14px;
    letter-spacing: -.01em;
  }
}
.ca-sr__sum {
  min-height: 1.5em;
  margin: 16px 0;
  font-size: 15px;
  font-weight: 600;
}
.ca-sr__cta {
  width: 100%;
  min-height: 48px;
  border: 0;
  border-radius: 12px;
  background: #b5174f;
  color: #fff;
  font: inherit;
  font-size: 16px;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .15s ease;
}
.ca-sr__cta:hover {
  background: #92113f;
}
.ca-sr__cta:focus-visible {
  outline: 3px solid #b5174f;
  outline-offset: 3px;
}
.ca-sr__fine {
  margin: 10px 0 0;
  color: #5c544b;
  font-size: 13px;
  text-align: center;
}

Weekday-only delivery date

  • setCustomValidity()
  • aria-describedby
  • quick-pick chips

When should we deliver?

Pantry Run delivers Monday to Friday, 8:00 to 20:00. Closed on weekends and public holidays.

No attribute can block weekends or holidays, so a change listener checks the day and calls setCustomValidity with a reason. The same message is printed under the field and linked with aria-describedby, and the browser shows it again if someone tries to continue. The chips fill the next open days in one tap and mirror the field state with aria-pressed.

View code
.ca-dw {
  box-sizing: border-box;
  width: min(100%, 420px);
  padding: 26px 24px;
  border-radius: 20px;
  background: #fffdf7;
  color: #1c2a1e;
  box-shadow: 0 0 0 1px #e7e2d3, 0 16px 36px rgba(28, 42, 30, .1);
}
.ca-dw__title {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 24px;
  font-weight: 600;
  line-height: 1.2;
}
.ca-dw__sub {
  margin: 6px 0 18px;
  color: #4d5a4f;
  font-size: 14px;
}
.ca-dw__chips {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 8px;
  margin-bottom: 18px;
}
.ca-dw__chip {
  display: grid;
  min-height: 56px;
  padding: 6px 4px;
  border: 1.5px solid #cfd8c9;
  border-radius: 12px;
  background: #fff;
  color: #1c2a1e;
  font: inherit;
  font-size: 13px;
  line-height: 1.3;
  cursor: pointer;
  transition: border-color .15s ease, background-color .15s ease;
}
.ca-dw__chip b {
  font-size: 15px;
}
.ca-dw__chip:hover {
  border-color: #2b7a3d;
}
.ca-dw__chip[aria-pressed="true"] {
  border-color: #23683a;
  background: #23683a;
  color: #fff;
}
.ca-dw__chip:focus-visible,
.ca-dw__go:focus-visible {
  outline: 3px solid #23683a;
  outline-offset: 2px;
}
.ca-dw__label {
  display: block;
  margin-bottom: 6px;
  font-size: 14px;
  font-weight: 600;
}
.ca-dw__input {
  box-sizing: border-box;
  width: 100%;
  height: 46px;
  margin: 0;
  padding: 0 12px;
  border: 1.5px solid #b8c2b2;
  border-radius: 12px;
  background: #fff;
  color: #1c2a1e;
  font: inherit;
  font-size: 15px;
  color-scheme: light;
}
.ca-dw__input::-webkit-date-and-time-value {
  text-align: left;
}
.ca-dw__input:focus-visible {
  border-color: #23683a;
  outline: 3px solid rgba(35, 104, 58, .28);
  outline-offset: 1px;
}
.ca-dw__input:invalid {
  border-color: #b42318;
  background: #fff6f5;
}
.ca-dw__err {
  min-height: 1.4em;
  margin: 6px 0 14px;
  color: #b42318;
  font-size: 14px;
  font-weight: 600;
}
.ca-dw__go {
  width: 100%;
  min-height: 48px;
  border: 0;
  border-radius: 12px;
  background: #23683a;
  color: #fff;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .15s ease;
}
.ca-dw__go:hover {
  background: #1a522d;
}
.ca-dw__ok {
  margin: 10px 0 0;
  color: #23683a;
  font-size: 14px;
}
.ca-dw__ok:empty {
  display: none;
}

Month grids: table, CSS Grid and JavaScript

The same month built five ways, from a static table to a keyboard-driven date picker. The demos use a fixed date, Monday 26 October 2026, so the layout and the highlighted day stay the same on every visit.

Semantic table month

  • caption + th scope
  • abbr
  • aria-current="date"
October 2026
MoTuWeThFrSaSu
Payroll
Launch
Payroll
Open dayParty

A static month that needs no script: the caption names the grid, each weekday header carries scope="col" and its full name in abbr, and every day number sits in a time element with a machine-readable datetime. Today is marked with aria-current="date", and the weekend columns are styled with td:nth-child(n+6), which is safe here because table columns never shift. A print rule keeps the tints on paper.

View code
.ca-tmw {
  width: min(100%, 540px);
  container-type: inline-size;
}
.ca-tm {
  width: 100%;
  border-collapse: collapse;
  table-layout: fixed;
  background: #fffcf5;
  color: #2a211b;
  box-shadow: 0 1px 0 #e3d9c8, 0 18px 40px rgba(42, 33, 27, .12);
}
.ca-tm caption {
  padding: 22px 18px 14px;
  background: #fffcf5;
  text-align: left;
  border-bottom: 3px solid #2a211b;
}
.ca-tm__m {
  font-family: "Fraunces", Georgia, serif;
  font-size: 36px;
  font-weight: 600;
  line-height: 1;
}
.ca-tm__y {
  color: #9a3412;
  font-family: "Fraunces", Georgia, serif;
  font-size: 36px;
  font-weight: 300;
  line-height: 1;
}
.ca-tm th {
  padding: 10px 0 8px;
  color: #6b5b4e;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
}
.ca-tm td {
  height: 66px;
  padding: 6px 7px;
  border: 1px solid #e8dfcf;
  vertical-align: top;
  font-size: 15px;
  font-variant-numeric: tabular-nums;
}
.ca-tm td:nth-child(n+6) {
  background: #f6eee0;
  color: #8a3210;
}
.ca-tm td.ca-tm__pad {
  background: #fbf7ee;
}
.ca-tm td small {
  display: block;
  margin-top: 4px;
  overflow: hidden;
  color: #5a4a3e;
  font-size: 11px;
  line-height: 1.25;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ca-tm td[aria-current="date"] time {
  display: inline-grid;
  place-items: center;
  width: 28px;
  height: 28px;
  margin: -3px 0 0 -4px;
  border-radius: 50%;
  background: #9a3412;
  color: #fff;
  font-weight: 700;
}
@container (max-width: 420px) {
  .ca-tm__m,
  .ca-tm__y {
    font-size: 28px;
  }
  .ca-tm td {
    height: 50px;
    padding: 4px;
    font-size: 14px;
  }
  .ca-tm td small {
    font-size: 10px;
    white-space: normal;
    text-overflow: clip;
  }
}
@media print {
  .ca-tm {
    box-shadow: none;
  }
  .ca-tm td {
    height: 90px;
    border: 1px solid #000;
  }
  .ca-tm td:nth-child(n+6),
  .ca-tm td[aria-current="date"] time {
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
  }
}

CSS Grid month with grid-column-start

  • repeat(7, minmax(0, 1fr))
  • grid-column-start
  • custom property

October 2026

22 weekdays

The days are a plain ordered list of 31 items, and only the first one is placed: grid-column-start reads a custom property that holds the weekday of the 1st, so every other day flows into the right column on its own. October 2026 starts on a Thursday, which is column 4 in a Monday-first grid. Because the offset shifts the children, the weekend selectors use 7n + 3 and 7n + 4 for this month.

View code
.ca-gm {
  box-sizing: border-box;
  width: min(100%, 460px);
  padding: 26px 24px 24px;
  border: 2px solid #191919;
  border-radius: 6px;
  background: #f7f1e3;
  color: #191919;
  box-shadow: 8px 8px 0 #191919;
}
.ca-gm__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 12px;
  margin-bottom: 18px;
}
.ca-gm__month {
  margin: 0;
  font-size: 30px;
  font-weight: 800;
  letter-spacing: -.02em;
  line-height: 1;
}
.ca-gm__month span {
  color: #c2410c;
}
.ca-gm__meta {
  margin: 0;
  color: #4a4a4a;
  font-size: 14px;
}
.ca-gm__wd,
.ca-gm__days {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  margin: 0;
  padding: 0;
  list-style: none;
}
.ca-gm__wd {
  padding-bottom: 8px;
  border-bottom: 2px solid #191919;
  color: #4a4a4a;
  font-size: 13px;
  font-weight: 700;
  text-align: center;
}
.ca-gm__days {
  row-gap: 6px;
  padding-top: 10px;
  text-align: center;
}
.ca-gm__days li:first-child {
  grid-column-start: var(--ca-gm-start, 1);
}
.ca-gm__days time {
  display: inline-grid;
  place-items: center;
  width: min(42px, 100%);
  aspect-ratio: 1;
  border-radius: 50%;
  font-size: 17px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.ca-gm__days li:nth-child(7n + 3) time,
.ca-gm__days li:nth-child(7n + 4) time {
  color: #a23409;
}
.ca-gm__days .ca-gm__today time {
  background: #c2410c;
  color: #fff;
  box-shadow: 3px 3px 0 #191919;
}

JavaScript month with APG keyboard support

  • Intl.DateTimeFormat
  • roving tabindex
  • role=grid

No date chosen

Click a day, then try the arrow keys, Home, End, Page Up and Page Down (add Shift for a year).

The month is generated from two numbers, the weekday of the 1st and the day count, with createElement and textContent only. Month and weekday names come from Intl.DateTimeFormat in the visitor locale. Only the focused day has tabindex="0", so Tab enters the grid once, and the keys follow the APG date picker: Page Up and Page Down keep the day number and clamp to the last day of a shorter month. Today carries aria-current="date" and the chosen day carries aria-selected, two separate states.

View code
.ca-dp {
  box-sizing: border-box;
  width: min(100%, 360px);
  padding: 18px 18px 14px;
  border-radius: 18px;
  background: #fff;
  color: #18213d;
  box-shadow: 0 0 0 1px #e2e6f0, 0 20px 44px rgba(24, 33, 61, .14);
}
.ca-dp__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 8px;
}
.ca-dp__label {
  margin: 0;
  font-size: 17px;
  font-weight: 700;
}
.ca-dp__nav {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border: 1px solid #d5dbe8;
  border-radius: 12px;
  background: #fff;
  color: #18213d;
  cursor: pointer;
  transition: background-color .15s ease;
}
.ca-dp__nav:hover {
  background: #eef1f8;
}
.ca-dp__nav svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.ca-dp__grid {
  width: 100%;
  border-collapse: separate;
  border-spacing: 2px;
  table-layout: fixed;
}
.ca-dp__grid th {
  padding: 6px 0;
  color: #5a6482;
  font-size: 12px;
  font-weight: 700;
}
.ca-dp__grid td {
  height: 40px;
  padding: 0;
  border-radius: 10px;
  font-size: 15px;
  font-variant-numeric: tabular-nums;
  text-align: center;
}
.ca-dp__grid td[data-date] {
  cursor: pointer;
  transition: background-color .12s ease;
}
.ca-dp__grid td[data-date]:hover {
  background: #eef1f8;
}
.ca-dp__grid td[aria-current="date"] {
  box-shadow: inset 0 0 0 2px #c2410c;
  color: #a2360b;
  font-weight: 700;
}
.ca-dp__grid td[aria-selected="true"] {
  background: #2747b8;
  color: #fff;
  font-weight: 700;
}
.ca-dp__grid td[aria-selected="true"]:hover {
  background: #1f3a99;
}
.ca-dp__grid td:focus {
  outline: none;
}
.ca-dp__grid td:focus-visible {
  outline: 3px solid #18213d;
  outline-offset: 1px;
}
.ca-dp__nav:focus-visible,
.ca-dp__today:focus-visible {
  outline: 3px solid #2747b8;
  outline-offset: 2px;
}
.ca-dp__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid #e8ebf3;
}
.ca-dp__out {
  margin: 0;
  color: #3c4664;
  font-size: 13px;
}
.ca-dp__today {
  flex: none;
  min-height: 36px;
  padding: 0 14px;
  border: 0;
  border-radius: 10px;
  background: #eef1f8;
  color: #2747b8;
  font: inherit;
  font-size: 14px;
  font-weight: 700;
  cursor: pointer;
}

Date range selection

  • aria-pressed
  • pointerover preview
  • ::before band

November 2026

Click a start day, move the pointer, then click an end day.

The first click sets the start, the second sets the end, and while only the start exists, hovering or focusing a later day previews the band. The band is drawn by ::before on each cell, with half-width pieces on the two ends and rounded caps where a week row breaks, so it reads as one continuous strip. Start and end carry aria-pressed and the summary sits in a status region.

View code
.ca-rs {
  box-sizing: border-box;
  width: min(100%, 380px);
  padding: 20px 18px 18px;
  border-radius: 20px;
  background: #fff;
  color: #102a43;
  box-shadow: 0 0 0 1px #dfe6ee, 0 22px 48px rgba(16, 42, 67, .12);
}
.ca-rs__top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 10px;
}
.ca-rs__title {
  margin: 0;
  font-size: 18px;
  font-weight: 700;
}
.ca-rs__clear {
  min-height: 32px;
  padding: 0 12px;
  border: 0;
  border-radius: 8px;
  background: none;
  color: #0b5f8a;
  font: inherit;
  font-size: 14px;
  font-weight: 700;
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}
.ca-rs__wd,
.ca-rs__grid {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
}
.ca-rs__wd {
  margin: 0 0 4px;
  padding: 0;
  list-style: none;
  color: #52667a;
  font-size: 12px;
  font-weight: 700;
  text-align: center;
}
.ca-rs__grid {
  row-gap: 4px;
}
.ca-rs__day {
  position: relative;
  isolation: isolate;
  height: 44px;
  padding: 0;
  border: 0;
  background: none;
  color: #102a43;
  font: inherit;
  font-size: 15px;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
}
.ca-rs__day::before,
.ca-rs__day::after {
  content: "";
  position: absolute;
  z-index: -1;
}
.ca-rs__day::after {
  inset: 2px;
  margin: auto;
  max-width: 40px;
  border-radius: 50%;
}
.ca-rs__day:hover::after {
  background: #e3edf6;
}
.ca-rs__day.in-range::before {
  inset: 4px 0;
  background: #d7ebf7;
}
.ca-rs__day.in-range.is-preview::before {
  background: #e8f2f9;
}
.ca-rs__grid.has-end .ca-rs__day.is-start::before {
  inset: 4px 0 4px 50%;
  background: #d7ebf7;
}
.ca-rs__grid.has-end .ca-rs__day.is-end::before {
  inset: 4px 50% 4px 0;
  background: #d7ebf7;
}
.ca-rs__day.in-range.is-row-start::before {
  border-radius: 18px 0 0 18px;
}
.ca-rs__day.in-range.is-row-end::before {
  border-radius: 0 18px 18px 0;
}
.ca-rs__day.is-start,
.ca-rs__day.is-end {
  color: #fff;
  font-weight: 700;
}
.ca-rs__day.is-start::after,
.ca-rs__day.is-end::after {
  background: #0b5f8a;
}
.ca-rs__day.is-end.is-preview::after {
  background: #3d7ea3;
}
.ca-rs__day:focus-visible {
  outline: none;
}
.ca-rs__day:focus-visible::after {
  box-shadow: 0 0 0 3px #102a43;
}
.ca-rs__clear:focus-visible {
  outline: 3px solid #0b5f8a;
  outline-offset: 2px;
}
.ca-rs__out {
  min-height: 1.5em;
  margin: 12px 0 0;
  padding-top: 12px;
  border-top: 1px solid #e6edf3;
  font-size: 14px;
  font-weight: 600;
}

Year at a glance heatmap

  • 12 mini grids
  • data-level colors
  • role=img summaries
Studio bookings, 2026

Twelve small month grids instead of one 53-column strip, so the year folds from six columns down to two on a phone without scrolling. Each square gets a data-level from 0 to 4 and one solid color per level, days after today stay hollow, and every month is exposed as a single image with a total in its label rather than 365 separate announcements. The demo data comes from a seeded generator, so it is identical on every load.

View code
.ca-yh {
  box-sizing: border-box;
  width: min(100%, 860px);
  margin: 0;
  padding: 22px 22px 18px;
  border-radius: 16px;
  background: #fff;
  color: #1a1d2e;
  box-shadow: 0 0 0 1px #e3e6ef, 0 14px 34px rgba(26, 29, 46, .08);
}
.ca-yh__cap {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 12px;
  margin-bottom: 18px;
}
.ca-yh__title {
  font-size: 17px;
  font-weight: 700;
}
.ca-yh__total {
  color: #4b5168;
  font-size: 14px;
  font-variant-numeric: tabular-nums;
}
.ca-yh__months {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 112px), 1fr));
  gap: 16px 18px;
}
.ca-yh__m {
  min-width: 0;
}
.ca-yh__name {
  margin: 0 0 6px;
  color: #3d4257;
  font-size: 12px;
  font-weight: 700;
}
.ca-yh__cells {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  gap: 3px;
}
.ca-yh__cells span,
.ca-yh__legend i {
  aspect-ratio: 1;
  border-radius: 3px;
  background: #e8ebf3;
}
.ca-yh [data-level="1"] {
  background: #c3cbf3;
}
.ca-yh [data-level="2"] {
  background: #8b9be8;
}
.ca-yh [data-level="3"] {
  background: #4f62d6;
}
.ca-yh [data-level="4"] {
  background: #26308f;
}
.ca-yh [data-level="f"] {
  background: none;
  box-shadow: inset 0 0 0 1px #e1e4ec;
}
.ca-yh .is-today {
  outline: 2px solid #1a1d2e;
  outline-offset: 1px;
}
.ca-yh__legend {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 4px;
  margin-top: 18px;
  color: #4b5168;
  font-size: 12px;
}
.ca-yh__legend i {
  width: 12px;
}

Event, schedule and booking calendars

Calendars that carry data: event pills with an overflow popover, a week of time slots placed on grid rows, a sidebar mini month, appointment availability and a habit tracker that counts with CSS alone.

Event calendar with pills and a +2 more popover

  • popover
  • anchor positioning
  • container queries

October 2026

  • Work
  • Team
  • Personal
    • Sprint planning
    • Design crit
    • Dentist
    • Payroll run
    • 1:1 with Priya
    • Atlas 2.0 launch
    • Press call

    Wednesday, October 14

    • Atlas 2.0 launch
    • Press call
    • Launch retro
    • Team dinner
    • Yoga
    • Board prep
    • Webinar
    • Payroll run
    • Standup
    • Client review

    Monday, October 26

    • Standup
    • Client review
    • Vendor call
    • Gym
    • Offsite
    • Costume party

Each day shows two events at most, and the rest sit behind a +2 more button that opens a popover with the full list. The popover lives in the top layer, so the clipped grid cannot cut it off, and browsers with anchor positioning place it under its button while others center it. Under 560px of container width the pills shrink to colored dots, but their text stays in the accessibility tree.

View code
.ca-em {
  width: 100%;
  max-width: 880px;
  container-type: inline-size;
  color: #1b2030;
}
.ca-em__bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 8px 16px;
  margin-bottom: 12px;
}
.ca-em__title {
  margin: 0;
  font-size: 22px;
  font-weight: 700;
}
.ca-em__legend {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 14px;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: 13px;
}
.ca-em__key {
  display: flex;
  align-items: center;
  gap: 6px;
}
.ca-em__key::before {
  content: "";
  width: 10px;
  height: 10px;
  border-radius: 3px;
}
.ca-em__key--work::before {
  background: #3b5bdb;
}
.ca-em__key--team::before {
  background: #e8590c;
}
.ca-em__key--life::before {
  background: #2f9e44;
}
.ca-em__wd,
.ca-em__grid {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  margin: 0;
  padding: 0;
  list-style: none;
}
.ca-em__wd {
  padding: 0 0 6px;
  color: #535a6e;
  font-size: 12px;
  font-weight: 700;
  text-align: center;
}
.ca-em__grid {
  gap: 1px;
  overflow: hidden;
  border: 1px solid #dde1ea;
  border-radius: 14px;
  background: #dde1ea;
}
.ca-em__grid > li {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
  min-height: 104px;
  padding: 6px;
  background: #fff;
}
.ca-em__grid > li > time {
  font-size: 13px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.ca-em__grid > .ca-em__out {
  background: #f6f7fa;
  color: #6a7184;
}
.ca-em__today > time {
  display: inline-grid;
  place-items: center;
  align-self: flex-start;
  min-width: 24px;
  height: 24px;
  border-radius: 12px;
  background: #1b2030;
  color: #fff;
}
.ca-em__evs {
  display: grid;
  gap: 3px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.ca-em__pill {
  overflow: hidden;
  padding: 2px 6px;
  border-radius: 5px;
  font-size: 12px;
  font-weight: 600;
  line-height: 1.4;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ca-em__pill--work {
  background: #dbe4ff;
  color: #1e3a8a;
}
.ca-em__pill--team {
  background: #ffe8cc;
  color: #7c2d12;
}
.ca-em__pill--life {
  background: #d3f9d8;
  color: #14532d;
}
.ca-em__more {
  align-self: flex-start;
  min-height: 24px;
  padding: 0 6px;
  border: 0;
  border-radius: 5px;
  background: none;
  color: #2c3550;
  font: inherit;
  font-size: 12px;
  font-weight: 700;
  cursor: pointer;
}
.ca-em__more:hover {
  background: #eef0f5;
}
.ca-em__more:focus-visible,
.ca-em__pop-x:focus-visible {
  outline: 2px solid #3b5bdb;
  outline-offset: 1px;
}
.ca-em__pop {
  box-sizing: border-box;
  width: min(280px, calc(100vw - 32px));
  margin: auto;
  padding: 14px 16px;
  border: 0;
  border-radius: 14px;
  background: #fff;
  color: #1b2030;
  box-shadow: 0 0 0 1px #dde1ea, 0 18px 40px rgba(27, 32, 48, .2);
}
.ca-em__pop-day {
  margin: 0 0 8px;
  font-size: 15px;
  font-weight: 700;
}
.ca-em__pop-list {
  display: grid;
  gap: 6px;
  margin: 0 0 10px;
  padding: 0;
  list-style: none;
  font-size: 14px;
}
.ca-em__row {
  padding-left: 10px;
  border-left: 4px solid #3b5bdb;
}
.ca-em__row--team {
  border-left-color: #e8590c;
}
.ca-em__row--life {
  border-left-color: #2f9e44;
}
.ca-em__row time {
  display: inline-block;
  min-width: 3.2em;
  color: #535a6e;
  font-variant-numeric: tabular-nums;
}
.ca-em__pop-x {
  min-height: 32px;
  padding: 0 12px;
  border: 1px solid #cfd4e0;
  border-radius: 8px;
  background: #fff;
  color: #1b2030;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
@supports (position-area: bottom) {
  @media (min-width: 700px) {
    .ca-em__pop {
      inset: auto;
      margin: 4px 0 0;
      position-area: bottom span-right;
      position-try-fallbacks: flip-inline, flip-block;
    }
  }
}
@container (max-width: 560px) {
  .ca-em__grid > li {
    align-items: center;
    min-height: 58px;
    padding: 5px 2px;
  }
  .ca-em__today > time {
    align-self: center;
  }
  .ca-em__evs {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 3px;
  }
  .ca-em__pill {
    width: 7px;
    height: 7px;
    padding: 0;
    border-radius: 50%;
    font-size: 0;
  }
  .ca-em__pill--work {
    background: #3b5bdb;
  }
  .ca-em__pill--team {
    background: #e8590c;
  }
  .ca-em__pill--life {
    background: #2f9e44;
  }
  .ca-em__more {
    align-self: center;
    padding: 0 4px;
    font-size: 11px;
  }
  .ca-em__more-w {
    display: none;
  }
}

Week view with time slots on grid rows

  • grid-row: start / end
  • sticky day header
  • scrollable region

Week of October 26

Design team

8:00 9:00 10:00 11:00 12:00 13:00 14:00 15:00 16:00 17:00

Monday, Standup

Monday, Client review

Monday, Vendor call

Tuesday, Standup

Tuesday, Design review

Tuesday, Focus time

Wednesday, Standup

Wednesday, Hiring panel

Wednesday, Lunch and learn

Thursday, Standup

Thursday, Roadmap workshop

Friday, Retro

Friday, Demo day

Every grid row is 30 minutes, so an event from 10:00 to 11:30 is simply grid-row: 5 / 8 in its day column, and overlap-free layout comes for free. The hour lines are a hard-stop background sized to two rows. The schedule scrolls inside a focusable region with a label, the day header stays outside the scroll, and each event carries a visually hidden weekday so it makes sense when read on its own. Under 520px of container width the view drops to three days, the way phone calendar apps do.

View code
.ca-wk {
  box-sizing: border-box;
  width: min(100%, 820px);
  padding: 18px 16px 0;
  border-radius: 18px;
  background: #fff;
  color: #1d2433;
  box-shadow: 0 0 0 1px #e1e5ec, 0 18px 40px rgba(29, 36, 51, .1);
  container-type: inline-size;
}
.ca-wk__top {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 12px;
  margin-bottom: 12px;
}
.ca-wk__title {
  margin: 0;
  font-size: 18px;
  font-weight: 700;
}
.ca-wk__meta {
  margin: 0;
  color: #525c70;
  font-size: 13px;
}
.ca-wk__head,
.ca-wk__grid {
  display: grid;
  grid-template-columns: 44px repeat(5, minmax(0, 1fr));
  column-gap: 3px;
}
.ca-wk__head {
  padding-bottom: 8px;
  border-bottom: 1px solid #e1e5ec;
  color: #525c70;
  font-size: 12px;
  text-align: center;
}
.ca-wk__head b {
  display: block;
  color: #1d2433;
  font-size: 18px;
}
.ca-wk__head .ca-wk__today,
.ca-wk__head .ca-wk__today b {
  color: #c92a2a;
}
.ca-wk__scroll {
  height: 340px;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.ca-wk__scroll:focus-visible {
  outline: 3px solid #1d2433;
  outline-offset: -3px;
}
.ca-wk__grid {
  grid-template-rows: repeat(20, 28px);
  padding: 8px 0 12px;
  background-image: linear-gradient(#e7eaf0 1px, transparent 1px);
  background-size: 100% 56px;
  background-position: 0 8px;
}
.ca-wk__hr {
  grid-column: 1;
  margin-top: -8px;
  color: #5d6678;
  font-size: 11px;
  font-variant-numeric: tabular-nums;
}
.ca-wk__col {
  grid-row: 1 / -1;
  border-left: 1px solid #eef0f4;
}
.ca-wk__ev {
  z-index: 1;
  display: flex;
  flex-direction: column;
  min-width: 0;
  margin: 1px 0;
  padding: 3px 6px;
  overflow: hidden;
  border-left: 3px solid;
  border-radius: 6px;
  font-size: 12px;
  line-height: 1.3;
}
.ca-wk__ev b {
  overflow: hidden;
  text-overflow: ellipsis;
}
.ca-wk__ev time {
  color: inherit;
  font-size: 11px;
  opacity: .85;
}
.ca-wk__ev--sync {
  flex-direction: row;
  gap: 6px;
  border-color: #6741d9;
  background: #ece6ff;
  color: #3b1f8f;
}
.ca-wk__ev--client {
  border-color: #c2255c;
  background: #ffe3ec;
  color: #7f1239;
}
.ca-wk__ev--craft {
  border-color: #1971c2;
  background: #dcecfb;
  color: #0b3d6e;
}
.ca-wk__ev--focus {
  border-color: #5c940d;
  background: #e9f5d6;
  color: #2f4f06;
}
.ca-wk__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.ca-wk__now {
  position: relative;
  z-index: 2;
  align-self: start;
  height: 2px;
  margin-top: 9px;
  background: #c92a2a;
}
.ca-wk__now::before {
  content: "";
  position: absolute;
  top: -4px;
  left: -5px;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: #c92a2a;
}
@container (max-width: 520px) {
  .ca-wk__head,
  .ca-wk__grid {
    grid-template-columns: 38px repeat(3, minmax(0, 1fr));
  }
  .ca-wk__late {
    display: none;
  }
  .ca-wk__ev time {
    display: none;
  }
}

Appointment booking with available and full days

  • disabled day buttons
  • aria-pressed slots
  • container query

November 2026

  • Open
  • Few left
  • Full

Northside Physio, 45 min assessment

Pick an open day, then a time.

Days that cannot be booked are real disabled buttons, so they drop out of the tab order, and their reason is in the accessible name as well as the hatching and strike-through. Choosing an open day builds its slot list, and the confirm button only becomes active once a time is picked. Calendar and slots sit side by side when the container is wide and stack when it is not.

View code
.ca-bk {
  box-sizing: border-box;
  width: min(100%, 760px);
  container-type: inline-size;
  padding: 22px;
  border-radius: 20px;
  background: #fff;
  color: #13262f;
  box-shadow: 0 0 0 1px #dce5e8, 0 20px 44px rgba(19, 38, 47, .1);
}
.ca-bk__inner {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 22px;
}
@container (min-width: 620px) {
  .ca-bk__inner {
    grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
    gap: 28px;
  }
}
.ca-bk__cal,
.ca-bk__side {
  min-width: 0;
}
.ca-bk__title {
  margin: 0 0 6px;
  font-size: 18px;
  font-weight: 700;
}
.ca-bk__legend {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 14px;
  margin: 0 0 12px;
  padding: 0;
  list-style: none;
  color: #3f5560;
  font-size: 12px;
}
.ca-bk__lg {
  display: flex;
  align-items: center;
  gap: 6px;
}
.ca-bk__lg::before {
  content: "";
  width: 8px;
  height: 8px;
  border-radius: 50%;
}
.ca-bk__lg--open::before {
  background: #0c8a6c;
}
.ca-bk__lg--few::before {
  background: #d9480f;
}
.ca-bk__lg--full::before {
  border: 1px solid #8a9aa2;
  background: #e9eef0;
}
.ca-bk__wd,
.ca-bk__days {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  gap: 4px;
}
.ca-bk__wd {
  margin: 0 0 4px;
  padding: 0;
  list-style: none;
  color: #3f5560;
  font-size: 11px;
  font-weight: 700;
  text-align: center;
}
.ca-bk__d {
  position: relative;
  height: 42px;
  padding: 0;
  border: 1px solid #cfdcdf;
  border-radius: 10px;
  background: #fff;
  color: #13262f;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  transition: border-color .15s ease, background-color .15s ease;
}
.ca-bk__d::after {
  content: "";
  position: absolute;
  bottom: 5px;
  left: 50%;
  width: 5px;
  height: 5px;
  margin-left: -2.5px;
  border-radius: 50%;
  background: #0c8a6c;
}
.ca-bk__d--few::after {
  background: #d9480f;
}
.ca-bk__d:hover:not(:disabled) {
  border-color: #0c6e57;
}
.ca-bk__d[aria-pressed="true"] {
  border-color: #0c6e57;
  background: #0c6e57;
  color: #fff;
}
.ca-bk__d[aria-pressed="true"]::after {
  background: #fff;
}
.ca-bk__d:disabled {
  border-color: #e1e8ea;
  background-color: #f3f6f7;
  color: #5f6f76;
  font-weight: 400;
  cursor: not-allowed;
}
.ca-bk__d:disabled::after {
  display: none;
}
.ca-bk__d--full {
  background-image: repeating-linear-gradient(135deg, transparent 0 5px, #e3e9eb 5px 6px);
  text-decoration: line-through;
}
.ca-bk__who {
  margin: 0;
  color: #3f5560;
  font-size: 13px;
}
.ca-bk__date {
  margin: 4px 0 12px;
  font-size: 18px;
  font-weight: 700;
}
.ca-bk__slots {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
  gap: 8px;
}
.ca-bk__slot {
  min-height: 44px;
  border: 1.5px solid #0c6e57;
  border-radius: 10px;
  background: #fff;
  color: #0a5644;
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  transition: background-color .15s ease;
}
.ca-bk__slot:hover {
  background: #e6f4f0;
}
.ca-bk__slot[aria-pressed="true"] {
  background: #0c6e57;
  color: #fff;
}
.ca-bk__go {
  width: 100%;
  min-height: 48px;
  margin-top: 16px;
  border: 0;
  border-radius: 12px;
  background: #13262f;
  color: #fff;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.ca-bk__go:disabled {
  background: #dfe6e8;
  color: #4f6068;
  cursor: not-allowed;
}
.ca-bk__d:focus-visible,
.ca-bk__slot:focus-visible,
.ca-bk__go:focus-visible {
  outline: 3px solid #13262f;
  outline-offset: 2px;
}
.ca-bk__out {
  margin: 10px 0 0;
  color: #0a5644;
  font-size: 14px;
  font-weight: 600;
}
.ca-bk__out:empty {
  display: none;
}

Dark habit tracker that counts with CSS

  • checkbox days
  • CSS counters
  • :has(:focus-visible)

Morning run

October 2026

Days with a morning run in October 2026

of 26 days so far

Tick or untick any past day.

Each day is a real checkbox inside its label, so it toggles with a click or the Space key and needs no script. A CSS counter increments on every :checked box and prints the total after the grid, which is why the summary must come later in the source than the boxes. Future days are disabled, today has a ring, and the focus ring is drawn on the label through :has(:focus-visible).

View code
.ca-ht {
  box-sizing: border-box;
  width: min(100%, 400px);
  padding: 22px 20px 20px;
  border: 1px solid #222a3a;
  border-radius: 20px;
  background: #121826;
  color: #e7ecf5;
  counter-reset: ca-ht-done;
  color-scheme: dark;
}
.ca-ht__top {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 12px;
  margin-bottom: 16px;
}
.ca-ht__title {
  margin: 0;
  font-size: 20px;
  font-weight: 700;
}
.ca-ht__month {
  margin: 0;
  color: #a6b0c3;
  font-size: 14px;
}
.ca-ht__wd,
.ca-ht__grid {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  gap: 6px;
}
.ca-ht__wd {
  margin: 0 0 6px;
  padding: 0;
  list-style: none;
  color: #8f9ab0;
  font-size: 11px;
  font-weight: 700;
  text-align: center;
}
.ca-ht__grid {
  margin: 0;
  padding: 0;
  border: 0;
  min-width: 0;
}
.ca-ht__legend {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.ca-ht__day:first-of-type {
  grid-column-start: 4;
}
.ca-ht__day {
  position: relative;
  display: grid;
  place-items: center;
  aspect-ratio: 1;
  min-height: 34px;
  border: 1px solid #2c3548;
  border-radius: 10px;
  background: #182032;
  color: #c9d2e3;
  font-size: 14px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  transition: background-color .15s ease, border-color .15s ease;
}
.ca-ht__day input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: inherit;
}
.ca-ht__day input:checked {
  counter-increment: ca-ht-done;
}
.ca-ht__day:hover {
  border-color: #4a5670;
}
.ca-ht__day:has(:checked) {
  border-color: #34d399;
  background: #34d399;
  color: #052e1f;
}
.ca-ht__day:has(:disabled) {
  border-style: dashed;
  background: none;
  color: #8792a8;
  cursor: not-allowed;
}
.ca-ht__day--today {
  box-shadow: 0 0 0 2px #121826, 0 0 0 4px #fbbf24;
}
.ca-ht__day:has(:focus-visible) {
  outline: 3px solid #93c5fd;
  outline-offset: 2px;
}
.ca-ht__sum {
  margin: 18px 0 0;
  padding-top: 14px;
  border-top: 1px solid #222a3a;
  color: #a6b0c3;
  font-size: 15px;
}
.ca-ht__count::before {
  content: counter(ca-ht-done);
  color: #34d399;
  font-size: 22px;
  font-weight: 800;
}

Mobile layouts and calendar files

Small screens need a list, not a squeezed grid. A phone agenda, a month that turns into an agenda through a container query, and an Add to calendar button that builds a real .ics file in the browser.

Mobile agenda with a week strip

  • position: sticky
  • scrollTo()
  • aria-current day

October 2026

Today, Monday 26

StandupRoom 4B

Lunch with MaraOlive Tree Cafe

Climbing gymBoulder Barn

Tuesday 27

Design reviewVideo call

DentistHarbor Dental, 2nd floor

Wednesday 28

Nothing planned

Thursday 29

Team offsiteRiverside Hall, all day

Book clubInes, 14 Elm Street

Friday 30

Payroll cutoffFinance

Five-a-sideNorthfield pitches

Saturday 31

Costume partyThe Lantern Rooms

Sunday 1

Long runCanal path, 16 km

Tap a day in the strip, or scroll the list.

On a phone the month grid becomes a week strip plus a scrolling list grouped by day, with each day header sticking to the top while its events pass under it. Tapping a day scrolls the list with scrollTo on the list itself, so the page around it never jumps, and smooth scrolling is used only when reduced motion is not requested. Scrolling the list updates the pressed day in the strip.

View code
.ca-pa {
  display: flex;
  flex-direction: column;
  height: 100%;
  background: #f7f7fb;
  color: #17172a;
}
.ca-pa__top {
  flex: none;
  padding: 10px 14px 12px;
  border-bottom: 1px solid #e4e4ee;
  background: #fff;
}
.ca-pa__month {
  margin: 0 0 10px;
  font-size: 22px;
  font-weight: 800;
  letter-spacing: -.01em;
}
.ca-pa__strip {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  gap: 4px;
}
.ca-pa__day {
  display: grid;
  justify-items: center;
  gap: 2px;
  min-height: 52px;
  padding: 6px 0;
  border: 0;
  border-radius: 14px;
  background: none;
  color: #17172a;
  font: inherit;
  cursor: pointer;
  transition: background-color .15s ease;
}
.ca-pa__day span {
  color: #5c5c75;
  font-size: 11px;
  font-weight: 600;
}
.ca-pa__day b {
  font-size: 17px;
  font-variant-numeric: tabular-nums;
}
.ca-pa__day[aria-current="date"] b {
  color: #5b3fd6;
}
.ca-pa__day[aria-pressed="true"] {
  background: #17172a;
}
.ca-pa__day[aria-pressed="true"] span,
.ca-pa__day[aria-pressed="true"] b {
  color: #fff;
}
.ca-pa__day:focus-visible {
  outline: 2px solid #5b3fd6;
  outline-offset: 1px;
}
.ca-pa__list {
  position: relative;
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.ca-pa__list::after {
  content: "End of this week";
  display: block;
  padding: 8px 14px 320px;
  color: #5c5c75;
  font-size: 13px;
  text-align: center;
}
.ca-pa__list:focus-visible {
  outline: 3px solid #5b3fd6;
  outline-offset: -3px;
}
.ca-pa__sec {
  padding: 0 14px 6px;
}
.ca-pa__h {
  position: sticky;
  top: 0;
  z-index: 1;
  margin: 0 -14px 6px;
  padding: 10px 14px 6px;
  background: #f7f7fb;
  color: #45455e;
  font-size: 13px;
  font-weight: 700;
}
.ca-pa__ev {
  display: grid;
  grid-template-columns: 48px minmax(0, 1fr);
  column-gap: 10px;
  margin: 0 0 8px;
  padding: 10px 12px;
  border-left: 4px solid #3b5bdb;
  border-radius: 12px;
  background: #fff;
  box-shadow: 0 1px 2px rgba(23, 23, 42, .06);
}
.ca-pa__ev time {
  grid-row: span 2;
  color: #45455e;
  font-size: 13px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.ca-pa__ev b {
  font-size: 15px;
}
.ca-pa__ev span {
  color: #5c5c75;
  font-size: 13px;
}
.ca-pa__ev--rose {
  border-left-color: #c2255c;
}
.ca-pa__ev--green {
  border-left-color: #2b8a3e;
}
.ca-pa__ev--amber {
  border-left-color: #e67700;
}
.ca-pa__none {
  margin: 0 0 8px;
  padding: 12px;
  border: 1px dashed #c9c9d8;
  border-radius: 12px;
  color: #5c5c75;
  font-size: 14px;
}

Month grid that turns into an agenda

  • container-type
  • @container
  • :has() empty days

December 2026

  1. Tue

  2. Wed

  3. Thu

    Team offsite

  4. Fri

  5. Sat

  6. Sun

  7. Mon

  8. Tue

    Invoice run

  9. Wed

  10. Thu

  11. Fri

    Holiday party

  12. Sat

  13. Sun

  14. Mon

  15. Tue

    Code freeze

  16. Wed

  17. Thu

  18. Fri

    Year in review

  19. Sat

  20. Sun

  21. Mon

  22. Tue

  23. Wed

  24. Thu

    Office closes at 13:00

  25. Fri

    Office closed

  26. Sat

  27. Sun

  28. Mon

  29. Tue

  30. Wed

  31. Thu

    Office closed

On a wide screen, drag the corner handle to make the calendar narrower.

The same list of 31 days renders as a 7-column month when its container is wide and as an agenda when it is narrow. Under 480px the weekday header hides, every day without an event is removed with li:not(:has(.ca-cq__ev)), and each remaining day shows its weekday name, which the grid view hides. Because it is a container query, the calendar switches inside a narrow sidebar too, not only on small screens.

View code
.ca-cq {
  width: 100%;
  max-width: 820px;
}
.ca-cq__frame {
  box-sizing: border-box;
  width: 100%;
  min-width: 240px;
  max-width: 100%;
  padding: 18px;
  overflow: auto;
  resize: horizontal;
  border: 1px solid #d9dde3;
  border-radius: 16px;
  background: #fff;
  color: #20262e;
  container-type: inline-size;
}
.ca-cq__title {
  margin: 0 0 12px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 24px;
  font-weight: 600;
}
.ca-cq__head,
.ca-cq__days {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  margin: 0;
  padding: 0;
  list-style: none;
}
.ca-cq__head {
  padding-bottom: 6px;
  color: #59616c;
  font-size: 12px;
  font-weight: 700;
  text-align: center;
}
.ca-cq__days {
  gap: 1px;
  padding: 1px;
}
.ca-cq__days li {
  min-width: 0;
  min-height: 74px;
  padding: 6px;
  outline: 1px solid #e3e6ea;
  background: #fff;
}
.ca-cq__days li:first-child {
  grid-column-start: 2;
}
.ca-cq__d {
  margin: 0;
  font-size: 13px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.ca-cq__wd {
  display: none;
}
.ca-cq__ev {
  margin: 4px 0 0;
  padding: 3px 6px;
  border-radius: 5px;
  background: #fff0db;
  color: #6b3b00;
  font-size: 12px;
  font-weight: 600;
  line-height: 1.3;
}
@container (max-width: 480px) {
  .ca-cq__head {
    display: none;
  }
  .ca-cq__days {
    display: block;
    padding: 0;
  }
  .ca-cq__days li:not(:has(.ca-cq__ev)) {
    display: none;
  }
  .ca-cq__days li {
    display: grid;
    grid-template-columns: 56px minmax(0, 1fr);
    align-items: center;
    gap: 12px;
    min-height: 0;
    padding: 10px 0;
    outline: 0;
    border-bottom: 1px solid #eceef1;
  }
  .ca-cq__d {
    display: grid;
    justify-items: center;
    padding: 4px 0;
    border-radius: 10px;
    background: #f3f4f6;
    font-size: 18px;
    line-height: 1.1;
  }
  .ca-cq__wd {
    display: block;
    color: #59616c;
    font-size: 11px;
    font-weight: 700;
  }
  .ca-cq__ev {
    margin: 0;
    padding: 8px 10px;
    font-size: 14px;
  }
}

Add to calendar with a generated .ics file

  • Blob
  • RFC 5545 text
  • a[download]

Upcoming in Portugal

  • Nov 28

    Harbor Lights Festival

    Saturday, all day. Ribeira riverside, Porto

  • Dec 5

    Winter Craft Market

    10:00 to 16:00 Lisbon time. Ribeira Hall, Lisbon

  • Dec 31

    Fireworks Cruise on the Tagus

    22:30 to 00:30 Lisbon time. Cais do Sodre pier

Preview the .ics text
Press Add to calendar to build a file.

Press Add to calendar. The file is built in your browser, and the preview shows its text.

The button builds an iCalendar file from fixed data with CRLF line endings, escaped commas and semicolons, and lines folded at 75 octets, then hands it to a temporary a[download] link through a Blob URL that is revoked right after. The all-day festival uses VALUE=DATE with an exclusive end date, and the timed events use UTC, so no VTIMEZONE block is needed. Nothing leaves the page, and the filename is reduced to letters, digits and hyphens.

View code
.ca-ic {
  box-sizing: border-box;
  width: min(100%, 560px);
  padding: 22px 20px 18px;
  border-radius: 18px;
  background: #fff;
  color: #13233a;
  box-shadow: 0 0 0 1px #dfe5ee, 0 18px 40px rgba(19, 35, 58, .1);
}
.ca-ic__title {
  margin: 0 0 12px;
  font-size: 19px;
  font-weight: 700;
}
.ca-ic__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.ca-ic__item {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 14px;
  padding: 14px 0;
  border-top: 1px solid #e8edf3;
}
.ca-ic__badge {
  display: grid;
  flex: none;
  place-items: center;
  width: 52px;
  height: 56px;
  margin: 0;
  border-radius: 12px;
  background: #13233a;
  color: #fff;
  line-height: 1;
}
.ca-ic__badge span {
  color: #ffd8a8;
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
}
.ca-ic__badge b {
  margin-top: -10px;
  font-size: 21px;
}
.ca-ic__body {
  flex: 1 1 180px;
  min-width: 0;
}
.ca-ic__name {
  margin: 0;
  font-size: 16px;
  font-weight: 700;
}
.ca-ic__when {
  margin: 2px 0 0;
  color: #4a5a70;
  font-size: 13px;
}
.ca-ic__add {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  margin-left: auto;
  padding: 0 16px;
  border: 1.5px solid #13233a;
  border-radius: 999px;
  background: #fff;
  color: #13233a;
  font: inherit;
  font-size: 14px;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .15s ease, color .15s ease;
}
.ca-ic__add:hover {
  background: #13233a;
  color: #fff;
}
.ca-ic__add:focus-visible,
.ca-ic__peek summary:focus-visible {
  outline: 3px solid #d9480f;
  outline-offset: 2px;
}
.ca-ic__status {
  min-height: 1.4em;
  margin: 6px 0 10px;
  color: #2b6a3e;
  font-size: 14px;
  font-weight: 600;
}
.ca-ic__peek summary {
  min-height: 32px;
  color: #13233a;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.ca-ic__pre {
  max-height: 220px;
  margin: 8px 0 0;
  padding: 12px;
  overflow: auto;
  border-radius: 10px;
  background: #0f1b2d;
  color: #d7e3f4;
  font: 12px/1.5 ui-monospace, Consolas, monospace;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

How Can You Build an HTML Calendar?

Everything here renders from native elements, CSS and a little JavaScript, so no external calendar application gets involved. What changes from method to method is how much the browser does for you. With the native date input it does nearly all of it. A static table gives you markup and nothing else. Past that you’re writing your own CSS Grid and JavaScript, or handing the job to a library such as FullCalendar or Flatpickr. The first two need nothing beyond plain HTML.

MethodJavaScriptEffortBest for
Native date inputNoOne lineForm fields, booking dates
Static HTML tableNoLowOne fixed month, printed schedules
CSS Grid with JavaScriptYesMediumMonth view with navigation
Library or widgetYesFast setup, slow to restyleEvent calendars, date ranges

Pick the simplest method that meets the requirement. A date input beats a custom grid whenever the visitor only needs to choose one date.

Reaching for a library first is the usual mistake. It adds weight to a page that a single input element would have served.

The native date input needs no JavaScript, and the browser handles the popup and keyboard entry. The downside is that the picker looks different in each browser, and it can’t show event markers.

A static table works everywhere without a script and prints cleanly. You get one table per month and no navigation, and the dates are typed by hand or generated on the server.

A CSS Grid with JavaScript hands you full control over markup, layout and behavior, and any month on demand. Date logic and localization become your job, and so does accessibility, which is a lot to own.

A library comes with tested date logic and locale support, plus event APIs. The cost is extra page weight and style overrides, along with upgrade work down the line.

Flatpickr covers date picking, while a Bootstrap datepicker plugin suits projects already built on that framework. Event-heavy calendars are where a full library earns its weight.

Adding a Native Date Picker With the Date Input Type

The markup is one input with type=”date” and a label. The browser supplies the popup and the keyboard handling, and it shows dates in the visitor’s locale.

Whatever the field displays, the submitted value is always yyyy-mm-dd (the ISO 8601 date format), according to MDN Web Docs. A US visitor sees the month first and a German visitor sees the day first. Either way the server receives the same string.

Always attach a label element. That is the baseline of accessible forms, and the date input has no visible text of its own.

Date, Month, Week and Datetime-Local Inputs

Input typeValue formatPicks
date2026-10-12One day
month2026-10One month and year
week2026-W42One week and year
datetime-local2026-10-12T14:30Day and time, no time zone

Billing periods and timesheets are where month and week pickers make sense. Bookings usually call for date or datetime-local.

Min, Max and Step Attributes

The min attribute sets the earliest accepted date and max sets the latest. Step controls the granularity in days, with a default of 1.

Dates outside the min and max range are disabled in the picker (except on iOS Safari, which ignores min, max and step on date inputs, per caniuse), and the form shows an error if someone submits one anyway (MDN Web Docs). Set step to 7 with a min that falls on a Saturday and only Saturdays can be selected.

In practice the bigger limit is that no attribute can block individual dates, holidays for example.

Browser Differences You Will See

caniuse counts 97.24% of global browser usage as supporting the date and time input types, 90.36% fully and 6.88% partially (StatCounter usage data, checked October 26, 2026).

Partial support is flagged on desktop Safari and desktop Firefox, plus Firefox for Android. Safari on iOS reaches full support from version 18.2.

The popup design depends on the browser and the operating system. caniuse groups all 5 types in one row, so test month and week separately. If you want decoration such as validity icons, put it on a sibling element, because Chrome places generated content inside the control.

The date input itself has been available across browsers since April 2021 and is Baseline widely available, per MDN. A short round of cross-browser compatibility testing on iOS Safari and desktop Firefox catches most surprises.

Marking Up a Month Grid With the HTML Table Element

A static calendar is a single table. It has a caption naming the month, a header row of weekday names and a body of week rows, and nothing needs a script to display.

The caption holds the month and year, which also titles the grid for assistive technology. The header row takes 7 th cells with scope=”col”, one per weekday. The tbody then gets 6 tr rows of 7 td cells, and each day number sits inside a time element with a datetime attribute such as 2026-10-12.

Six rows are needed because a 31-day month that starts on a Saturday spans six weeks in a Sunday-first layout. The html-calendar Python package documentation says the same thing, that every table it generates has 6 rows, even when the last one is empty.

Cells before the 1st and after the last day stay as empty td elements. A class on them lets CSS grey out the gap.

MDN describes the datetime attribute as a machine-readable date that supports features such as reminders. Day cells carry the yyyy-mm-dd form.

Header cells with scope attributes and a caption are the building blocks of accessible tables, and a calendar grid needs both.

This approach suits a printed schedule or a page where scripts are blocked, and it’s fine for a single fixed month such as an event date. It falls apart once you want month navigation, since each month is another table and a year takes 12. Highlighting today doesn’t work either, unless the server or a script marks the cell.

The table only covers the markup, so the dates have to come from JavaScript.

How to Generate a Calendar Month With JavaScript

Generating a month takes two numbers, the weekday of the 1st and the number of days in the month. The rest is a loop that fills rows of 7 columns.

The Date object in plain JavaScript returns both values, so no library is required.

  1. Store the target year and month in two variables, defaulting to today.
  2. Get the weekday of the 1st with new Date(year, month, 1).getDay().
  3. Get the day count with new Date(year, month + 1, 0).getDate().
  4. Render the weekday header cells in the order the calendar starts its week.
  5. Render empty cells up to the offset, then days 1 through the day count, then empty cells to finish the last row.
  6. Wire the previous and next buttons to change the month and re-render, rolling December into January of the next year.

Date Object Pitfalls

MDN documents getMonth() as a zero-based value from 0 to 11, so October is 9.

Passing 0 as the day, as in new Date(year, month + 1, 0), returns the last day of the month, and leap years take care of themselves (February 2028 returns 29). Rollover works in your favor too. new Date(2026, 12, 1) lands on January 1, 2027, so month navigation arithmetic is safe.

Weeks are where people slip. getDay() returns 0 for Sunday, so a Monday-first grid uses the offset (getDay() + 6) % 7.

Parsing bites just as often. Build dates from numbers, not from yyyy-mm-dd strings, because date-only strings parse as UTC and can shift the day in time zones behind it.

Most broken calendars trace back to one of these four.

Localized Names With Intl.DateTimeFormat

A hard-coded month array works for one language. Intl.DateTimeFormat, available across browsers since September 2017 and Baseline widely available (MDN Web Docs), returns names for any locale.

With month set to “long”, the locale en-US gives February and de-DE gives Februar. The same call with weekday set to “short” fills the header row.

Month and weekday names follow the locale, but the first day of the week doesn’t, so set it explicitly.

Styling an HTML Calendar With CSS Grid

A 7-column CSS Grid puts the weekday headers and the day cells on the same tracks, so one rule lays out the whole month: grid-template-columns: repeat(7, minmax(0, 1fr)).

Use minmax(0, 1fr) instead of a bare 1fr. A plain 1fr column will not shrink below the width of its content, so one long event label can push the grid past the edge of the screen.

CSS Flexbox handles a single week strip well. A full month needs rows and columns together, which is the job Grid was built for. Utility-first projects can build the same grid as a Tailwind calendar.

Highlighting Today and Weekends

For today, have JavaScript add a class such as “today” to the cell whose date matches the current day.

Weekends can stay in CSS. In a Sunday-first grid, nth-child(7n+1) and nth-child(7n) hit Sunday and Saturday, and in a Monday-first grid nth-child(7n+6) and nth-child(7n) hit Saturday and Sunday. The leading and trailing cells from neighboring months get a muted color and no hover state.

The selectors count children of the grid container, so they assume the 7 weekday headers sit in that same container.

Tinted backgrounds still need enough color contrast against the day number. Test the highlighted cell, not just the plain one.

Keeping Cells Usable on Narrow Screens

WCAG 2.2 success criterion 2.5.8 (Level AA) sets the minimum pointer target at 24 by 24 CSS pixels, according to W3C’s Understanding documentation.

A 320 px screen with 16 px side gutters leaves 288 px, about 41 px per column before gaps. The day cells clear the minimum, so the weak spot is usually the previous and next buttons.

To fit the space, abbreviate weekday names to one or two letters and swap event text for a dot or a count. Below a certain breakpoint, drop the grid and show a plain list of the month’s events instead.

The breakpoint switch is a job for media queries, which can hide the grid and reveal the list.

W3C also lists an exemption worth knowing. The calendar inside a native date input counts as a user agent control, so it is excepted as long as the author leaves its default size alone.

How Do You Make an HTML Calendar Accessible?

Use the native date input where it fits. A custom grid has to rebuild keyboard movement and focus handling, plus every screen reader label, from scratch.

The W3C ARIA Authoring Practices Guide (APG) publishes a date picker dialog example built on its grid pattern, last updated 12 August 2025. W3C warns that the example is not production code and that it needs testing with assistive technologies.

KeyFocus moves to
Left, RightPrevious day, next day
Up, DownSame weekday in the previous or next week
Home, EndFirst or last day of the current week
Page Up, Page DownSame day number in the previous or next month
Shift + Page Up, Shift + Page DownSame day number in the previous or next year

When the target month has no matching day number (Page Up from March 31, for example), focus lands on the last day of that month. The APG spells this case out, and it is easy to miss in a custom grid.

The example layers a handful of ARIA roles and states on top of the table markup. The table gets role=”grid” and is named through aria-labelledby. A roving tabindex gives one day cell tabindex=”0″ and the rest -1, so Tab enters the grid once. Two-letter weekday headers carry the complete name in the abbr attribute, and aria-live=”polite” on the month heading announces month changes.

Today and selected are separate states. MDN lists aria-current=”date” for the current date in a calendar, while the APG example marks only the chosen date with aria-selected and leaves aria-current out of its attribute table.

MDN adds two rules. Only one element in a set should be marked current, and aria-current should never stand in for aria-selected on a gridcell. Use both attributes, on different cells.

Libraries copy the keyboard map but not every detail. The react-datepicker README lists the same arrow, Page Up, Page Down, Home and End behavior, yet it closes the calendar on Tab, where the APG dialog keeps Tab moving inside the dialog.

Before shipping, work through a checklist for web accessibility and run a pass with a screen reader.

Adding Events, Google Calendar Embeds and .ics Files to an HTML Calendar

Events attach to a day cell through a date key. Store them as a JSON array whose dates use the same yyyy-mm-dd string as each time element, then render a marker in the matching cell.

Event Markers From JSON Data

Each event is one object with a date and a title, plus an optional url. Show a dot or a count per day, and put the full list under the grid on small screens. Give the marker text for screen readers, such as “2 events”.

Fetching the array through an asynchronous Ajax request keeps the page itself static and lets each month load only its own events.

The same fields feed Event structured data on each event’s own page. Google’s Search Central documentation requires name, startDate and a location with an address.

Google supports only pages that focus on a single event, so a calendar page listing many events should not carry the markup.

The same documentation cites Eventbrite, which saw a 100% increase in its typical year-over-year growth of traffic from Google Search through the event experience.

Embedding Google Calendar

Google’s embed customizer asks for a width and a height of at least 500 each. Getting to it takes a few clicks, and only on desktop, because the mobile app cannot produce the code.

  1. Open Settings.
  2. Pick the calendar’s name.
  3. Choose Integrate calendar.
  4. Select Customize under Embed code.

The calendar shows only to people it is shared with, so make it public for anonymous visitors. With the navigation buttons switched off, visitors can change months only through keyboard shortcuts. An Add to Google Calendar link sits at the bottom left of the embed.

The embed is an iframe, a separate document, so your own CSS cannot restyle its cells. That is the price of zero date logic.

WordPress sites place the code differently, through the block editor or a calendar plugin.

Offering an iCalendar Download

iCalendar, defined in RFC 5545 (IETF, September 2009), is the file format calendar apps import to exchange events independent of any calendar service.

Date formExampleMeaning
FloatingDTSTART:19970714T133000Same wall-clock time in every time zone
UTCDTSTART:19970714T173000ZOne absolute moment
Named zoneDTSTART;TZID=America/New_York:19970714T133000Local time bound to a time zone definition

A named zone needs a matching VTIMEZONE component inside the file. Leave it out and different apps read the local time differently.

All-day events use VALUE=DATE with the date written as 8 digits and no hyphens (19970714), unlike the hyphenated yyyy-mm-dd that HTML uses.

Content lines end with a CRLF sequence, and long lines are folded. Generate the file on the server, or build it in the browser from a Blob, and serve it as a download from an “Add to calendar” link.

When Does a Hand-Built HTML Calendar Stop Working?

It stops working when events need recurrence rules, time zone conversion, drag and drop, or several views. Below that line, table cells and a few dozen lines of JavaScript hold up fine.

Failure Modes of a Custom Calendar

Recurring events are the first wall. RFC 5545 defines recurrence rules with 14 rule parts, and only FREQ is required. “Last working day of the month” takes this rule:

FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1

Impossible dates such as February 30 must be skipped.

Time zones come next. The react-datepicker README says its native Date objects display in the visitor’s local zone and that the library has no conversion tools. It points to date-fns-tz for that job.

Interaction is another gap. FullCalendar documents event dragging and resizing, popovers and virtual rendering as separate features, and a hand-built grid has none of them.

Then there’s upkeep. Browser updates and new locales land on you, and so does every accessibility regression.

If none of the four applies, stay hand-built.

Library Options and Their Fit

LibraryBest fitNotes
FullCalendarEvent calendars with month, week and list viewsv7.1.3 released October 16, 2026; Timeline and resource views are Premium
FlatpickrDate and time picking on form fieldsNo dependencies, MIT license
react-datepickerDate picking in React appsNative Date objects, date-fns for locales, MIT license
jQuery UI DatepickerLegacy jQuery projectsTied to a text input; inline, multi-month and range demos
PikadayNothing newArchived August 11, 2025

Pikaday no longer belongs on the shortlist, even though older tutorials still recommend it. Its maintainers archived the repository, noting that it started before input type date was supported and is probably not the right choice today.

Date math inside a hand-built grid does not need a full calendar library. Day.js weighs 2kB, with a largely Moment.js-compatible API and immutable objects, per the Day.js site.

date-fns is the other common pick. react-datepicker switched to it from Moment.js at version 2.0.0, according to its README, to cut package size.

HTML Calendar FAQ

Is there a calendar element in HTML?

No. HTML has no calendar tag.

The closest tools are the input element with type date, month or week, which opens a browser picker, and the table element, which lays out a static month grid.

Can a native date input block weekends or specific dates?

Not through attributes. The min, max and step attributes limit the range and spacing, but none of them can exclude holidays or weekends.

Check the chosen date in a change listener and call setCustomValidity with an error message. Repeat the check on the server, since MDN warns that client-side validation is not a substitute.

Can native HTML pick a date range?

Not with a single control. Use two date inputs, one for the start and one for the end.

A short script sets the end input’s min attribute to the start value whenever it changes, so the browser rejects an end date that falls before the start.

How do you print an HTML calendar?

Add a print stylesheet that hides the previous and next buttons and gives every cell a visible border, with a fixed row height.

Browsers skip background colors by default, so set print-color-adjust to exact when weekend tints must appear on paper.

How do you add week numbers to an HTML calendar?

Add a leading column, change the grid rule to repeat(8, minmax(0, 1fr)), and compute the ISO week for each row.

RFC 5545 defines week number one as the first week with at least 4 days in the new year. ISO 8601 weeks start on Monday, so Monday-first rows match that numbering.

How do you pre-fill a date input with today’s date?

Build the yyyy-mm-dd string from the local year, month and day, then assign it to the input’s value property.

Avoid valueAsDate with new Date(). It works in UTC, so near midnight it can fill in the neighboring day.

Where can you find free HTML calendar templates?

Tutorial sites such as GeeksforGeeks publish copy-paste table and CSS calendars. HCAL, a form-based generator, returns a month table that you then edit by hand.

Check each one for a caption and scope attributes on the header cells before reusing it.

What to Test Before Launching an HTML Calendar

Before launch, check keyboard and screen reader use, locale output, time zone handling and the browsers you target. The order matters.

Start with the keyboard and screen reader on the bare markup, since markup defects cost least to fix before styling and locale data exist. Then run locale and time zone checks with real event data, and finish with a browser and device matrix down to 320 px.

Embeds need a separate check. Google’s customizer sets a minimum of 500 for width and height, which exceeds the 288 px content width of a 320 px screen, so a pasted embed needs a width rule.

The trade-off is manual effort that a maintained library’s test suite would absorb. This position was verified on October 26, 2026, and the checks should run again whenever a calendar library ships a new major version, as FullCalendar did with v7.