Таблицы

Таблица данных, пять вариантов оформления, прокрутка вместо выноса страницы и липкая шапка с линией, которая едет вместе с ней.

griffincss-ui packages/ui/scss/_table.scss

Классы

КлассЧто делает
.gr-tableСхлопнутые границы, отступы в ячейках, шапка приглушённым кеглем
.gr-table-stripedЧередование строк тела
.gr-table-borderedПолная сетка: вертикальные линии и внешняя рамка
.gr-table-compactОтступы вдвое меньше, кегль мельче
.gr-table-relaxedОбратное компактному: воздуха больше стандартного
.gr-table-hoverПодсветка строки под указателем
.gr-table-wrapОбёртка с горизонтальной прокруткой
.gr-table-stickyШапка остаётся видна при прокрутке тела
.gr-table-stack, -{sm,md,lg,xl}, -c{sm,md,lg,xl}Карточки вместо строк: всегда, ниже ширины окна или ниже ширины контейнера

Внутри .gr-table селекторы идут по элементам — thead, tbody, tr, th, td. Разметка таблицы задана стандартом, придумывать ей отдельные классы не нужно: достаточно одного на самой <table>.

Полосатая с подсветкой строки
Размер сборок, версия 0.16.0-dev
ПакетCSSgzipКлассов
griffincss-core24.1 КБ3.3 КБ207
griffincss-ui66.1 КБ9.9 КБ193
griffincss-utils105.5 КБ14.2 КБ2507
Ресет подключается отдельно1.1 КБ0.6 КБ
<table class="gr-table gr-table-striped gr-table-hover">
  <caption>Подпись</caption>
  <thead><tr><th scope="col">Пакет</th>…</tr></thead>
  <tbody><tr><td>griffincss-core</td>…</tr></tbody>
</table>

scope="col" на заголовках — не украшение. Без него скринридер не связывает ячейку со столбцом, и таблица из четырёх колонок читается сплошным потоком чисел, в котором непонятно, что к чему относится.

Полосы и подсветка — полупрозрачный оттенок цвета текста, а не готовый цвет поверхности. Таблица может лежать на чём угодно: на странице, на карточке, на утопленной панели. Любой непрозрачный цвет совпадёт с одной из этих подложек и станет невидимым — так полосы и пропадали внутри блока примера на этой самой странице. Оттенок виден на всех подложках и меняет направление вместе с темой сам: в светлой currentcolor тёмный, в тёмной — светлый.

Подсветка при этом ложится слоем поверх фона строки (background-image), а не вместо него (background-color). У полосы и подсветки одна специфичность, поэтому вторая просто заменяла бы первую — и на нечётной строке полосатой таблицы разница при наведении почти не читалась бы. Слой складывается с полосой, и шаг одинаков на любой строке.

Полная сетка и плотность

gr-table-bordered + gr-table-compact
СлойКто в нёмКого перекрывает
griffincss.resetРесетникого
griffincss.coreТокены, раскладки, флексресет
griffincss.uiКомпонентыядро и ресет
griffincss.utilsУтилитывсех троих
gr-table-relaxed
ВолнаЧто в ней
8aСтатические компоненты
8bНавигация и раскрытие
8cОбратная связь и состояния

Прокрутка и липкая шапка

Широкая таблица не сжимается ниже своего содержимого. Без обёртки вбок уезжает вся страница, а не одна таблица — .gr-table-wrap оставляет прокрутку внутри себя.

Прокручиваемой области нужен tabindex="0": без него до содержимого, уехавшего вправо, нельзя добраться с клавиатуры — колесо и палец есть не у всех. Вместе с ним ставится role="region" и aria-label, иначе фокус попадёт в безымянный блок.

Липкая шапка внутри прокручиваемой области
ТокенЗначениеГде применяется
--gr-control-height2.5remКнопка, поле, список
--gr-control-height-sm2remТе же в размере sm
--gr-control-height-lg3remТе же в размере lg
--gr-control-padding-x0.875remГоризонтальные поля элементов управления
--gr-control-font-size1remКегль элементов управления
--gr-ui-gap0.5remЗазор между иконкой и текстом
--gr-overlayчёрный, 55 %Подложка модалки и ящика (волна 8b)
--gr-z-dropdown1000Попап выпадающего меню
--gr-z-modal1100Модальное окно без top layer
--gr-z-toast1200Всплывающие сообщения
<div class="gr-table-wrap" tabindex="0" role="region" aria-label="Данные">
  <table class="gr-table gr-table-sticky">…</table>
</div>

Липкой шапке обязателен собственный фон — иначе сквозь ячейки просвечивают уезжающие строки. Он берётся из --gr-color-bg, поэтому в тёмной теме и в режиме высокого контраста меняется вместе со страницей.

Линию под шапкой рисует внутренняя тень, а не граница. При border-collapse: collapse границу между ячейками рисует таблица, а не сама ячейка, — и с уехавшей липкой шапкой она не двигается: линия оставалась на исходном месте и пропадала из виду. Тень принадлежит элементу и едет вместе с ним.

Карточки вместо строк

Таблице кабинета — заказы, транзакции, бонусы, состав заказа — на телефоне места нет: пять колонок в 375 px не помещаются, а прокрутка вбок прячет половину строки. .gr-table-stack ниже порога превращает каждую строку в карточку: ячейки в столбик, у каждой — подпись колонки из data-label, строка обведена и скруглена как контейнер (--gr-radius-container). Шапка при этом не display: none — она ужата в пиксель, как .gr-sr-only, и программа чтения с экрана по-прежнему связывает ячейки с заголовками колонок.

КлассКарточки
.gr-table-stackВсегда
.gr-table-stack-sm-xlНиже 640 / 768 / 1024 / 1280 px ширины окна; от порога — таблица
.gr-table-stack-csm-cxlНиже той же ширины контейнера — ближайшего предка с .gr-cq

Суффикс называет порог, а не «от брейкпоинта». У остальных адаптивных классов -md значит «от 768 px и выше»; здесь класс описывает узкое состояние, а широкое — просто таблица, поэтому .gr-table-stack-md — «карточки, пока уже 768 px». Контейнерный порог нужен кабинету с боковой колонкой: таблица в содержимом складывается от ширины своей колонки, а не окна.

<table class="gr-table gr-table-stack-md">
  <thead>
    <tr><th scope="col">Заказ</th><th scope="col">Дата</th><th scope="col">Статус</th><th scope="col" class="gr-text-end">Сумма</th></tr>
  </thead>
  <tbody>
    <tr>
      <td data-label="Заказ">#1042</td>
      <td data-label="Дата">12.09.2026</td>
      <td data-label="Статус"><span class="gr-badge gr-badge-success">Доставлен</span></td>
      <td data-label="Сумма" class="gr-text-end gr-tabular-nums">24 800 ₽</td>
    </tr>
  </tbody>
</table>
Сузьте окно ниже 768 px — строки станут карточками
ЗаказДатаСтатусСумма
#1042 12.09.2026 Доставлен 24 800 ₽
#1043 14.09.2026 В пути 1 200 ₽
#1044 19.09.2026 Оформлен 318 400 ₽

Без data-label ячейка идёт без подписи. Она не ломается — значение занимает всю строку карточки, — но читатель видит число без имени колонки. Подпись ставится на каждой ячейке, которой она нужна; ячейка с самоочевидным содержимым (кнопка «Подробнее», название товара) может обойтись без неё, и тогда пустого места под подпись у неё нет.

Числа вправо, заголовки в две строки — утилитами, а не классами таблицы. .gr-text-end на <td> перебивает text-align: start компонента, а .gr-whitespace-normal на <th> снимает nowrap с заголовка: утилиты лежат в слое griffincss.utils, который старше griffincss.ui, и побеждают при любой специфичности. Разряды в колонке чисел выравнивает .gr-tabular-nums. В карточках .gr-text-end на ячейке ничего не двигает — значение и так стоит за подписью, а подпись держится начала своей доли. Класс шрифта (.gr-font-mono) ставится на значение, а не на ячейку: подпись наследует шрифт ячейки, как любой ::before.

Примеры использования

Классы таблицы держат каркас, а живой её делают соседи в ячейках: аватары, бейджи, кнопки-иконки, выравнивание чисел. Два рецепта ниже — самая тиражируемая таблица админки и её противоположность, тихий прайс-лист.

Таблица пользователей: составные ячейки

Первая колонка — мини медиа-объект: .gr-avatar-sm и стопка «имя + почта» двумя строками разного веса. Статус — бейдж со смысловым цветом, действия — .gr-btn-icon .gr-btn-ghost с .gr-sr-only-подписью, прижатые в конец колонки .gr-text-end. Всё это лежит в обычных td — таблице достаточно .gr-table .gr-table-hover и обёртки с прокруткой на узкий экран.

<div class="gr-table-wrap" tabindex="0" role="region" aria-label="Пользователи">
  <table class="gr-table gr-table-hover">
    <thead><tr><th scope="col">Пользователь</th><th scope="col">Статус</th><th scope="col" class="gr-text-end">Действия</th></tr></thead>
    <tbody>
      <tr>
        <td>
          <span class="gr-flex gr-flex-items-center gr-gap-sm">
            <span class="gr-avatar gr-avatar-sm">АК</span>
            <span><span class="gr-block gr-font-medium">Анна Ковалёва</span>
            <span class="gr-block gr-text-xs gr-text-ink-secondary">anna@example.ru</span></span>
          </span>
        </td>
        <td><span class="gr-badge gr-badge-success">Активна</span></td>
        <td class="gr-text-end">
          <button class="gr-btn gr-btn-icon gr-btn-ghost">✎<span class="gr-sr-only">Редактировать</span></button>
        </td>
      </tr>
    </tbody>
  </table>
</div>
Ячейка-стопка, бейдж статуса, действия в конце строки
ПользовательСтатусДействия
АК Анна Ковалёваanna@example.ru Активна
ДС Даниил Соколовd.sokolov@example.ru Приглашён
МЛ Мария Лебедеваm.lebedeva@example.ru Заблокирована

Прайс-лист с числами в конце строки

Счёт, спецификация, отчёт: две-три колонки, числа выровнены в конец строки (.gr-text-end — мелочь, без которой разряды не совпадают и колонку невозможно сканировать взглядом), плотность — .gr-table-compact, итог — tfoot с .gr-font-semibold. Никакой зебры: в коротком списке она шумит, хватает разделителей строк.

<table class="gr-table gr-table-compact">
  <thead><tr><th scope="col">Позиция</th><th scope="col" class="gr-text-end">Кол-во</th><th scope="col" class="gr-text-end">Сумма</th></tr></thead>
  <tbody>
    <tr><td>Кресло «Дюна»</td><td class="gr-text-end">2</td><td class="gr-text-end">24 800 ₽</td></tr>
    …
  </tbody>
  <tfoot>
    <tr class="gr-font-semibold"><td>Итого</td><td class="gr-text-end">4</td><td class="gr-text-end">33 900 ₽</td></tr>
  </tfoot>
</table>
Числа — .gr-text-end, итог — tfoot полужирным
ПозицияКол-воСумма
Кресло «Дюна»224 800 ₽
Торшер «Свет»15 900 ₽
Полка «Норд»13 200 ₽
Итого433 900 ₽

С подключённым griffinjs.js

Всё выше работает без скрипта. Этот подраздел — о том, что добавляет таблице опциональный слой GriffinJS: сортировку строк по столбцу. Разметка и классы те же, скрипт читает data-gr-sortable и пишет только состояния: сортировку — в aria-sort на заголовке, а к ней tabindex на сортируемых заголовках и data-gr-state на самой таблице — признак того, что скрипт поднялся; на нём в CSS держатся курсор и подсказка «столбец сортируется». Набор для этого — griffinjs-core.js плюс griffinjs-sortable.js, 3,6 КБ gzip.

Сортировка появилась не потому, что её попросили. В списке того, чего здесь не будет (TODO.md в репозитории), сортировка таблицы и выбор даты стояли рядом с одним доводом — «это приложение, а не разметка». Для выбора даты довод верен: модель состояния, календарь, локали, клавиатура, мобильный ввод. Для сортировки он не работает: это перестановка строк по признаку, который уже объявлен в разметке, с aria-sort в качестве состояния и Intl.Collator в качестве сравнения. Ни модели, ни хранилища, ни сети. Пересмотрен один довод, а не решение «экосистемы не будет».

Что обязательно, а что нет

АтрибутГдеЗачем
data-gr-sortableна <table>Обязателен. Включает виджет
aria-sort="none"на <th> в <thead>Обязателен на каждом сортируемом столбце. Заголовок без него не сортируется — у колонки действий сортировать нечего
aria-sort="ascending"
aria-sort="descending"
там жеНеобязателен. Так таблица говорит, что её уже отсортировал сервер: строки виджет не трогает, а первое нажатие даёт разворот — в обе стороны, и по возрастанию, и по убыванию
data-gr-sort-valueна <td>Необязателен. Значение для сравнения, когда текст ячейки отформатирован для человека: «24 800 ₽», «вчера», «★★★★☆»

Состояние живёт в aria-sort и больше нигде — ни в классе, ни в своём атрибуте данных. Одно место читают все четверо: человек через программу чтения, указатель направления в CSS, сам виджет и сервер, если сортирует он. Второй источник правды разошёлся бы с первым на третьем нажатии.

<table class="gr-table gr-table-hover" data-gr-sortable>
  <thead>
    <tr>
      <th scope="col" aria-sort="none">Город</th>
      <th scope="col" aria-sort="none">Дата</th>
      <th scope="col" aria-sort="none" class="gr-text-end">Сумма</th>
      <th scope="col">Действия</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Пермь</td>
      <td>2026-08-30</td>
      <td class="gr-text-end" data-gr-sort-value="24800">24 800 ₽</td>
      <td>…</td>
    </tr>
  </tbody>
</table>
Три типа значений в одной таблице; нажмите заголовок трижды нужен griffinjs.js
Заказы: текст, дата и сумма
Город Дата Сумма Действия
Пермь2026-08-3024 800 ₽
Ёлкино2026-01-09113 200 ₽
Азов2026-08-055 900 ₽
Яровое2026-09-14999 ₽

Как сравниваются значения

Значение берётся из data-gr-sort-value, а если атрибута нет — из текста ячейки. Тип столбца решается один раз, по всем его ячейкам сразу: сравнение «числа с числом, буквы с буквами» на каждой паре не давало бы порядка вовсе.

Что в столбцеКак сравнивается
Числа: 9, -3, 12.5Числами. Текстом «10» меньше «9», и столбец чисел стал бы бесполезным. Форматированное число — разряды пробелом, десятичная запятая, знак валюты — числом не считается: «1,234» в английской записи это тысячи, в русской — единицы, и угадывать значило бы отвечать неверно молча. Для него и нужен data-gr-sort-value
Даты в ISO: 2026-08-30Датами. Другой формат — 31.08.2026, «вчера» — сравнится как текст: для него и нужен data-gr-sort-value
Всё остальноеIntl.Collator с локалью из lang документа. Если тег локали непригоден — lang="ru_RU" вместо ru-RU приходит туда из серверной локали, — сравнение идёт по умолчанию движка: терять из-за этого сортировку целиком виджет не станет
Пустые ячейкиУходят в конец в обе стороны: пустая ячейка не значит «меньше всех», а наверху списка заслоняла бы данные, ради которых столбец и сортируют

Локаль решает больше, чем кажется. В Юникоде «Ё» стоит перед «А» (U+0401 против U+0410), поэтому сравнение кодами символов поднимает «Ёлку» выше «Апельсина». Коллатор с русской локалью ставит её туда, где её ищут глазами, — между «Апельсином» и «Яблоком». Локаль берётся из lang документа, то есть из того же места, откуда её берут перенос слов и проверка орфографии.

Сортировка стабильная. Строки с одинаковым значением сохраняют порядок, в котором пришли, — и при развороте тоже. Без этого повторное нажатие перемешивало бы равные строки, и таблица выглядела бы сломанной.

Нажатие, клавиатура и круг из трёх состояний

Нажатие на заголовок ведёт aria-sort по кругу: нет → по возрастанию → по убыванию → нет. Третье нажатие возвращает порядок, в котором таблица пришла: отменить сортировку иначе нечем, кроме перезагрузки страницы. Отсортированным при этом объявлен ровно один столбец — у соседнего aria-sort возвращается в none.

Круг замыкается только там, где порядок навёл сам виджет. У столбца, который объявил сервер, «снять сортировку» вернуло бы тот же самый порядок: нажатие выглядело бы сломанным, а aria-sort сообщал бы «не отсортирована» об отсортированной таблице. Поэтому первое нажатие по такому столбцу — всегда разворот, а круг начинается после него.

С клавиатуры заголовок доступен так же, как мышью: под скриптом он получает tabindex="0", а Enter и Пробел делают то же, что щелчок (прокрутка страницы пробелом при этом не срабатывает — иначе таблица уезжала бы из виду в момент сортировки). Если вы предпочитаете разметку по образцу WAI-ARIA и ставите в заголовок свою <button>, виджет это видит: своего tabindex он тогда не добавляет, чтобы Tab не останавливался на одной ячейке дважды, а щелчок по кнопке всплывает до таблицы сам.

Без скрипта

Таблица остаётся таблицей. Заголовок не получает ни tabindex, ни курсора-указателя: предлагать нажатие, которое ничего не сделает, нельзя. Указатель направления при этом остаётся — он рисуется из aria-sort, а не из скрипта, и потому одинаково работает у таблицы, отсортированной сервером. Правило живёт в _table.scss — в компоненте, а не в griffinjs.css: серверу для aria-sort слой не нужен.

Плата названа: aria-sort="none" в разметке без скрипта означает «столбец сортируемый», а сортировать его будет нечем. Если страница отдаётся без griffinjs.js и без серверной сортировки — атрибут ставить не нужно, и таблица не пообещает лишнего.

Предел виджета

Виджет не делает пагинации, фильтрации, серверной сортировки, выбора строк, изменения ширины колонок и не запоминает выбранный порядок между заходами. Каждый пункт выглядит на полчаса работы — и каждый превращает перестановку строк в приложение, у которого появляются модель состояния, хранилище и сеть. Своего события виджет по той же причине не шлёт: обработчику здесь нечего делать, кроме перечисленного.

Что осталось за границей и решается разметкой: сортировка идёт по первому <tbody>, объединённые ячейки (colspan) в шапке сдвигают счёт столбцов, а сортировать по строке-заголовку (<th> внутри <tbody>) виджет не умеет. Таблицы, которым нужно больше, — это уже таблица-приложение, и её место в коде потребителя.