Верстка календаря на 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 с его базами и календарным видом или собранного за день ИИ-ассистента для персонального планирования.

HTML и CSS — основа верстки календаря для сайта
ОНЛАЙН-ПРАКТИКУМ
ЗАПУСК нейросети DEEPSEEK R1 ЛОКАЛЬНО НА СВОЕМ КОМПЬЮТЕРЕ
ЧТО БУДЕТ НА ОБУЧЕНИИ?
  • ПОКАЖЕМ, КАК РАЗВЕРНУТЬ МОДЕЛЬ нейросети 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.

Роли в дереве доступности, 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.

Пять способов сделать семь колонок: замер в Chromium 145
Правило 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
ПОКАЖЕМ НА КОНКРЕТНЫХ КЕЙСАХ
  • Освой нейросеть Perplexity и узнай, как пользоваться функционалом остальных ИИ в одном
  • УЧАСТВОВАТЬ ЗА 0 РУБ.
  • Расскажем, как получить подписку
Участвовать бесплатно
ОНЛАЙН-ПРАКТИКУМ
ЗАПУСК нейросети DEEPSEEK R1 ЛОКАЛЬНО НА СВОЕМ КОМПЬЮТЕРЕ
ЧТО БУДЕТ НА ОБУЧЕНИИ?
  • ПОКАЖЕМ, КАК РАЗВЕРНУТЬ МОДЕЛЬ нейросеть DEEPSEEK R1 ПРЯМО НА СВОЁМ КОМПЬЮТЕРЕ
Участвовать бесплатно