Базовый обзор того, как создать компонент вкладок, аналогичный тем, что используются в приложениях iOS и Android.
В этой публикации я хочу поделиться мыслями о создании адаптивного компонента вкладок для веб-сайтов, поддерживающего различные устройства ввода и работающего в различных браузерах. Попробуйте демо .
Если вы предпочитаете видео, вот версия этого поста на YouTube:
Обзор
Вкладки — распространённый компонент дизайн-систем, но могут принимать самые разные формы. Сначала были вкладки для настольных компьютеров, построенные на элементе <frame>
, а теперь у нас есть сложные мобильные компоненты, которые анимируют контент на основе физических свойств. Все они стремятся к одному: экономить место.
Сегодня неотъемлемой частью пользовательского опыта работы с вкладками является область навигации с кнопками, которая переключает видимость контента в рамке дисплея. Многие области контента занимают одно и то же пространство, но отображаются условно в зависимости от выбранной кнопки навигации.

Веб-тактики
В целом я обнаружил, что этот компонент довольно прост в создании, благодаря нескольким важным функциям веб-платформы:
-
scroll-snap-points
для элегантного взаимодействия с помощью прокрутки и клавиатуры с соответствующими позициями остановки прокрутки - Глубокие ссылки через хеши URL для поддержки привязки прокрутки страницы и обмена с помощью браузера
- Поддержка экранного чтения с разметкой элементов
<a>
иid="#hash"
-
prefers-reduced-motion
для включения плавных переходов и мгновенной прокрутки страницы - Встроенная веб-функция
@scroll-timeline
для динамического подчеркивания и изменения цвета выбранной вкладки
HTML-код
По сути, UX здесь такой: щелкните ссылку, URL-адрес отобразит состояние вложенной страницы, а затем увидите обновление области содержимого по мере того, как браузер прокручивает страницу до соответствующего элемента.
Здесь есть несколько структурных элементов контента: ссылки и :target
. Нам нужен список ссылок, для чего отлично подходит элемент <nav>
, и список элементов <article>
, для чего отлично подходит элемент <section>
. Каждый хеш ссылки будет соответствовать разделу, позволяя браузеру прокручивать содержимое с помощью якорей.
Например, нажатие на ссылку автоматически устанавливает фокус на статью :target
в Chrome 89, без необходимости в JavaScript. После этого пользователь может прокручивать содержимое статьи с помощью устройства ввода, как обычно. Это дополнительный контент, как указано в разметке.
Для организации вкладок я использовал следующую разметку:
<snap-tabs>
<header>
<nav>
<a></a>
<a></a>
<a></a>
<a></a>
</nav>
</header>
<section>
<article></article>
<article></article>
<article></article>
<article></article>
</section>
</snap-tabs>
Я могу установить связи между элементами <a>
и <article>
с помощью свойств href
и id
следующим образом:
<snap-tabs>
<header>
<nav>
<a href="#responsive"></a>
<a href="#accessible"></a>
<a href="#overscroll"></a>
<a href="#more"></a>
</nav>
</header>
<section>
<article id="responsive"></article>
<article id="accessible"></article>
<article id="overscroll"></article>
<article id="more"></article>
</section>
</snap-tabs>
Затем я заполнил статьи разным количеством лоремов, а ссылки — заголовками разной длины и с изображениями. Имея готовый контент, мы можем приступить к верстке.
Прокручиваемые макеты
В этом компоненте есть 3 различных типа областей прокрутки:
- Навигация (розовая) прокручивается горизонтально.
- Область содержимого (синяя) прокручивается горизонтально.
- Каждый элемент статьи (зеленый) можно прокручивать по вертикали.

В прокрутке задействованы два различных типа элементов:
- Окно
Ящик с определенными размерами, имеющий стиль свойстваoverflow
. - Поверхность увеличенного размера
В этом макете это контейнеры списка: навигационные ссылки, статьи разделов и содержимое статей.
макет <snap-tabs>
В качестве верхнего уровня я выбрал Flexbox. Я задал направление « column
, чтобы заголовок и раздел располагались вертикально. Это наше первое окно с прокруткой, и оно скрывает всё, скрывая переполнение. Скоро заголовок и раздел будут использовать прокрутку как отдельные зоны.
<snap-tabs> <header></header> <section></section> </snap-tabs>
snap-tabs { display: flex; flex-direction: column; /* establish primary containing box */ overflow: hidden; position: relative; & > section { /* be pushy about consuming all space */ block-size: 100%; } & > header { /* defend againstneeding 100% */ flex-shrink: 0; /* fixes cross browser quarks */ min-block-size: fit-content; } }
Возвращаясь к красочной диаграмме с тремя свитками:
-
<header>
теперь готов стать (розовым) контейнером прокрутки. -
<section>
подготовлен для использования в качестве (синего) контейнера прокрутки.
Кадры, которые я выделил ниже с помощью VisBug, помогают нам увидеть окна , созданные контейнерами прокрутки.

Макет вкладок <header>
заголовок>
Следующий макет почти такой же: я использую flex для создания вертикального порядка.
<snap-tabs> <header> <nav></nav> <span class="snap-indicator"></span> </header> <section></section> </snap-tabs>
header { display: flex; flex-direction: column; }
.snap-indicator
должен перемещаться горизонтально вместе с группой ссылок, и этот макет заголовка помогает это сделать. Здесь нет элементов с абсолютным позиционированием!

Далее, стили прокрутки. Оказывается, мы можем использовать общие стили прокрутки для двух областей горизонтальной прокрутки (заголовка и раздела), поэтому я создал служебный класс .scroll-snap-x
.
.scroll-snap-x {
/* browser decide if x is ok to scroll and show bars on, y hidden */
overflow: auto hidden;
/* prevent scroll chaining on x scroll */
overscroll-behavior-x: contain;
/* scrolling should snap children on x */
scroll-snap-type: x mandatory;
@media (hover: none) {
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
}
Для каждого элемента требуется переполнение по оси X, ограничение прокрутки для предотвращения перепрокрутки, скрытые полосы прокрутки для сенсорных устройств и, наконец, привязка прокрутки для фиксации областей отображения контента. Наш порядок вкладок на клавиатуре доступен, и любое взаимодействие с ним естественным образом направляет фокус. Контейнеры с привязкой прокрутки также поддерживают взаимодействие с клавиатурой в стиле карусели.
Макет заголовка вкладки <nav>
Ссылки навигации должны быть расположены в одну строку без переносов, выровнены по вертикали и центрированы, а каждый элемент ссылки должен быть привязан к контейнеру прокрутки. Быстрая работа для CSS 2021!
<nav> <a></a> <a></a> <a></a> <a></a> </nav>
nav { display: flex; & a { scroll-snap-align: start; display: inline-flex; align-items: center; white-space: nowrap; } }
Каждая ссылка сама определяет свой стиль и размер, поэтому в макете навигации достаточно указать только направление и порядок. Уникальная ширина элементов навигации делает переход между вкладками интересным, поскольку индикатор подстраивает свою ширину под новую цель. В зависимости от количества элементов браузер отображает полосу прокрутки или нет.

Макет вкладок <section>
раздел>
Этот раздел — гибкий элемент, который должен быть основным потребителем пространства. Он также должен создавать колонки для размещения статей. И снова, быстрая работа для CSS 2021! Значение block-size: 100%
растягивает этот элемент, чтобы максимально заполнить родительский элемент, а затем для его собственной разметки создаёт ряд колонок, ширина которых составляет 100%
от ширины родительского элемента. Процентные значения здесь отлично работают, поскольку мы задали строгие ограничения для родительского элемента.
<section> <article></article> <article></article> <article></article> <article></article> </section>
section { block-size: 100%; display: grid; grid-auto-flow: column; grid-auto-columns: 100%; }
Как будто мы говорим: «Расширяйтесь вертикально как можно сильнее, напористо» (вспомните заголовок, который мы установили как flex-shrink: 0
: это защита от этого расширения), что задаёт высоту строки для набора столбцов полной высоты. Стиль auto-flow
заставляет сетку всегда располагать дочерние элементы горизонтально, без переноса, что как раз то, что нам нужно: чтобы они выходили за пределы родительского окна.

Иногда мне сложно это понять! Этот элемент раздела вписывается в рамку, но при этом создаёт набор рамок. Надеюсь, иллюстрации и пояснения помогут.
Макет вкладок <article>
статья>
Пользователь должен иметь возможность прокручивать содержимое статьи, а полосы прокрутки должны появляться только при переполнении. Эти элементы статьи расположены в удобном месте. Они одновременно являются родительским и дочерним элементом прокрутки. Браузеру приходится обрабатывать сложные взаимодействия с сенсорным экраном, мышью и клавиатурой.
<article> <h2></h2> <p></p> <p></p> <h2></h2> <p></p> <p></p> ... </article>
article { scroll-snap-align: start; overflow-y: auto; overscroll-behavior-y: contain; }
Я решил привязать статьи к родительскому скроллеру. Мне очень нравится, как элементы навигационных ссылок и статьи привязываются к началу строки соответствующих контейнеров скроллера. Это выглядит и ощущается как гармоничное сочетание.

Статья — дочерний элемент сетки, и её размер предопределен в соответствии с областью просмотра, которую мы хотим обеспечить при прокрутке. Это значит, что мне не нужны стили высоты и ширины, нужно лишь определить, как она переполняется. Я устанавливаю для свойства overflow-y значение auto, а затем также отлавливаю взаимодействие с прокруткой с помощью удобного свойства overscroll-behavior.
Обзор 3 областей прокрутки
Ниже я выбрал в настройках системы опцию «Всегда показывать полосы прокрутки». Думаю, корректная работа макета при включённой этой настройке вдвойне важна, поскольку мне нужно будет проверять макет и организацию прокрутки.

Думаю, наличие поля прокрутки в этом компоненте помогает чётко показать, где находятся области прокрутки, какое направление они поддерживают и как они взаимодействуют друг с другом. Обратите внимание, что каждый из этих фреймов окна прокрутки также является родительским элементом Flex или Grid для макета.
DevTools может помочь нам визуализировать это:

Макеты прокрутки готовы: привязка, глубокая ссылка и управление с клавиатуры. Прочная основа для улучшения пользовательского опыта, стиля и удовольствия.
Выделение функции
Дочерние элементы, привязанные к прокрутке, сохраняют своё заблокированное положение при изменении размера. Это означает, что JavaScript не нужно отображать какие-либо элементы при повороте устройства или изменении размера окна браузера. Попробуйте это в режиме устройства в Chromium DevTools, выбрав любой режим, кроме Responsive , а затем изменив размер рамки устройства. Обратите внимание, что элемент остаётся видимым и заблокированным вместе со своим содержимым. Эта функция доступна с тех пор, как Chromium обновил свою реализацию в соответствии со спецификацией. Вот запись в блоге об этом.
Анимация
Цель анимации здесь — чётко связать взаимодействия с обратной связью пользовательского интерфейса. Это помогает пользователю (надеюсь) беспрепятственно ориентироваться во всём контенте. Я буду добавлять движение целенаправленно и по обстоятельствам. Теперь пользователи могут задавать настройки движения в своей операционной системе, и мне очень нравится учитывать их предпочтения в своих интерфейсах.
Я свяжу подчёркивание табуляцией с позицией прокрутки статьи. Привязка — это не только красивое выравнивание, но и привязка начала и конца анимации. Это позволяет сохранить связь <nav>
, который действует как мини-карта , с контентом. Мы будем проверять настройки движения пользователя как с помощью CSS, так и с помощью JavaScript. Есть несколько важных моментов, на которые стоит обратить внимание!
Поведение прокрутки
Есть возможность улучшить поведение движения как :target
, так и element.scrollIntoView()
. По умолчанию оно мгновенное. Браузер просто устанавливает позицию прокрутки. А что, если мы хотим перейти к этой позиции прокрутки, а не мигать там?
@media (prefers-reduced-motion: no-preference) {
.scroll-snap-x {
scroll-behavior: smooth;
}
}
Поскольку мы добавляем движение, которое пользователь не контролирует (например, прокрутку), мы применяем этот стиль только в том случае, если в операционной системе пользователя нет предпочтений относительно уменьшения движения. Таким образом, мы добавляем движение прокрутки только для тех, кого оно устраивает.
Индикатор вкладок
Цель этой анимации — помочь связать индикатор с состоянием контента. Я решил использовать цветные переходы border-bottom
страницы для пользователей, предпочитающих минимальную анимацию, и анимацию скольжения при прокрутке с плавным переходом цвета для пользователей, которые не против движения.
В Chromium Devtools я могу переключать настройки и демонстрировать два разных стиля перехода. Разработка доставила мне массу удовольствия.
@media (prefers-reduced-motion: reduce) {
snap-tabs > header a {
border-block-end: var(--indicator-size) solid hsl(var(--accent) / 0%);
transition: color .7s ease, border-color .5s ease;
&:is(:target,:active,[active]) {
color: var(--text-active-color);
border-block-end-color: hsl(var(--accent));
}
}
snap-tabs .snap-indicator {
visibility: hidden;
}
}
Я скрываю .snap-indicator
, когда пользователь предпочитает сдержанное движение, так как он мне больше не нужен. Затем я заменяю его стилями border-block-end
и transition
. Также обратите внимание, что при взаимодействии с вкладками активный элемент навигации не только подчёркнут фирменным шрифтом, но и имеет более тёмный цвет текста. У активного элемента более высокая контрастность цвета текста и яркий подсвечивающий акцент.
Всего несколько дополнительных строк CSS — и кто-то почувствует себя увиденным (в том смысле, что мы вдумчиво учитываем его предпочтения в движении). Мне это нравится.
@scroll-timeline
В предыдущем разделе я показал, как работаю со стилями плавного перехода с уменьшенным движением, а в этом разделе покажу, как связал индикатор и область прокрутки. Дальше нас ждёт несколько интересных экспериментов. Надеюсь, вы в таком же восторге, как и я.
const { matches:motionOK } = window.matchMedia(
'(prefers-reduced-motion: no-preference)'
);
Сначала я проверяю предпочтения пользователя в отношении движения через JavaScript. Если результат — false
, то есть пользователь предпочитает ограниченное движение, то мы не будем запускать никакие эффекты движения, связанные с прокруткой.
if (motionOK) {
// motion based animation code
}
На момент написания этой статьи браузеры не поддерживают @scroll-timeline
. Это черновая версия спецификации , содержащая только экспериментальные реализации. Однако для неё есть полифилл, который я использую в этой демонстрации.
ScrollTimeline
Хотя и CSS, и JavaScript могут создавать временные шкалы прокрутки, я выбрал JavaScript, чтобы иметь возможность использовать динамические измерения элементов в анимации.
const sectionScrollTimeline = new ScrollTimeline({
scrollSource: tabsection, // snap-tabs > section
orientation: 'inline', // scroll in the direction letters flow
fill: 'both', // bi-directional linking
});
Я хочу, чтобы один элемент следовал за позицией прокрутки другого, и, создавая ScrollTimeline
, я определяю драйвер ссылки прокрутки — scrollSource
. Обычно анимация в вебе выполняется в соответствии с глобальным временным интервалом, но с помощью пользовательского sectionScrollTimeline
в памяти я могу всё это изменить.
tabindicator.animate({
transform: ...,
width: ...,
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
Прежде чем перейти к ключевым кадрам анимации, важно отметить, что элемент прокрутки, tabindicator
, будет анимирован на основе пользовательской временной шкалы, прокрутки нашего раздела. Это завершает связь, но не хватает последнего компонента — точек с состоянием, между которыми должна выполняться анимация, также известных как ключевые кадры.
Динамические ключевые кадры
Существует действительно мощный декларативный CSS-метод для анимации с помощью @scroll-timeline
, но выбранная мной анимация оказалась слишком динамичной. Нет возможности переходить между значениями auto
ширины и нет возможности динамически создавать несколько ключевых кадров на основе длины дочерних элементов.
Однако JavaScript знает, как получить эту информацию, поэтому мы самостоятельно пройдемся по дочерним элементам и получим вычисленные значения во время выполнения:
tabindicator.animate({
transform: [...tabnavitems].map(({offsetLeft}) =>
`translateX(${offsetLeft}px)`),
width: [...tabnavitems].map(({offsetWidth}) =>
`${offsetWidth}px`)
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
Для каждого tabnavitem
деструктурируем позицию offsetLeft
и возвращаем строку, которая использует её в качестве значения translateX
. Это создаёт 4 ключевых кадра преобразования для анимации. То же самое делается для width: для каждого элемента запрашивается его динамическая ширина, а затем значение используется в качестве ключевого кадра.
Вот пример вывода, основанный на моих шрифтах и настройках браузера:
Ключевые кадры TranslateX:
[...tabnavitems].map(({offsetLeft}) =>
`translateX(${offsetLeft}px)`)
// results in 4 array items, which represent 4 keyframe states
// ["translateX(0px)", "translateX(121px)", "translateX(238px)", "translateX(464px)"]
Ключевые кадры ширины:
[...tabnavitems].map(({offsetWidth}) =>
`${offsetWidth}px`)
// results in 4 array items, which represent 4 keyframe states
// ["121px", "117px", "226px", "67px"]
Подводя итог стратегии, индикатор вкладок теперь будет анимироваться в четырёх ключевых кадрах в зависимости от положения привязки ползунка скроллера раздела. Точки привязки создают чёткое разграничение между ключевыми кадрами и усиливают ощущение синхронизированности анимации.

Пользователь управляет анимацией посредством взаимодействия, наблюдая, как ширина и положение индикатора меняются от одного раздела к другому, идеально следуя за прокруткой.
Возможно, вы не заметили, но я очень горжусь изменением цвета при выборе выделенного элемента навигации.
Невыделенный светло-серый цвет выглядит ещё более отодвинутым назад, когда выделенный элемент более контрастен. Обычно цвет текста меняется, например, при наведении курсора и при выделении, но переход цвета при прокрутке, синхронизированный с индикатором подчёркивания, — это уже следующий уровень.
Вот как я это сделал:
tabnavitems.forEach(navitem => {
navitem.animate({
color: [...tabnavitems].map(item =>
item === navitem
? `var(--text-active-color)`
: `var(--text-color)`)
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
});
Каждой навигационной ссылке на вкладке нужна эта новая цветовая анимация, отслеживающая ту же временную шкалу прокрутки, что и индикатор подчёркивания. Я использую ту же временную шкалу, что и раньше: поскольку её роль — генерировать импульс при прокрутке, мы можем использовать этот импульс в любой анимации. Как и раньше, я создаю четыре ключевых кадра в цикле и возвращаю цвета.
[...tabnavitems].map(item =>
item === navitem
? `var(--text-active-color)`
: `var(--text-color)`)
// results in 4 array items, which represent 4 keyframe states
// [
"var(--text-active-color)",
"var(--text-color)",
"var(--text-color)",
"var(--text-color)",
]
Ключевой кадр с цветом var(--text-active-color)
выделяет ссылку, а в остальном это стандартный цвет текста. Вложенный цикл делает это относительно простым, поскольку внешний цикл — это каждый элемент навигации, а внутренний — персональные ключевые кадры каждого элемента навигации. Я проверяю, совпадает ли элемент внешнего цикла с элементом внутреннего цикла, и использую это, чтобы определить, выбран ли он.
Мне было очень весело писать это. Очень весело.
Еще больше улучшений JavaScript
Стоит напомнить, что ядро того, что я вам здесь показываю, работает без JavaScript. Итак, давайте посмотрим, как можно улучшить его, когда JavaScript станет доступен.
Глубокие ссылки
Глубокие ссылки — это скорее мобильный термин, но, думаю, их предназначение здесь достигается благодаря вкладкам: вы можете поделиться URL-адресом непосредственно на содержимое вкладки. Браузер перейдет на страницу по идентификатору, соответствующему хешу URL-адреса. Я обнаружил, что этот обработчик onload
работает на всех платформах.
window.onload = () => {
if (location.hash) {
tabsection.scrollLeft = document
.querySelector(location.hash)
.offsetLeft;
}
}
Синхронизация конца прокрутки
Наши пользователи не всегда нажимают на кнопки или используют клавиатуру, иногда они просто прокручивают страницу, как и должно быть. Когда полоса прокрутки раздела останавливается, её положение должно соответствовать верхней панели навигации.
Вот как я жду окончания прокрутки: js tabsection.addEventListener('scroll', () => { clearTimeout(tabsection.scrollEndTimer); tabsection.scrollEndTimer = setTimeout(determineActiveTabSection, 100); });
При каждой прокрутке разделов сбрасывайте тайм-аут, если он есть, и начинайте новый. При остановке прокрутки разделов не сбрасывайте тайм-аут и срабатывайте через 100 мс после паузы. При срабатывании тайм-аута вызовите функцию, которая определяет, где остановился пользователь.
const determineActiveTabSection = () => {
const i = tabsection.scrollLeft / tabsection.clientWidth;
const matchingNavItem = tabnavitems[i];
matchingNavItem && setActiveTab(matchingNavItem);
};
Если предположить, что прокрутка зафиксирована, то деление текущего положения прокрутки на ширину области прокрутки должно дать целое число, а не десятичное. Затем я пытаюсь получить navitem из нашего кэша по этому вычисленному индексу, и если он что-то находит, я отправляю это совпадение для активации.
const setActiveTab = tabbtn => {
tabnav
.querySelector(':scope a[active]')
.removeAttribute('active');
tabbtn.setAttribute('active', '');
tabbtn.scrollIntoView();
};
Установка активной вкладки начинается с очистки всех активных вкладок, а затем присваивания входящему элементу навигации атрибута «активное состояние». Вызов scrollIntoView()
имеет интересное взаимодействие с CSS, на которое стоит обратить внимание.
.scroll-snap-x {
overflow: auto hidden;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
@media (prefers-reduced-motion: no-preference) {
scroll-behavior: smooth;
}
}
В CSS-утилите горизонтальной прокрутки мы добавили медиазапрос, который применяет smooth
прокрутку, если пользователь толерантен к движению. JavaScript может свободно вызывать прокрутку элементов, а CSS может декларативно управлять пользовательским интерфейсом. Иногда они создают довольно забавные сочетания.
Заключение
Теперь, когда вы знаете, как я это сделал, как бы поступили вы?! Получается интересная архитектура компонентов! Кто собирается сделать первую версию со слотами в своём любимом фреймворке? 🙂
Давайте разнообразим наши подходы и изучим все способы разработки в интернете. Создайте Glitch , отправьте мне твит своей версии, и я добавлю её в раздел «Ремиксы сообщества» ниже.
Ремиксы сообщества
- @devnook , @rob_dodson и @DasSurma с веб-компонентами: статья .
- @jhvanderschee с кнопками: Codepen .