Верстка календаря на HTML и CSS — это семь колонок с днями недели и четыре-шесть строк с числами. Разметку держит элемент table: месяц — это табличные данные, а не декор. CSS отвечает только за вид, JavaScript не нужен, пока календарь не листает месяцы. Ниже — готовый код календаря-месяца, нативный выбор даты для формы и замер каждого числа в headless Chromium: где верстка держится, а где рассыпается.
Коротко по делу:
- семь равных колонок даёт одна строка
table-layout: fixed— CSS Grid для этого не нужен; display: grid, поставленный прямо наtable, ломает раскладку: у таблицы всего три прямых потомка, и в семь колонок раскладываются они, а не дни;- календарь для формы — это два элемента:
labelиinput type="date", с проверкой диапазона на стороне браузера; - сегодняшний день помечают атрибутом
aria-current="date", а выбранный в date-picker —aria-selected: это разные вещи; - весь код ниже прогнан в браузере, и каждое число в статье — из этого прогона, а не из головы.
Три задачи, которые называют версткой календаря

Под одним запросом прячутся три разные задачи, и код для них тоже разный. Прежде чем копировать чужой сниппет, стоит понять, в какой вы колонке.
| Что нужно на странице | Чем верстать | Нужен ли JavaScript | Когда не подойдёт |
|---|---|---|---|
| Показать месяц или расписание: витрина занятий, график работы, афиша | table с caption, th scope="col" и time datetime |
Нет | Если пользователь должен листать месяцы |
| Дать выбрать дату в форме заявки | Нативный input type="date" |
Нет | Если нужен свой внешний вид поля или выбор диапазона |
| Свой календарь выбора даты поверх формы | table с role="grid", JS и элемент dialog |
Да | Если хватает нативного поля — тогда это лишние 150 строк |
Первая задача закрывается чистым HTML и CSS — с неё и начнём. Если календарь нужен вам самим для планирования, а не сайту, верстка не нужна вовсе: хватит готового пространства вроде Notion с его базами и календарным видом или собранного за день ИИ-ассистента для персонального планирования.


- ПОКАЖЕМ, КАК РАЗВЕРНУТЬ МОДЕЛЬ нейросети DEEPSEEK R1 ПРЯМО НА СВОЁМ КОМПЬЮТЕРЕ
- Где и как применять? Потестируем модель после установки на разных задачах
- Как дообучить модель под себя?
Таблица или div с CSS Grid: чем размечать календарь
Спор «table или div» в этой теме решается не модой, а определением. MDN описывает элемент так: «The table HTML element represents tabular data — that is, information presented in a two-dimensional table comprised of rows and columns of cells containing data». Месяц — это ровно оно: строки-недели, колонки-дни недели, в ячейке дата.
Учебник W3C WAI формулирует ту же мысль с другой стороны: «Data tables are used to organize data with a logical relationship in grids» — и отдельно предупреждает, что для раскладки таблицы не предназначены: «As a general rule, tables aren’t meant to be used for layout purposes». Обратное правило тоже работает: если данные табличные, не надо изображать таблицу дивами.
Сильнее всего это видно в эталонном примере самого W3C. Их паттерн Date Picker Dialog из ARIA Authoring Practices Guide построен на элементе table с ролью grid: полные названия дней вынесены в атрибут abbr заголовков колонок, а выбранная дата помечена aria-selected="true". То есть организация, которая пишет спецификацию ARIA, календарь верстает таблицей.
Что видит скринридер: замер дерева доступности
Разговоры о семантике легко проверить. Мы собрали четыре варианта разметки одного и того же календаря и сняли роли из дерева доступности через протокол CDP в Chromium 145.
| Разметка | Роли, которые получает браузер |
|---|---|
table с обычным display |
table, caption, rowgroup, row, columnheader, cell |
table с display: grid |
table, caption, rowgroup, row, columnheader, cell |
div + grid + роли table/row/cell |
table, row, columnheader, cell |
div + grid без ролей |
generic, generic, none, none |
Два вывода, и оба против расхожих утверждений. Первое: display: grid на таблице в Chromium 145 семантику не убивает — роли остались на месте, флаг ignored у всех узлов равен false. Так что пугать «grid сломает доступность таблицы» на этом браузере не за что. Второе: сетка из дивов без ARIA-ролей отдаёт вспомогательным технологиям generic и none — ни таблицы, ни строк, ни ячеек. Именно так свёрстаны календари у большинства статей в выдаче.
Вывод короткий: берите table — вы получаете правильные роли бесплатно. Если разметка обязана быть на дивах, роли придётся выписать руками, и тогда их будет ровно столько, сколько вы не забудете.
Готовая верстка календаря на HTML: разметка месяца
Скелет календаря для сайта — это подпись, шапка с днями недели и тело с числами. Три вещи, которые делают его не просто сеткой квадратов:
caption— название месяца. W3C WAI: «A caption identifies the overall topic of a table and is useful in most situations». Заголовок внутри таблицы, а неh3рядом с ней, потому что он относится к данным;th scope="col"у дней недели — связывает колонку с её заголовком. Сокращения раскрываются черезabbr title, так же как в эталонном примере W3C;time datetimeу каждого числа — машиночитаемая дата. Для «12» в ячейке браузер и парсер видят 2026-09-12, а не просто число.
Пустые ячейки в начале и конце месяца остаются пустыми td: это дни соседних месяцев, у них нет данных, и подсовывать туда числа не нужно.
<table class="cal">
<caption>Сентябрь 2026</caption>
<thead>
<tr>
<th scope="col"><abbr title="Понедельник">Пн</abbr></th>
<th scope="col"><abbr title="Вторник">Вт</abbr></th>
<th scope="col"><abbr title="Среда">Ср</abbr></th>
<th scope="col"><abbr title="Четверг">Чт</abbr></th>
<th scope="col"><abbr title="Пятница">Пт</abbr></th>
<th scope="col"><abbr title="Суббота">Сб</abbr></th>
<th scope="col"><abbr title="Воскресенье">Вс</abbr></th>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td><time datetime="2026-09-01">1</time></td>
<td><time datetime="2026-09-02">2</time></td>
<td><time datetime="2026-09-03">3</time></td>
<td><time datetime="2026-09-04">4</time></td>
<td><time datetime="2026-09-05">5</time></td>
<td><time datetime="2026-09-06">6</time></td>
</tr>
<tr>
<td><time datetime="2026-09-07">7</time></td>
<td><time datetime="2026-09-08">8</time></td>
<td><time datetime="2026-09-09">9</time></td>
<td><time datetime="2026-09-10">10</time></td>
<td><time datetime="2026-09-11">11</time></td>
<td><time datetime="2026-09-12">12</time></td>
<td><time datetime="2026-09-13">13</time></td>
</tr>
<tr>
<td><time datetime="2026-09-14">14</time></td>
<td aria-current="date"><time datetime="2026-09-15">15</time></td>
<td><time datetime="2026-09-16">16</time></td>
<td><time datetime="2026-09-17">17</time></td>
<td><time datetime="2026-09-18">18</time></td>
<td><time datetime="2026-09-19">19</time></td>
<td><time datetime="2026-09-20">20</time></td>
</tr>
<tr>
<td><time datetime="2026-09-21">21</time></td>
<td><time datetime="2026-09-22">22</time></td>
<td><time datetime="2026-09-23">23</time></td>
<td><time datetime="2026-09-24">24</time></td>
<td><time datetime="2026-09-25">25</time></td>
<td><time datetime="2026-09-26">26</time></td>
<td><time datetime="2026-09-27">27</time></td>
</tr>
<tr>
<td><time datetime="2026-09-28">28</time></td>
<td><time datetime="2026-09-29">29</time></td>
<td><time datetime="2026-09-30">30</time></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
Разметка сама по себе уже работает: браузер выведет сетку 7 на 5, а скринридер объявит «таблица, сентябрь 2026, столбец Понедельник». Дальше — только внешний вид.
Верстка календаря на CSS: семь равных колонок
Главный вопрос раскладки — как заставить колонки быть одинаковыми. У таблицы для этого есть собственный механизм, и он короче любой сетки: table-layout: fixed вместе с width: 100%. Браузер перестаёт подгонять колонки под содержимое и делит ширину поровну.
Замер: в контейнере 700 px все семь колонок получились по 100 px ровно, ячейка дня — 100 на 46 px. Уберите table-layout: fixed и напишите вместо «Пн» слово «Понедельник» — колонки разъедутся: 145,09 / 89,56 / 64,98 / 84,91 / 91,88 / 87,52 / 136,06 px. Это и есть причина, по которой дни недели сокращают.
.cal {
--cal-accent: #6d28d9;
width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
font: 1rem/1 system-ui, sans-serif;
}
.cal caption {
padding-bottom: .75rem;
font-size: 1.125rem;
font-weight: 700;
}
.cal th {
padding: .25rem 0 .5rem;
font-size: .75rem;
font-weight: 600;
color: #6b7280;
}
.cal th abbr { text-decoration: none; }
.cal td {
height: 2.75rem;
text-align: center;
border-radius: .5rem;
}
.cal tr:has([aria-current="date"]) { background: #f5f3ff; }
.cal td[aria-current="date"] {
background: var(--cal-accent);
color: #fff;
font-weight: 700;
}
Тематизация — одна переменная --cal-accent на корне календаря: меняете её значение и получаете календарь под цвета сайта, не трогая остальные правила. Воздух между ячейками, если он нужен, даёт border-spacing, а не gap: при border-spacing: 4px в том же контейнере 700 px колонки становятся по 95,42 px, а таблица остаётся таблицей.
Что показал прогон кода в headless Chromium

Код из этой статьи мы собрали в страницу и прогнали в headless Chromium 145.0.7632.6 через Playwright: скрипт открывает календарь, снимает геометрию ячеек и роли из дерева доступности. Ниже — таблица того, что получилось у разных способов уложить те же семь колонок. Сцена во всех строках одна: контейнер шириной 700 px.
| Правило CSS | Ширины колонок, px | Ячейка дня, px | Итог |
|---|---|---|---|
table-layout: fixed |
100 у всех семи | 100 × 46 | То, что нужно |
table-layout: auto, полные названия дней |
от 64,98 до 145,09 | плавает | Колонки разъезжаются |
border-spacing: 4px поверх fixed |
95,42 (последняя 95,47) | 95,42 × 46 | Воздух есть, таблица цела |
display: grid на самой table |
от 12,42 до 15,95 | 19,25 × 46 | Раскладка рассыпалась |
display: grid на каждой tr |
96,56 и 96,58 | 96,56 × 46 | Работает, но зачем |
Строка про display: grid на таблице — самая полезная. Причину видно в том же прогоне: у элемента table ровно три прямых потомка — caption, thead и tbody. В семь колонок сетка раскладывает именно их, а не 35 ячеек таблицы. Высота таблицы при этом падает с 284 до 230 px, колонки схлопываются до 12–16 px, и календарь превращается в узкую полоску. Ошибка тихая: браузер ничего не сообщает, страница просто выглядит сломанной.
Отсюда практическое правило. CSS Grid в календаре полезен для обвязки — расположить шапку с кнопками месяцев, сам календарь и боковую панель событий. Внутри месяца сетка не нужна: таблица уже сетка, и её собственная раскладка справляется одной строкой.
Календарь для формы: нативное поле выбора даты
Если календарь нужен только для того, чтобы человек выбрал дату в заявке, верстать его не надо вообще. В HTML есть поле input type="date", которое рисует календарь силами браузера и операционной системы. MDN описывает поведение так: «The value is normalized to the format yyyy-mm-dd. The resulting value includes the year, month, and day, but not the time». По статусу Baseline на MDN это поле «available across browsers since April 2021».
<label for="visit-date">Дата визита</label>
<input
type="date"
id="visit-date"
name="visit"
min="2026-09-01"
max="2026-09-30"
value="2026-09-15"
required>
Что дал прогон этих девяти строк в Chromium 145: после ввода поле отдало value строкой 2026-09-15, а на дате 2026-10-05 метод checkValidity() вернул false — браузер сам не пустил дату за пределы max, без единой строки JavaScript. В дереве доступности элемент получил роль Date, то есть скринридер объявляет его как поле даты, а не как безымянный текстовый ввод.
Что вы получаете даром: раскладку месяца на языке системы, ввод с клавиатуры, календарь-выпадашку на десктопе и системный барабан дат на смартфоне, валидацию диапазона и правильную роль. Что теряете: контроль над внешним видом поля — стилизуется оно скудно и по-разному в разных браузерах. Это и есть развилка. Нужен строгий бренд-вид или выбор диапазона «с — по» — придётся строить свой date-picker, о нём ниже.
Сегодняшний день и выбранный день: это разные атрибуты
Подсветить сегодня цветом мало: цвет видит только зрячий пользователь. Состояние нужно записать в разметку. MDN перечисляет допустимые значения дословно: «The aria-current attribute accepts a limited list of values including page, step, location, date, time, true, and false», и про нужное нам значение: «date — Represents the current date within a collection of dates such as the current date within a calendar».
<td aria-current="date"><time datetime="2026-09-15">15</time></td>
Дальше цепляемся стилями за сам атрибут, а не за служебный класс. Подсветить заодно всю неделю помогает :has() — по данным MDN этот селектор в статусе Baseline «available across browsers since December 2023».
.cal tr:has([aria-current="date"]) { background: #f5f3ff; }
.cal td[aria-current="date"] {
background: var(--cal-accent);
color: #fff;
font-weight: 700;
}
Замер подтверждает: у строки с сегодняшним днём вычисленный фон rgb(245, 243, 255), у соседних строк — rgba(0, 0, 0, 0), у самой ячейки rgb(109, 40, 217). Одна важная оговорка из той же документации MDN: «Don’t use aria-current as a substitute for aria-selected in gridcell, option, row or tab». То есть в интерактивном календаре-выборе дата, которую человек выбрал, помечается aria-selected="true", а aria-current="date" остаётся за сегодняшним числом. Это два разных состояния, и на одной ячейке они могут стоять одновременно.
Наведение, фокус и занятые дни
Календарь-витрина не должен ловить фокус: в замере страницы с кодом из этой статьи фокусируемых элементов ровно 0, и человек с клавиатурой пролетает таблицу насквозь, как и положено тексту. Кнопки появляются только тогда, когда по дню действительно кликают, — и ставить их надо внутрь td, не вместо него.
.cal td button {
width: 100%;
height: 100%;
border: 0;
background: none;
font: inherit;
cursor: pointer;
transition: background 120ms ease-out;
}
.cal td button:hover { background: #f3f4f6; }
.cal td button:focus-visible {
outline: 2px solid var(--cal-accent);
outline-offset: -2px;
}
.cal td[data-state="busy"] button {
color: #9ca3af;
text-decoration: line-through;
pointer-events: none;
}
Ключевая строка тут — :focus-visible. Замер показывает разницу: после перехода клавишей Tab у кнопки дня вычисленный цвет обводки равен rgb(109, 40, 217) при толщине 2px, а после клика мышью обводки нет вовсе. Так подсветка появляется у того, кому она нужна, и не мешает остальным. Занятость слота живёт в атрибуте data-state, а стиль цепляется за него: состояние хранится в разметке, а не в наборе классов, которые надо не забыть снять.
Адаптивность: календарь на узком экране
Хорошая новость: у таблицы с width: 100% и table-layout: fixed адаптивность встроенная. Замер в контейнере 360 px: колонки стали по 51,42 px (последняя 51,47 из-за округления), ячейка дня — 51,42 на 46 px. Ни одного медиазапроса при этом не написано. Свойство height: 2.75rem задаёт 44 px, в замере ячейка вышла 46 px — этого хватает, чтобы попасть пальцем, не целясь.
Медиазапросы нужны для другого — сократить шапку и подписи, когда колонка становится совсем узкой:
@media (max-width: 380px) {
.cal { font-size: .875rem; }
.cal th { font-size: .625rem; }
.cal td { height: 2.25rem; }
}
Почему @container из старых руководств молчит
В прошлой версии этой статьи стоял совет уменьшать ячейки правилом @container (max-width: 480px). Мы прогнали тот код и получили ноль эффекта: в контейнере шириной 400 px свойство grid-auto-rows так и осталось minmax(44px, 1fr), а abbr сохранил display: block, хотя правило внутри @container велело его спрятать.
Причина в пропущенной строке. MDN: «To use container size queries, you need to declare a containment context on an element… To do this, use the container-type property with a value of size, inline-size, or normal». Вычисленное значение container-type у родителя в том коде было normal — то есть контекста не объявили, и запрос не к чему было применять. Правильно так:
.calendar { container-type: inline-size; }
@container (max-width: 480px) {
.cal td { height: 2rem; }
}
Урок общий: контейнерный запрос без container-type не выдаёт ошибку, он просто ничего не делает. Проверять такие правила надо вычисленным стилем, а не глазами.
Ещё одна тихая ошибка: селектор, который никогда не срабатывает
Там же жил селектор .day[today] для подсветки текущего числа. В разметке той статьи атрибута today не было ни у одной кнопки, и прогон это показывает буквально: document.querySelectorAll('.day[today]') вернул 0 элементов, подсвеченных кнопок — тоже 0. Правило было написано, цвет объявлен, результата нет. Отсюда и требование к любому сниппету из интернета: прежде чем вставлять его в проект, откройте страницу и проверьте, что нужный элемент действительно нашёлся.
Свой date-picker: когда без JavaScript не обойтись
Свой календарь выбора даты нужен в трёх случаях: диапазон «с — по», занятые и свободные слоты в одной сетке, жёсткий бренд-вид поля. Всё остальное дешевле закрыть нативным полем. Если случай ваш — не изобретайте поведение, возьмите готовый паттерн Date Picker Dialog из ARIA Authoring Practices Guide: там описаны роль grid на таблице, перемещение фокуса стрелками при одном tabindex="0" на всю сетку и пометка выбранной даты через aria-selected="true".
Оболочкой служит элемент dialog: по статусу Baseline на MDN он «available across browsers since March 2022» и берёт на себя модальность.
<button type="button" id="pick-open" aria-haspopup="dialog">Выбрать дату</button>
<dialog id="pick" aria-label="Выбор даты">
<table class="cal" role="grid" aria-label="Сентябрь 2026">
<!-- та же разметка месяца, но ячейки интерактивные:
<td role="gridcell" tabindex="-1" aria-selected="false">15</td> -->
</table>
<form method="dialog"><button>Готово</button></form>
</dialog>
const dlg = document.getElementById('pick');
document.getElementById('pick-open')
.addEventListener('click', () => dlg.showModal());
Что даёт showModal() без единой строки вашего кода — снова по замеру в Chromium 145: свойство open становится true, фокус уезжает внутрь диалога (проверка dialog.contains(document.activeElement) вернула true), кнопка под фоном перестаёт принимать клики — elementFromPoint в её координатах возвращает сам диалог, а клавиша Escape закрывает окно и возвращает open в false. Три поведения, которые в самописных всплывашках чаще всего забывают.
JavaScript в таком календаре отвечает ровно за две вещи: перерисовать месяц и обновить состояние ячеек. Разметка и стили остаются теми же, что в разделах выше, — это и есть смысл разделения слоёв.
Чек-лист верстки календаря
| Пункт | Что должно быть |
|---|---|
| Разметка | table с caption, днями недели в th scope="col" и датами в time datetime |
| Колонки | table-layout: fixed и width: 100% — семь равных долей без CSS Grid |
| Пустые дни | Пустые td в начале и конце месяца, без подставных чисел |
| Сегодня | aria-current="date" на ячейке, стиль цепляется за атрибут |
| Выбор | Форма — нативный input type="date"; свой picker — role="grid" и aria-selected |
| Мобильный | Проверена ширина колонки и высота ячейки на 360 px |
| Тема | Цвет вынесен в переменную --cal-accent |
| Проверка | Каждый селектор реально что-то находит — проверено в браузере, а не на глаз |
Календарь-витрина по этому списку собирается примерно за полчаса и дальше живёт без правок. Если же хочется автоматизировать не верстку, а само планирование, это соседняя задача: как собрать личного AI-ассистента для повседневных задач без кода, мы разбирали отдельно.
Частые вопросы про верстку календаря
Чем верстать календарь — таблицей или дивами с CSS Grid?
Таблицей. MDN определяет table как элемент для табличных данных, а месяц — это строки-недели и колонки-дни недели. Замер дерева доступности в Chromium 145 показал: сетка из дивов без ARIA-ролей получает роли generic и none, а таблица получает роли table, row, columnheader и cell бесплатно.
Как сделать семь равных колонок в календаре без CSS Grid?
Двумя строками CSS: width: 100% и table-layout: fixed. В контейнере 700 px это дало ровно по 100 px на колонку, в контейнере 360 px — по 51,42 px. Без table-layout: fixed колонки подгоняются под содержимое и разъезжаются от 64,98 до 145,09 px.
Можно ли поставить display: grid прямо на элемент table?
Технически можно, но раскладка сломается. У таблицы три прямых потомка — caption, thead и tbody, и в семь колонок сетка укладывает именно их, а не 35 ячеек таблицы. В замере колонки схлопнулись до 12,42–15,95 px, а ячейка дня стала 19,25 px вместо 100 px.
Нужен ли JavaScript для календаря на HTML и CSS?
Нет, если календарь показывает один месяц: расписание занятий, график работы, афишу. JavaScript нужен, когда пользователь листает месяцы или выбирает дату в собственном календаре. Для выбора даты в форме хватит нативного поля input с типом date, оно рисует календарь силами браузера.
Как пометить сегодняшний день, чтобы его понял скринридер?
Атрибутом aria-current со значением date на ячейке дня. MDN описывает это значение как current date within a collection of dates such as the current date within a calendar. Дату, выбранную пользователем в интерактивном календаре, помечают другим атрибутом — aria-selected со значением true.
- Освой нейросеть Perplexity и узнай, как пользоваться функционалом остальных ИИ в одном
- УЧАСТВОВАТЬ ЗА 0 РУБ.
- Расскажем, как получить подписку
- ПОКАЖЕМ, КАК РАЗВЕРНУТЬ МОДЕЛЬ нейросеть DEEPSEEK R1 ПРЯМО НА СВОЁМ КОМПЬЮТЕРЕ