Таблицы
Таблица данных, пять вариантов оформления, прокрутка вместо выноса страницы и липкая шапка с линией, которая едет вместе с ней.
Классы
| Класс | Что делает |
|---|---|
.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>.
| Пакет | CSS | gzip | Классов |
|---|---|---|---|
| griffincss-core | 24.1 КБ | 3.3 КБ | 207 |
| griffincss-ui | 66.1 КБ | 9.9 КБ | 193 |
| griffincss-utils | 105.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). У полосы и подсветки одна специфичность,
поэтому вторая просто заменяла бы первую — и на нечётной строке
полосатой таблицы разница при наведении почти не читалась бы. Слой
складывается с полосой, и шаг одинаков на любой строке.
Полная сетка и плотность
| Слой | Кто в нём | Кого перекрывает |
|---|---|---|
| griffincss.reset | Ресет | никого |
| griffincss.core | Токены, раскладки, флекс | ресет |
| griffincss.ui | Компоненты | ядро и ресет |
| griffincss.utils | Утилиты | всех троих |
| Волна | Что в ней |
|---|---|
| 8a | Статические компоненты |
| 8b | Навигация и раскрытие |
| 8c | Обратная связь и состояния |
Прокрутка и липкая шапка
Широкая таблица не сжимается ниже своего содержимого. Без обёртки вбок
уезжает вся страница, а не одна таблица — .gr-table-wrap
оставляет прокрутку внутри себя.
Прокручиваемой области нужен tabindex="0": без него до
содержимого, уехавшего вправо, нельзя добраться с клавиатуры — колесо
и палец есть не у всех. Вместе с ним ставится role="region"
и aria-label, иначе фокус попадёт в безымянный блок.
| Токен | Значение | Где применяется |
|---|---|---|
--gr-control-height | 2.5rem | Кнопка, поле, список |
--gr-control-height-sm | 2rem | Те же в размере sm |
--gr-control-height-lg | 3rem | Те же в размере lg |
--gr-control-padding-x | 0.875rem | Горизонтальные поля элементов управления |
--gr-control-font-size | 1rem | Кегль элементов управления |
--gr-ui-gap | 0.5rem | Зазор между иконкой и текстом |
--gr-overlay | чёрный, 55 % | Подложка модалки и ящика (волна 8b) |
--gr-z-dropdown | 1000 | Попап выпадающего меню |
--gr-z-modal | 1100 | Модальное окно без top layer |
--gr-z-toast | 1200 | Всплывающие сообщения |
<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>
| Заказ | Дата | Статус | Сумма |
|---|---|---|---|
| #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 полужирным| Позиция | Кол-во | Сумма |
|---|---|---|
| Кресло «Дюна» | 2 | 24 800 ₽ |
| Торшер «Свет» | 1 | 5 900 ₽ |
| Полка «Норд» | 1 | 3 200 ₽ |
| Итого | 4 | 33 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>
| Город | Дата | Сумма | Действия |
|---|---|---|---|
| Пермь | 2026-08-30 | 24 800 ₽ | |
| Ёлкино | 2026-01-09 | 113 200 ₽ | |
| Азов | 2026-08-05 | 5 900 ₽ | |
| Яровое | 2026-09-14 | 999 ₽ |
Как сравниваются значения
Значение берётся из 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>) виджет
не умеет. Таблицы, которым нужно больше, — это уже таблица-приложение,
и её место в коде потребителя.