Навигация
Вкладки
Горизонтальный переключатель видов внутри одного раздела. По умолчанию — на ссылках: переключение экрана это роутинг, и JS ему не нужен.
Разметка
<nav class="inst-tabs" aria-label="Разделы прогонов">
<a class="inst-tab" href="#" aria-current="page">Активные</a>
<a class="inst-tab" href="#">История</a>
<a class="inst-tab" href="#">Расписание</a>
</nav>
Использование
<nav class="inst-tabs" aria-label="Разделы прогонов">
<a class="inst-tab" href="/runs/active" aria-current="page">Активные</a>
<a class="inst-tab" href="/runs/history">История</a>
</nav>
| Что | Обязательно | Почему |
|---|---|---|
<nav> + aria-label на роутинговом варианте |
да | Навигаций на экране несколько, и без имени они неразличимы |
aria-current="page" |
да | Активный вид при переключении адресом |
role="tablist" + role="tab" + aria-selected + aria-controls |
да, только у настоящих вкладок | Роль без стрелочной навигации и панели — обещание, которого никто не выполнит |
type="button" у вкладки-кнопки |
да | Иначе внутри формы она её отправит |
Когда использовать
| Используйте | Возьмите другое |
|---|---|
| Несколько видов одних и тех же данных, у каждого свой адрес | Постоянный список разделов приложения — навигация в боковой колонке |
| 2–7 видов, названия которых помещаются в строку | Выбор одного значения из равных — сегментированный контрол: это ввод, а не переход |
Настоящие вкладки внутри страницы — с role="tablist", см. ниже |
Длинный список — селект: вкладки не сворачиваются в список |
| Путь до текущего экрана — рядом | Иерархия, а не братья — крошки |
Варианты
Ссылки или настоящие вкладки — два разных случая, и разметка у них разная. Выбирается тот, который правдив:
| Случай | Разметка | Признак |
|---|---|---|
| Переключение экрана | <nav class="inst-tabs"> + <a class="inst-tab" aria-current="page"> |
У каждого вида свой адрес. Средний клик открывает вкладку в новой вкладке браузера |
| Панели внутри одной страницы | role="tablist" + role="tab" + aria-selected + aria-controls |
Адрес не меняется, содержимое подменяется на месте |
Оформление ловит оба: селектор кита реагирует и на
[aria-current]:not([aria-current="false"]), и на [aria-selected="true"].
Поэтому вопрос «какой класс поставить» не возникает — возникает вопрос «какая
разметка правдива».
Настоящие вкладки
role="tablist" ставится только если вкладки настоящие. Роль обещает
скринридеру стрелочную навигацию и связь с панелью; необеспеченное обещание
хуже отсутствующего.
Разметка
<div class="inst-tabs" role="tablist" aria-label="Свойства объекта">
<button class="inst-tab" type="button" role="tab" id="t-geom"
aria-selected="true" aria-controls="p-geom" tabindex="0">Геометрия</button>
<button class="inst-tab" type="button" role="tab" id="t-mat"
aria-selected="false" aria-controls="p-mat" tabindex="-1">Материал</button>
<button class="inst-tab" type="button" role="tab" id="t-phys"
aria-selected="false" aria-controls="p-phys" tabindex="-1">Физика</button>
</div>
<div id="p-geom" role="tabpanel" aria-labelledby="t-geom" tabindex="0">Вершин 12 480, полигонов 6 240.</div>
<div id="p-mat" role="tabpanel" aria-labelledby="t-mat" tabindex="0" hidden>Стандартный PBR, две текстуры.</div>
<div id="p-phys" role="tabpanel" aria-labelledby="t-phys" tabindex="0" hidden>Выпуклая оболочка, масса 4,2 кг.</div>
Пример живой: войдите Tab и нажмите ← или →. Отметка переносится вместе
с фокусом, Home и End работают, перебор идёт по кругу.
Стрелки, Home, End и бегущий tabindex выполняет
kit.js. Показать нужную панель — приложение:
какая панель за какой вкладкой, кит не знает.
Состояния
Разметка
<nav class="inst-tabs" aria-label="Пример активной вкладки">
<a class="inst-tab" href="#" aria-current="page">Активная</a>
<a class="inst-tab" href="#">Обычная</a>
<a class="inst-tab" href="#">Ещё одна</a>
</nav>
| Признак активной | Что даёт |
|---|---|
| Подчёркивание | border-block-end шириной --size-marker цветом --accent-solid. Носитель наравне с цветом |
| Цвет подписи | --text-primary вместо --text-secondary |
| Начертание | --weight-medium |
Полоса вкладки перекрывает линию контейнера отрицательным полем в --hairline,
а не сдвигом на полпикселя: браузер не рисует рамку тоньше физического
пикселя, и остаток был бы виден на каждом стыке.
JS
Подключите модуль один раз на страницу.
<script type="module" src="src/kit.js"></script>
Что делает кит
Пример в разделе «Настоящие вкладки» живой: ←, →, Home, End, перебор по кругу.
Полосу объявляет role="tablist", и кит выполняет контракт этой роли: ← и
→ между вкладками, Home, End, перебор по кругу, один Tab на всю
полосу. aria-selected переносится и стрелкой, и щелчком.
События
inst:select всплывает с выбранной вкладки, detail — { value }:
data-value, если он есть, иначе подпись.
tabs.addEventListener('inst:select', (e) => {
for (const panel of panels) {
panel.hidden = panel.id !== e.detail.value;
}
});
Что остаётся приложению
Показать нужную панель и спрятать остальные. Какая панель за какой вкладкой, знает только приложение — кит видит разметку, а не смысл.
Композиции
С иконкой и счётчиком
Разметка
<nav class="inst-tabs" aria-label="Пример со счётчиком">
<a class="inst-tab" href="#" aria-current="page">
<svg class="inst-icon" aria-hidden="true"><use href="#i-list"/></svg>Очередь
<span class="inst-badge">7</span></a>
<a class="inst-tab" href="#">История</a>
</nav>
Зазор внутри вкладки — --space-3, между вкладками — --space-6: расстояние
между соседями обязано быть заметно больше, чем внутри одной, иначе иконка
следующей читается как часть предыдущей.
Правила
Так Роутинг на ссылках
Каждый вид — свой адрес. Средний клик, история браузера и копирование ссылки работают без JS.
Не так role="tablist" без стрелочной навигации
Роль обещает скринридеру стрелки и связь с панелью. Необеспеченное обещание хуже отсутствующего.
Так Активная вкладка подчёркиванием
Носитель наравне с цветом. aria-current или aria-selected сообщает то же
самое скринридеру.
Не так Вкладки вместо выбора значения
Переключение вида — переход; выбор одного из равных значений — ввод, то есть сегментированный контрол.
Доступность
| Клавиатура, роутинг | Нативные ссылки: Tab — обход, Enter — переход |
| Клавиатура, настоящие вкладки | Tab входит в полосу один раз, стрелки переключают, — это JS приложения. Без него role="tablist" ставить нельзя |
| Цвет не единственный носитель | Активная вкладка несёт подчёркивание и aria-current / aria-selected |
| Прокрутка | Полоса прокручивается по горизонтали, скроллбар скрыт визуально, но прокрутка колесом и с клавиатуры остаётся |
| Контраст | Неактивная подпись --text-secondary держит 4.5:1 в шести темах |
| Фокус | Кольцо :focus-visible от базового слоя, полоса вкладок его не обрезает |
API
| Имя | Значение | Что делает |
|---|---|---|
| класс | ||
inst-tabs | — | Полоса вкладок. Нижняя линия и горизонтальная прокрутка без видимого скроллбара |
inst-tab | — | Вкладка. Ссылка или кнопка с role="tab" |
| токен | ||
--space-3 | 6px | |
--space-4 | 8px | |
--space-6 | 16px | |
--text-sm | 0.8125rem | |
--weight-medium | 500 | |
--size-marker | 2px | |
--hairline | 1px | |
--border | light-dark(oklch(0 0 0 / 0.12), oklch(1 0 0 / 0.11)) | |
--accent-solid | var(--a-4) | |
--text-primary | light-dark(var(--n-12), var(--n-1)) | |
--text-secondary | light-dark(var(--n-9), var(--n-5)) | |
Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument