HTML-таблица на телефоне: как сохранить читаемость и связь с заголовками
- 16 октября 2026
На телефоне расписание вылезает за экран, а уменьшение шрифта делает цифры нечитаемыми. Сохраним настоящую HTML-таблицу, поместим её в подписанную область прокрутки и проверим, что к каждому значению по-прежнему можно найти заголовок.
Сохраните смысл сравнения
В расписании консультаций человек выбирает встречу по времени и формату, а затем проверяет, осталось ли для него свободное место. Это табличная задача: одна строка относится к встрече, столбцы описывают одинаковые свойства. Поэтому начнём с семантической таблицы и сохраним двумерное расположение данных на узком экране.
Основной приём — горизонтальная прокрутка внутри ограниченной области. Остальная страница остаётся по ширине окна. Такой выбор позволяет сравнивать строки, не дублируя названия столбцов в каждой карточке. Цена решения — необходимость прокрутить область до дальних колонок. Подсказку и доступ с клавиатуры добавим сразу.
Понадобятся базовые HTML и CSS, браузер, текстовый редактор и локальный Node.js для небольшого сервера. Создайте отдельную папку с index.html, style.css, server.mjs и check.mjs. Данные встреч вымышленные: это стенд для проверки чтения, а не расписание Академии.
У таблицы шесть столбцов и три строки данных. Одну тему специально сделаем длинной, а отсутствие мест обозначим словами. Такой набор помогает проверить реальные ограничения текста: несколько коротких чисел слишком хорошо помещаются даже в неудобную вёрстку.
Если нужно сначала освежить элементы таблицы, рядом есть материал «Таблицы в HTML». Здесь решаем следующую задачу: сохранить уже размеченные связи и чтение при изменении доступной ширины.
Разметьте заголовки по двум направлениям
Запишите index.html:
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Расписание консультаций</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<a class="skip" href="#main">К расписанию</a>
<main id="main">
<h1>Консультации по проектам</h1>
<p>Учебные данные. Время указано для одного условного часового пояса.</p>
<h2 id="schedule-title">Расписание на неделю</h2>
<p id="scroll-help">Если таблица шире экрана, прокрутите её по горизонтали.
С клавиатуры: перейдите к области таблицы клавишей Tab и используйте стрелки влево и вправо.</p>
<div class="table-scroll" role="region" tabindex="0"
aria-labelledby="schedule-title" aria-describedby="scroll-help">
<table>
<caption>Сравнение времени, формата и числа мест</caption>
<thead>
<tr>
<th scope="col">Тема</th>
<th scope="col">День и время</th>
<th scope="col">Формат</th>
<th scope="col">Продолжительность</th>
<th scope="col">Свободные места</th>
<th scope="col">Материалы</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Доступные формы</th>
<td><time datetime="2026-11-16T18:00">16 ноября, 18:00</time></td>
<td>Разбор примеров</td>
<td class="number">45 мин</td>
<td class="number">8</td>
<td><a href="#forms-note">План разбора форм</a></td>
</tr>
<tr>
<th scope="row">Таблицы с длинными названиями и пояснениями</th>
<td><time datetime="2026-11-17T19:00">17 ноября, 19:00</time></td>
<td>Совместная практика</td>
<td class="number">60 мин</td>
<td class="number">3</td>
<td><a href="#tables-note">План практики с таблицами</a></td>
</tr>
<tr>
<th scope="row">Асинхронный поиск</th>
<td><time datetime="2026-11-18T18:30">18 ноября, 18:30</time></td>
<td>Разбор ответов в обратном порядке</td>
<td class="number">50 мин</td>
<td>Мест нет</td>
<td><a href="#search-note">План разбора поиска</a></td>
</tr>
</tbody>
</table>
</div>
<h2>Планы встреч</h2>
<h3 id="forms-note">Доступные формы</h3>
<p>Свяжем подписи с полями и проверим сообщение об ошибке.</p>
<h3 id="tables-note">Таблицы</h3>
<p>Сравним значения по строкам и проверим чтение на узком экране.</p>
<h3 id="search-note">Поиск</h3>
<p>Воспроизведём ответы в обратном порядке и оставим свежую выдачу.</p>
<p><a href="#schedule-title">Вернуться к расписанию</a></p>
</main>
</body>
</html>
caption описывает содержание самой таблицы. Заголовки столбцов находятся в thead и имеют scope="col". Название встречи в каждой строке размечено через th scope="row". У числа мест появляется контекст по двум направлениям: тема встречи и заголовок «Свободные места».
Для такой прямоугольной структуры достаточно scope. Если позже появятся многоуровневые заголовки, объединённые ячейки и сложные группы, связи потребуется проверить заново; иногда удобнее задавать их через id и headers. Не переносите решение для простой сетки на произвольную сложную таблицу без проверки.
WCAG — рекомендации по доступности веб-контента. Программно определимые связи соответствуют задаче WCAG 1.3.1: смысл сохраняется при другом способе представления. Само наличие тегов ещё не гарантирует хорошее чтение во всех сочетаниях браузера и программы экранного доступа. Ниже будет отдельная проверка.
Обёртка получила role="region" и имя от schedule-title. Здесь роль относится к области прокрутки. Семантика таблицы остаётся у вложенного table: добавлять на неё role="grid" ради оформления не требуется. Сетка с собственным управлением стрелками — другая модель взаимодействия.
Атрибут tabindex="0" включает область в обычный порядок Tab. Он нужен, чтобы человек с клавиатурой мог сфокусировать саму прокрутку, даже если в невидимой части ещё нет доступной ссылки. Положительные значения tabindex не используем: порядок должен следовать документу.
Подсказка привязана через aria-describedby и видна всем. Название области объясняет, куда попал фокус, подсказка — какое действие доступно. Это помогает реализовать клавиатурный доступ по WCAG 2.1.1 и понятный порядок фокуса по 2.4.3.
Ограничьте прокрутку обёрткой
Сохраните style.css:
@layer base, components;
@layer base {
:root {
color: #202020;
background: #ffffff;
font: 1rem/1.55 system-ui, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; padding: 1rem; }
main { max-inline-size: 68rem; margin-inline: auto; min-inline-size: 0; }
h1 { font-size: 2rem; line-height: 1.2; }
a { color: #164bbb; text-underline-offset: .15em; }
:focus-visible { outline: 3px solid #164bbb; outline-offset: 3px; }
:target { scroll-margin-block-start: 1rem; }
}
@layer components {
.skip { display: inline-block; margin-block-end: 1rem; }
.table-scroll {
max-inline-size: 100%;
overflow-x: auto;
border: 1px solid #777777;
margin-block: 1rem 2rem;
}
table {
border-collapse: collapse;
inline-size: 100%;
min-inline-size: 52rem;
}
caption {
text-align: start;
font-weight: 600;
padding: .75rem;
}
th, td {
padding: .75rem 1rem;
border-block-end: 1px solid #777777;
text-align: start;
vertical-align: top;
}
thead th { background: #eeeeee; }
tbody th { min-inline-size: 12rem; max-inline-size: 20rem; }
td { overflow-wrap: anywhere; }
.number { text-align: end; font-variant-numeric: tabular-nums; white-space: nowrap; }
td a { display: inline-block; padding-block: .25rem; }
@media print {
.table-scroll { overflow: visible; border: 0; }
table { min-inline-size: 0; }
.skip, #scroll-help { display: none; }
}
@media (forced-colors: active) {
.table-scroll, th, td { border-color: CanvasText; }
:focus-visible { outline-color: Highlight; }
}
}
У .table-scroll максимальная ширина равна доступной ширине родителя, а overflow-x: auto разрешает горизонтальную прокрутку при необходимости. Таблица занимает ширину области, но имеет нижний предел 52rem. Когда область уже, таблица сохраняет читаемые колонки внутри неё.
Для нашего набора данных выбрали именно 52rem, чтобы длинная тема читалась по строкам, а короткие числовые значения оставались различимыми. Это отправная точка проверки, а не стандарт ширины таблиц. У каталога с двумя короткими колонками такой предел будет избыточен; у финансового отчёта с десятком показателей — недостаточен. Подбирайте его по содержимому и проверяйте длинные реальные значения.
Размер шрифта мы не уменьшаем. Пользователь может увеличить текст, а таблица получит больше прокрутки. rem связан с размером корневого шрифта, поэтому минимальная ширина тоже учитывает это изменение. Чтобы прочитать дальние колонки, придётся прокрутить область; значения останутся различимыми.
У заголовков и ячеек остался табличный тип отображения. На узкой ширине мы не назначаем display: block строкам и не прячем thead. Это сохраняет привычную модель чтения и позволяет тестировать один вариант разметки.
Числовые значения выровнены по краю и используют табличные цифры, если выбранный шрифт их поддерживает. Короткие пары «число + единица» не переносятся. Остальной текст может переноситься; overflow-wrap: anywhere у ячеек помогает пережить длинный адрес или слово без пробелов. Не применяйте white-space: nowrap ко всей таблице: одно длинное описание способно растянуть её сильнее, чем нужно.
Фокус должен быть виден. Рамка отделяет область прокрутки, а :focus-visible рисует заметный контур при фокусе с клавиатуры: это относится к WCAG 2.4.7, различимость контура — к 1.4.11.
В режиме принудительных цветов используем системные цвета для границ и фокуса. Смысл заголовка сохраняет разметка th, поэтому фоновая заливка не остаётся единственным признаком его назначения.
Для печати задайте отдельное правило. Это отдельный способ представления: перед печатью всё равно проверьте предпросмотр, потому что доступная ширина бумаги и поля отличаются от окна браузера.
Откройте пример и сравните таблицу в узком и широком окне
Сохраните server.mjs:
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/style.css', ['style.css', 'text/css; charset=utf-8']],
]);
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
const file = files.get(url.pathname);
if (!file) { res.writeHead(404).end('Not found'); return; }
try {
const body = await readFile(new URL(file[0], import.meta.url));
res.writeHead(200, { 'Content-Type': file[1] });
res.end(body);
} catch {
res.writeHead(500).end('Cannot read file');
}
});
server.listen(4173, '127.0.0.1', () => {
console.log('Open http://127.0.0.1:4173');
});
Из папки примера выполните:
node server.mjs
Откройте http://127.0.0.1:4173. Это локальный учебный сервер без внешних пакетов. Если порт уже занят, остановите другой стенд или измените число 4173 и адрес страницы.
В широком окне таблица помещается целиком. При сужении окна она остаётся внутри рамки, а пояснения до и после таблицы продолжают помещаться по ширине страницы. Сама страница не должна требовать горизонтальной прокрутки ради чтения обычного абзаца.
Проверяйте ширину около 320 CSS-пикселей и увеличение масштаба до 200%, затем более сильное увеличение, при котором видимая область становится ещё уже. Табличные данные могут требовать двумерного представления: у WCAG 1.4.10 есть соответствующее исключение для частей содержимого, которым такое расположение необходимо. Исключение не распространяется автоматически на всю страницу. Поэтому мы ограничиваем прокрутку таблицей.
На телефоне проведите пальцем по области таблицы до последнего столбца. Полоса прокрутки может скрываться настройками системы. По этой причине видимая текстовая подсказка остаётся полезной, даже если на вашем компьютере полоса всегда заметна.
Пройдите таблицу с клавиатуры
Перезагрузите страницу, чтобы начать с предсказуемого состояния, и нажимайте Tab. Сначала доступна ссылка «К расписанию», затем область таблицы. На области должен появиться контур. Стрелки влево и вправо должны перемещать содержимое горизонтально, когда оно шире окна.
Продолжайте нажимать Tab. Фокус перейдёт к ссылкам в ячейках, включая те, что изначально находились за правой границей. Браузер должен прокрутить область так, чтобы сфокусированная ссылка стала видна. Затем фокус должен выйти из таблицы к следующей ссылке документа. Проверьте обратный путь через Shift+Tab.
Если стрелки вместо ожидаемой прокрутки запускают навигацию программы экранного доступа, режим и команды зависят от программы. Не добавляйте глобальный перехват keydown, который заберёт стрелки у всех. Проверяйте обычный клавиатурный сценарий отдельно от специальных команд чтения таблиц.
Высоту области оставляем свободной, заголовок обычным. Так мы избегаем вложенной вертикальной прокрутки и перекрытия сфокусированных ссылок. Если добавите липкий заголовок, проверьте, что он не закрывает фокус: это уже дополнительное требование WCAG 2.4.11.
Проверьте связь значения с заголовками на слух
Откройте страницу с доступной вам программой экранного доступа и найдите таблицу её штатными командами. У таблицы должна быть понятная подпись. Перемещайтесь по ячейкам и проверьте, что контекст позволяет соотнести значение 3 с темой про таблицы и столбцом «Свободные места».
Сочетания клавиш и порядок объявлений зависят от программы, браузера и настроек. В отчёте запишите эту связку и конкретный результат. Фраза «экранный диктор работает» слишком общая: полезнее зафиксировать, у какой ячейки объявлены строка и столбец, а где контекст пропал.
Затем временно отключите CSS. Заголовки, подпись, значения и ссылки должны остаться в документе. Это проверка того, что смысл не зашит только в оформление. Атрибут data-label и подпись через ::before могли бы помочь визуально, но сами по себе не заменяют программную связь ячеек с заголовками.
Сравните обрезку и прокрутку на копии
Первая ошибка — скрыть всё, что не поместилось:
.table-scroll {
overflow-x: hidden;
}
На узкой ширине часть столбцов исчезнет из обычного визуального просмотра. Ссылки справа могут получить фокус неожиданно для человека, который не видит доступного способа добраться до них. Верните overflow-x: auto и проверьте крайний столбец.
Вторая ошибка выглядит привлекательнее: скрыть thead, поставить строкам и ячейкам display: block и добавить подписи из data-label. Визуально получатся карточки, однако смысл заголовков и поведение дерева доступности потребуют отдельной проверки в целевых сочетаниях браузера и вспомогательной программы. Универсальной гарантии такой трюк не даёт.
Для нашего расписания карточки ещё и затрудняют быстрое сравнение свободных мест между встречами: человек начинает читать длинные последовательности полей. Поэтому оставляем таблицу с локальной прокруткой.
Когда задача действительно состоит в чтении одной записи целиком, карточка может быть удачным отдельным представлением. Её следует проектировать как самостоятельную семантическую структуру, с видимыми подписями и без двух одновременно читаемых копий данных. Выбор зависит от действия пользователя, а не только от ширины экрана.
Сохраните матрицу проверок
| Проверка | Что должно сохраниться | Признак сбоя |
|---|---|---|
| Окно около 320 CSS-пикселей | Обычный текст помещается, таблица прокручивается внутри | Вся страница растянута по ширине таблицы |
| Увеличение текста и масштаба | Все значения доступны без уменьшения шрифта | Строки обрезаны по высоте, ячейки перекрываются |
| Tab на области | Понятное имя и видимый фокус | Фокус потерялся или область пропущена |
| Стрелки в обычном режиме браузера | Горизонтальная прокрутка фокусируемой области | Дальний столбец недоступен без мыши |
| Tab по ссылкам | Сфокусированная ссылка видна | Фокус ушёл в скрытую часть и остался там |
Чтение ячейки 3 | Контекст темы и свободных мест | Число произносится без понятной связи |
| Длинное название | Текст переносится, строки различимы | Одна ячейка растянула таблицу без ограничения |
| CSS отключён | Подпись, заголовки и данные остаются | Смысл существовал только в псевдоэлементах |
| Печать | Все столбцы доступны в предпросмотре | За рамкой печатной страницы пропали значения |
Проверьте исходники и измените данные
Оставьте сервер работать в первом терминале. Сохраните check.mjs, откройте второй терминал в папке примера и запустите в нём node check.mjs:
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const html = await readFile(new URL('index.html', import.meta.url), 'utf8');
const css = await readFile(new URL('style.css', import.meta.url), 'utf8');
assert.equal((html.match(/scope="col"/g) ?? []).length, 6);
assert.equal((html.match(/scope="row"/g) ?? []).length, 3);
assert.match(html, /class="table-scroll" role="region" tabindex="0"/);
for (const id of ['schedule-title', 'scroll-help', 'forms-note', 'tables-note', 'search-note']) {
assert.match(html, new RegExp(`id="${id}"`));
}
assert.match(html, /<caption>/);
assert.match(css, /overflow-x: auto/);
assert.match(css, /:focus-visible/);
assert.doesNotMatch(css, /(?:th|td|tr)\s*\{[^}]*display:\s*(?:block|flex|grid)/);
console.log('PASS: source contract; visual, keyboard and screen-reader checks remain manual');
Файл проверяет только договорённости исходного примера: число заголовков, наличие подписанной фокусируемой области, связей по идентификаторам и правил прокрутки. Он не умеет измерять переполнение, нажимать клавиши или слушать объявления программы экранного доступа. Эти пункты остаются в ручной матрице выше.
Для первого упражнения замените одну тему строкой втрое длиннее и добавьте в ячейку материалов длинный адрес без пробелов. Проверьте узкую ширину и доступность крайней ссылки. Затем самостоятельно добавьте столбец «Запись встречи»: у него должен быть настоящий заголовок, а отсутствие записи обозначьте понятным текстом. Повторите чтение одной новой ячейки с заголовками.
Если хотите дальше практиковать семантику и устойчивую вёрстку, выберите подходящую тему в курсах HTML Academy. Здесь оставьте себе проверяемый результат: на узком экране все данные доступны, а значение по-прежнему связано со своей строкой и столбцом.
«Доктайп» — журнал о фронтенде. Читайте, слушайте и учитесь с нами.