Таблица со сворачиваемыми строками в 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);
});
  1. Вызов querySelector получает таблицу с классом .data-table.
  2. Функция setGroupState принимает кнопку и нужное состояние группы.
  3. Атрибут aria-controls дает идентификатор тела, а getElementById находит это тело.
  4. Метод setAttribute записывает строковое значение ARIA.
  5. Свойство hidden получает обратное значение и показывает строки при expanded === true.
  6. Обработчик на таблице принимает клики от всех кнопок.
  7. Метод 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 у обеих групп. Кнопка «Свернуть все» вернет закрытое состояние.

Комментарии

Оставьте свой комментарий
ED
Evgeny D.

Можно ли сделать так, чтобы изначально блоки были свернуты, и лишь при нажатии - разворачивать их?

X
XcenNoN

.hide{
display: none;
}