Проект: дашборд
Плотный интерфейс: сайдбар, таблица с фиксированной шапкой, графики на чистом CSS, состояния загрузки и тёмная тема из коробки.
Что ты научишься делать
- Соберёшь раскладку с сайдбаром, которая не ломается на планшете
- Сделаешь таблицу с фиксированной шапкой и горизонтальной прокруткой
- Нарисуешь три вида графиков без единой библиотеки
#Чем дашборд отличается от лендинга
Лендинг человек пролистывает один раз. Дашборд открывают каждый день и проводят в нём часы. Это меняет всё.
- Плотность вместо воздуха. Пустое место, красивое на лендинге, здесь означает меньше данных на экране.
- Никаких анимаций «для настроения». На третий день они начинают раздражать.
- Тёмная тема обязательна. Люди сидят в интерфейсе вечерами.
- Состояния важнее вида. Загрузка, пустой список, ошибка запроса встречаются постоянно.
- Плотный текст. Мелкий шрифт и малые отступы, но контраст — строго по норме.
#Шаг 1. Раскладка
Классическая схема: сайдбар слева, шапка сверху, содержимое в остальном. Собирается одной сеткой на именованных областях — читается почти как картинка.
@layer layout { .app { display: grid; grid-template-areas: "side head" "side main"; grid-template-columns: 240px minmax(0, 1fr); grid-template-rows: auto 1fr; min-block-size: 100dvh; } .app__side { grid-area: side; } .app__head { grid-area: head; } .app__main { grid-area: main; overflow: auto; } /* Планшет: сайдбар ужимается до иконок */ @media (max-width: 1100px) { .app { grid-template-columns: 64px minmax(0, 1fr); } .app__side .nav__label { display: none; } } /* Телефон: сайдбар уезжает вниз лентой */ @media (max-width: 720px) { .app { grid-template-areas: "head" "main" "side"; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr auto; } .app__side { position: sticky; inset-block-end: 0; display: flex; overflow-x: auto; } }}#Шаг 2. Карточки показателей
#Шаг 3. Графики на чистом CSS
Для трёх базовых видов графиков библиотека не нужна. Столбики — это сетка, кольцо — conic-gradient, линия — SVG в несколько строк.
Ключевой приём для всех трёх: значение приходит переменной, а CSS сам считает высоту и угол. Тогда данные можно подставлять на сервере, и никакой скрипт не нужен.
#Шаг 4. Таблица с фиксированной шапкой
Главная таблица дашборда: шапка не уезжает при вертикальной прокрутке, первая колонка — при горизонтальной. Раньше это делали двумя синхронизированными таблицами; теперь достаточно липкого позиционирования.
#Шаг 5. Состояния загрузки
Данные приходят с задержкой всегда. Скелет вместо крутящегося колёсика удерживает раскладку и не даёт странице прыгать, когда данные приехали.
@layer components { .skeleton { /* Размер тот же, что у настоящего содержимого, иначе при загрузке страница дёрнется */ min-block-size: 1em; border-radius: 6px; background: linear-gradient( 90deg, var(--color-surface) 0%, color-mix(in oklab, var(--color-text) 8%, var(--color-surface)) 50%, var(--color-surface) 100% ); background-size: 200% 100%; animation: shimmer 1.4s linear infinite; } @keyframes shimmer { to { background-position: -200% 0; } } /* Без движения — просто ровная заливка */ @media (prefers-reduced-motion: reduce) { .skeleton { animation: none; opacity: .6; } }}Скелет надо и озвучить: область с данными помечается aria-busy="true", пока идёт загрузка. Тогда программа чтения с экрана скажет «занято» вместо того, чтобы читать пустые блоки.
#Шаг 6. Тёмная тема
Если компоненты с самого начала ссылаются на семантические токены, тема добавляется одним блоком. Это ровно то, что разбирали в тридцать седьмом уроке, — здесь оно окупается.
Особенность дашборда: в тёмной теме нужно пересматривать не только цвета текста, но и цвета данных. Зелёный и красный, хорошо читавшиеся на белом, на тёмном фоне теряют контраст и требуют осветления.
@layer tokens { [data-theme="dark"] { --color-bg: #0f1117; --color-surface: #171a24; --color-text: #e8eaf2; --color-muted: #a7adc2; --color-border: rgb(255 255 255 / .08); /* Цвета данных осветлены: на тёмном фоне насыщенные оттенки визуально темнеют */ --color-ok: #4bd1a0; --color-warn: #e5b567; --color-danger: #f2777a; }}#Плотность и читаемость
Дашборд живёт в противоречии: данных надо показать много, но текст обязан оставаться читаемым. Разрешается оно не уменьшением шрифта, а другими средствами.
Практические ориентиры, проверенные на живых интерфейсах.
| Параметр | Лендинг | Дашборд |
|---|---|---|
| Основной текст | 17–18px | 13–14px, но не меньше 13 |
| Межстрочный интервал | 1.6–1.7 | 1.4–1.5 |
| Отступ внутри карточки | 20–24px | 12–16px |
| Высота строки таблицы | — | 36–44px: меньше — тяжело попасть курсором |
| Контраст | 4.5 : 1 | 4.5 : 1, без исключений |
#Пустые состояния
Список без данных встречается чаще, чем кажется: новый аккаунт, фильтр без совпадений, первый день месяца. Пустая область без объяснения выглядит поломкой.
Хорошее пустое состояние отвечает на три вопроса: почему тут пусто, это нормально или ошибка, и что сделать дальше.
<!-- Данных ещё нет: подсказываем первый шаг --><div class="empty"> <p class="empty__title">Заказов пока нет</p> <p class="empty__text">Они появятся здесь после первой продажи.</p> <a class="btn" href="/catalog">Открыть каталог</a></div><!-- Фильтр ничего не нашёл: предлагаем ослабить --><div class="empty"> <p class="empty__title">Ничего не нашлось</p> <p class="empty__text">Попробуй убрать фильтр по дате.</p> <button class="btn btn--ghost" type="button">Сбросить фильтры</button></div><!-- Ошибка запроса: честно говорим и даём повторить --><div class="empty" role="alert"> <p class="empty__title">Не удалось загрузить</p> <p class="empty__text">Проверь соединение и попробуй ещё раз.</p> <button class="btn" type="button">Повторить</button></div>#Грабли этого проекта
| Что случилось | Почему | Решение |
|---|---|---|
| Таблица растянула весь дашборд | Колонка сетки шире содержимого не бывает при 1fr | minmax(0, 1fr) у колонки содержимого |
| У липкой шапки пропали границы | border-collapse: collapse отдаёт границы таблице | border-separate и границы у ячеек |
| Угол таблицы перекрывается | Липкие шапка и колонка на одном уровне | Ячейке угла больший z-index |
| Числа дёргаются при обновлении | Цифры разной ширины | font-variant-numeric: tabular-nums |
| Страница прыгает после загрузки | Скелет другого размера, чем данные | Скелету те же размеры, что и содержимому |
| График не читается голосом | Набор цветных блоков без текста | role="img" с итогом или скрытая таблица |
Где спотыкаются почти все
Колонка содержимого задана как <code>1fr</code>
Почему Минимальный размер такой колонки равен ширине содержимого. Широкая таблица растягивает всю сетку за экран.
Как надо minmax(0, 1fr) — колонка получает право быть уже содержимого.
Изменение показано только цветом
Почему Рост и падение, различимые лишь по зелёному и красному, недоступны части людей.
Как надо Добавь знак или стрелку рядом с числом.
Вместо скелета крутящееся колёсико
Почему Раскладка не зарезервирована — при появлении данных страница прыгает.
Как надо Скелет размером с будущее содержимое плюс aria-busy.
Цвета статусов не пересмотрены в тёмной теме
Почему Насыщенный зелёный на тёмном фоне теряет контраст и читается хуже нормы.
Как надо Отдельные значения токенов для тёмной темы, проверенные на контраст.
- Скелет загрузки skeleton screen
- Заглушка формой и размером с будущее содержимое.
- Липкая шапка sticky header
- Шапка таблицы, остающаяся на месте при прокрутке тела.
- Табличные цифры tabular-nums
- Начертание, где все цифры одной ширины.
- Конический градиент conic-gradient
- Градиент по кругу. Основа для кольцевых диаграмм на чистом CSS.
Проверь себя
Минимальный размер <code>1fr</code> равен размеру содержимого. Прокрутка должна появляться внутри таблицы, а не у страницы.
При схлопывании границы принадлежат таблице, а не ячейкам, и при прокрутке уезжают вместе с ней.
В большинстве шрифтов единица уже восьмёрки — на колонке чисел это хорошо заметно.
Набор цветных прямоугольников для читалки — пустое место. Данные нужно дать текстом.
Скелет тех же размеров, что и будущее содержимое, убирает сдвиг вёрстки.
Задание
Таблица дашборда
Собери таблицу заказов: шапка не уезжает при вертикальной прокрутке, первая колонка — при горизонтальной, суммы выровнены по правому краю табличными цифрами.
Обёртке задай ограничение по высоте и прокрутку. Не забудь: при липкой шапке нельзя использовать border-collapse: collapse.
Подсказка 1
Обёртке: max-height: 240px; overflow: auto;
Подсказка 2
Таблице: border-collapse: separate; border-spacing: 0;
Подсказка 3
Шапке: position: sticky; top: 0; z-index: 2; Первой колонке: position: sticky; left: 0;
Подсказка 4
Суммам: .num { text-align: right; font-variant-numeric: tabular-nums; }
Показать решение
<div class="table-wrap"> <table class="table"> <thead> <tr> <th scope="col">Номер</th> <th scope="col">Клиент</th> <th scope="col">Статус</th> <th scope="col" class="num">Сумма</th> </tr> </thead> <tbody> <tr><th scope="row">#4821</th><td>Марина К.</td><td>Оплачен</td><td class="num">1 240 ₽</td></tr> <tr><th scope="row">#4820</th><td>Константин П.</td><td>Ждёт оплаты</td><td class="num">890 ₽</td></tr> <tr><th scope="row">#4819</th><td>Анна С.</td><td>Отменён</td><td class="num">2 105 ₽</td></tr> <tr><th scope="row">#4818</th><td>Пётр В.</td><td>Оплачен</td><td class="num">640 ₽</td></tr> <tr><th scope="row">#4817</th><td>Ольга Д.</td><td>Оплачен</td><td class="num">3 480 ₽</td></tr> <tr><th scope="row">#4816</th><td>Илья М.</td><td>Ждёт оплаты</td><td class="num">1 015 ₽</td></tr> </tbody> </table></div>.table-wrap { max-height: 240px; overflow: auto; background: #fff; border: 1px solid #e2e2ea; border-radius: 12px;}.table { width: 100%; min-width: 480px; font-size: 13px; /* Схлопнутые границы уехали бы вместе с телом таблицы */ border-collapse: separate; border-spacing: 0;}.table th,.table td { padding: 9px 12px; text-align: left; border-bottom: 1px solid #eeedf4; white-space: nowrap;}.table thead th { position: sticky; top: 0; z-index: 2; background: #f5f5fa;}.table tbody th { position: sticky; left: 0; background: #fff; font-weight: 600;}/* Угол выше обеих липких зон */.table thead th:first-child { z-index: 3; }.num { text-align: right; font-variant-numeric: tabular-nums;}