Таблица со сворачиваемыми строками в HTML
Таблица со сворачиваемыми строками прячет детали внутри групп и оставляет заголовки на экране. Используйте соседние <tbody>, кнопку с ARIA-атрибутами и свойство hidden. Чистый JavaScript добавит переключение по клику, клавиатуре и двум массовым командам.
Как устроена таблица со сворачиваемыми группами
Одна группа состоит из тела-заголовка и соседнего тела с данными. Первое <tbody> содержит строку группы, второе хранит строки, которые нужно скрывать.
Элемент <tbody> объединяет строки таблицы. Внутри него остаются только <tr>, а внутри строк – ячейки <th> и <td>. Вложить один <tbody> в другой нельзя: HTML-парсер закрывает внешний элемент перед новым <tbody>. Поэтому несколько секций должны идти соседями.
Документация MDN по элементу tbody описывает такую секцию как часть табличной структуры. Обернуть строки в <div> тоже нельзя: между таблицей и строкой браузер ожидает табличные элементы. Контейнер для прокрутки ставьте вокруг всей таблицы.
Элементы <details> и <summary> не подходят для группы строк. Внутри отдельной ячейки они допустимы, но не объединяют соседние <tr> в табличную секцию.
Разметка: строка группы и скрываемые строки
Заголовок группы оформите как <th colspan="5" scope="rowgroup">. Поместите внутрь настоящую кнопку с type="button", aria-expanded и aria-controls. Значение aria-controls должно совпадать с уникальным id скрываемого <tbody>.
В таблице четыре квартала и столбец с городом. Атрибут headers связывает числовую ячейку с заголовком региона, города и квартала.
<div class="table-scroll">
<table class="data-table">
<caption>Учебные данные по кварталам 2026</caption>
<thead>
<tr>
<th id="col-region" scope="col">Регион / город</th>
<th id="q1" scope="col">I квартал</th>
<th id="q2" scope="col">II квартал</th>
<th id="q3" scope="col">III квартал</th>
<th id="q4" scope="col">IV квартал</th>
</tr>
</thead>
<tbody class="group">
<tr class="group__header">
<th id="heading-north" colspan="5" scope="rowgroup">
<button class="group__button" type="button"
aria-expanded="true" aria-controls="group-north">
<span class="group__arrow" aria-hidden="true">▸</span>
Северный регион
</button>
</th>
</tr>
</tbody>
<tbody id="group-north" class="group__rows">
<tr>
<th id="city-ark" scope="row" headers="heading-north col-region">Архангельск</th>
<td headers="heading-north city-ark q1">120</td>
<td headers="heading-north city-ark q2">135</td>
<td headers="heading-north city-ark q3">142</td>
<td headers="heading-north city-ark q4">151</td>
</tr>
<tr>
<th id="city-mur" scope="row" headers="heading-north col-region">Мурманск</th>
<td headers="heading-north city-mur q1">98</td>
<td headers="heading-north city-mur q2">110</td>
<td headers="heading-north city-mur q3">117</td>
<td headers="heading-north city-mur q4">125</td>
</tr>
</tbody>
<tbody class="group">
<tr class="group__header">
<th id="heading-south" colspan="5" scope="rowgroup">
<button class="group__button" type="button"
aria-expanded="true" aria-controls="group-south">
<span class="group__arrow" aria-hidden="true">▸</span>
Южный регион
</button>
</th>
</tr>
</tbody>
<tbody id="group-south" class="group__rows">
<tr>
<th id="city-kra" scope="row" headers="heading-south col-region">Краснодар</th>
<td headers="heading-south city-kra q1">210</td>
<td headers="heading-south city-kra q2">224</td>
<td headers="heading-south city-kra q3">231</td>
<td headers="heading-south city-kra q4">245</td>
</tr>
<tr>
<th id="city-ros" scope="row" headers="heading-south col-region">Ростов-на-Дону</th>
<td headers="heading-south city-ros q1">184</td>
<td headers="heading-south city-ros q2">192</td>
<td headers="heading-south city-ros q3">205</td>
<td headers="heading-south city-ros q4">219</td>
</tr>
</tbody>
</table>
</div>Кнопка получает фокус через Tab и реагирует на Enter или Space. Значение aria-expanded="true" сообщает скринридеру, что группа открыта.
Стили таблицы и индикатор состояния
Обертка .table-scroll дает таблице горизонтальную прокрутку на узком экране. Таблица сохраняет табличный режим, а кнопка занимает всю ячейку группы.
.table-scroll {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 0.7rem;
border: 1px solid #cbd5e1;
text-align: left;
}
.data-table thead th {
background: #1e3a8a;
color: #fff;
}
.data-table .group__header th {
padding: 0;
}
.group__button {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.7rem;
border: 0;
background: #dbeafe;
color: #172554;
font: inherit;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.group__button:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: -3px;
}
.group__arrow {
transition: transform 0.15s ease;
}
.group__button[aria-expanded="true"] .group__arrow {
transform: rotate(90deg);
}Селектор с aria-expanded="true" поворачивает стрелку вниз. При закрытой группе символ остается направленным вправо. CSS отвечает за индикатор, JavaScript меняет видимость тела.
Сворачивание на чистом JavaScript
Скрывайте целое тело группы через hidden и одновременно меняйте ARIA-состояние кнопки. Делегирование событий на таблице оставляет один обработчик для всех групп.
const table = document.querySelector('.data-table');
function setGroupState(button, expanded) {
const groupId = button.getAttribute('aria-controls');
const rows = document.getElementById(groupId);
button.setAttribute('aria-expanded', String(expanded));
rows.hidden = !expanded;
}
table.addEventListener('click', (event) => {
const button = event.target.closest('.group__button');
if (!button || !table.contains(button)) {
return;
}
const expanded = button.getAttribute('aria-expanded') === 'true';
setGroupState(button, !expanded);
});- Вызов
querySelectorполучает таблицу с классом.data-table. - Функция
setGroupStateпринимает кнопку и нужное состояние группы. - Атрибут
aria-controlsдает идентификатор тела, аgetElementByIdнаходит это тело. - Метод
setAttributeзаписывает строковое значение ARIA. - Свойство
hiddenполучает обратное значение и показывает строки приexpanded === true. - Обработчик на таблице принимает клики от всех кнопок.
- Метод
closestнаходит кнопку при клике по стрелке или ее тексту.
Настоящая кнопка уже поддерживает Tab, Enter и Space. Скринридер получает новое состояние из aria-expanded, а декоративная стрелка не читается благодаря aria-hidden="true".
Такая связка соответствует шаблону disclosure в WAI-ARIA Authoring Practices.
Как свернуть группы по умолчанию и добавить массовые команды
Начальное состояние задайте в HTML. Закрытая группа получает hidden, а ее кнопка – значение aria-expanded="false".
<button class="group__button" type="button"
aria-expanded="false" aria-controls="group-north">
<span class="group__arrow" aria-hidden="true">▸</span>
Северный регион
</button>
<tbody id="group-north" class="group__rows" hidden>
…
</tbody>Для открытой группы уберите hidden и поставьте aria-expanded="true". Значение hidden="false" не открывает элемент: наличие атрибута все равно скрывает его.
Разместите две кнопки перед оберткой таблицы. Их data-groups-action передаст обработчику команду.
<div class="table-actions">
<button type="button" data-groups-action="open">Развернуть все</button>
<button type="button" data-groups-action="close">Свернуть все</button>
</div>document.querySelectorAll('[data-groups-action]').forEach((control) => {
control.addEventListener('click', () => {
const expanded = control.dataset.groupsAction === 'open';
table.querySelectorAll('.group__button').forEach((button) => {
setGroupState(button, expanded);
});
});
});После команды «Развернуть все» у каждой группы исчезает hidden, а кнопки получают aria-expanded="true". Команда «Свернуть все» возвращает скрытые тела и значение false.
Вариант на jQuery
Если проект уже использует jQuery, обработчик можно записать короче. Ради одной задачи подключать библиотеку не нужно: чистый JavaScript не создает дополнительную зависимость.
Этот вариант рассчитан только на проект с уже подключенным jQuery. Замените им обработчик table.addEventListener('click', …). Массовые кнопки оставьте на чистом JavaScript вместе с переменной table и функцией setGroupState.
$('.data-table').on('click', '.group__button', function () {
const $button = $(this);
const expanded = $button.attr('aria-expanded') === 'true';
const $rows = $button.closest('tbody').next('.group__rows');
$button.attr('aria-expanded', String(!expanded));
$rows.prop('hidden', expanded);
});Метод closest('tbody') находит секцию с кнопкой. Вызов next('.group__rows') выбирает следующее соседнее тело и не зависит от числа групп.
Не запускайте этот обработчик вместе с обработчиком чистого JavaScript: один клик тогда переключит состояние дважды.
Ошибки при сворачивании строк таблицы
- Вложенные
<tbody>. Парсер закроет внешнюю секцию перед внутренней, поэтому исходная структура исчезнет. - Одинаковые
id.getElementByIdможет найти первый элемент, и несколько кнопок начнут управлять одной группой. - Скрытый чекбокс. Элемент с
display: noneнельзя выбрать клавишей Tab. Настоящая кнопка решает эту проблему. display: blockу<tr>. Такое правило выводит строку из табличного алгоритма и ломает сетку столбцов.- Разные имена классов. Селектор
.group__buttonне найдет элемент с классом.group-button. - Расхождение состояния. Скрытое тело при
aria-expanded="true"дает скринридеру неверную информацию.
Атрибут hidden и CSS-правило display: none убирают элемент из раскладки и дерева доступности. Для целого <tbody> выбирайте hidden: скрипт управляет видимостью через одно свойство.
Значение visibility: collapse предназначено для строк и столбцов таблицы. Строка перестает рисоваться, но ее размеры могут учитываться при расчете ширины столбцов. Поведение зависит от табличного контекста и браузера.
hidden и display: none убирают скрываемую секцию полностью: она не занимает место и не участвует в раскладке. Не переопределяйте скрытое тело правилом вроде .group__rows { display: table-row-group; }, иначе CSS может вернуть его на экран вопреки hidden.
Готовый пример целиком
Сохраните листинг в файл с расширением .html и откройте его в браузере. Кнопки групп работают мышью и клавиатурой, а команды над таблицей меняют состояние всех групп.
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Таблица со сворачиваемыми строками</title>
<style>
.table-scroll { overflow-x: auto; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td {
padding: 0.7rem;
border: 1px solid #cbd5e1;
text-align: left;
}
.data-table thead th { background: #1e3a8a; color: #fff; }
.data-table .group__header th { padding: 0; }
.group__button {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.7rem;
border: 0;
background: #dbeafe;
color: #172554;
font: inherit;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.group__button:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: -3px;
}
.group__arrow { transition: transform 0.15s ease; }
.group__button[aria-expanded="true"] .group__arrow {
transform: rotate(90deg);
}
</style>
</head>
<body>
<div class="table-actions">
<button type="button" data-groups-action="open">Развернуть все</button>
<button type="button" data-groups-action="close">Свернуть все</button>
</div>
<div class="table-scroll">
<table class="data-table">
<caption>Учебные данные по кварталам 2026</caption>
<thead>
<tr>
<th id="col-region" scope="col">Регион / город</th>
<th id="q1" scope="col">I квартал</th>
<th id="q2" scope="col">II квартал</th>
<th id="q3" scope="col">III квартал</th>
<th id="q4" scope="col">IV квартал</th>
</tr>
</thead>
<tbody class="group">
<tr class="group__header">
<th id="heading-north" colspan="5" scope="rowgroup">
<button class="group__button" type="button" aria-expanded="true" aria-controls="group-north">
<span class="group__arrow" aria-hidden="true">▸</span>
Северный регион
</button>
</th>
</tr>
</tbody>
<tbody id="group-north" class="group__rows">
<tr>
<th id="city-ark" scope="row" headers="heading-north col-region">Архангельск</th>
<td headers="heading-north city-ark q1">120</td>
<td headers="heading-north city-ark q2">135</td>
<td headers="heading-north city-ark q3">142</td>
<td headers="heading-north city-ark q4">151</td>
</tr>
<tr>
<th id="city-mur" scope="row" headers="heading-north col-region">Мурманск</th>
<td headers="heading-north city-mur q1">98</td>
<td headers="heading-north city-mur q2">110</td>
<td headers="heading-north city-mur q3">117</td>
<td headers="heading-north city-mur q4">125</td>
</tr>
</tbody>
<tbody class="group">
<tr class="group__header">
<th id="heading-south" colspan="5" scope="rowgroup">
<button class="group__button" type="button" aria-expanded="true" aria-controls="group-south">
<span class="group__arrow" aria-hidden="true">▸</span>
Южный регион
</button>
</th>
</tr>
</tbody>
<tbody id="group-south" class="group__rows">
<tr>
<th id="city-kra" scope="row" headers="heading-south col-region">Краснодар</th>
<td headers="heading-south city-kra q1">210</td>
<td headers="heading-south city-kra q2">224</td>
<td headers="heading-south city-kra q3">231</td>
<td headers="heading-south city-kra q4">245</td>
</tr>
<tr>
<th id="city-ros" scope="row" headers="heading-south col-region">Ростов-на-Дону</th>
<td headers="heading-south city-ros q1">184</td>
<td headers="heading-south city-ros q2">192</td>
<td headers="heading-south city-ros q3">205</td>
<td headers="heading-south city-ros q4">219</td>
</tr>
</tbody>
</table>
</div>
<script>
const table = document.querySelector('.data-table');
function setGroupState(button, expanded) {
const groupId = button.getAttribute('aria-controls');
const rows = document.getElementById(groupId);
button.setAttribute('aria-expanded', String(expanded));
rows.hidden = !expanded;
}
table.addEventListener('click', (event) => {
const button = event.target.closest('.group__button');
if (!button || !table.contains(button)) return;
const expanded = button.getAttribute('aria-expanded') === 'true';
setGroupState(button, !expanded);
});
document.querySelectorAll('[data-groups-action]').forEach((control) => {
control.addEventListener('click', () => {
const expanded = control.dataset.groupsAction === 'open';
table.querySelectorAll('.group__button').forEach((button) => {
setGroupState(button, expanded);
});
});
});
</script>
</body>
</html>Откройте файл и нажмите «Северный регион». Две строки северных городов исчезнут, а стрелка повернется вправо. Повторный клик вернет строки.
Нажмите Tab до кнопки группы и активируйте ее клавишей Enter или Space. Оранжевый контур :focus-visible останется видимым.
Кнопка «Развернуть все» уберет hidden у обеих групп. Кнопка «Свернуть все» вернет закрытое состояние.
Популярное за неделю
- Как создать сайт самому в 2026: 4 способа + пошаговая инструкция 34 мин чтения
- Лучшие конструкторы для создания сайтов — плюсы, минусы и сравнения 22 мин чтения
- Скрипт сокращения URL на PHP с INI 3 мин чтения
- jquery select onchange в jQuery: как получить значение select 6 мин чтения
- Как создать фотогалерею для сайта 5 мин чтения
- Валидация формы js для регистрации с проверкой полей 5 мин чтения
Комментарии
Можно ли сделать так, чтобы изначально блоки были свернуты, и лишь при нажатии - разворачивать их?
.hide{
display: none;
}