Общий обзор подхода к созданию компонента вкладок, аналогичного используемым в приложениях для iOS и Android.
В этой статье я хочу поделиться своими мыслями о создании компонента вкладок для веб-сайтов, который будет адаптивным и совместимым с различными устройствами и браузерами. Посмотрите демопример.
Если вы предпочитаете видео, вот версия этой статьи на YouTube:
Обзор
Вкладки — стандартный компонент систем дизайна, но они могут быть разных видов и форм. Первыми были вкладки для ПК, построенные на элементе <frame>
, теперь же у нас есть красивые мобильные компоненты с анимацией на основе «физических» свойств. Но задача у них всех одна: сэкономить место.
Сегодня основной элемент взаимодействия с вкладками — область навигации с кнопками, которые переключают видимость контента в отображаемом фрейме. Различные области контента занимают одно и то же пространство, но отображаются условно в зависимости от кнопки, выбранной в элементе навигации.
Реализация на веб-платформе
В целом, сделать такой компонент оказалось довольно просто — благодаря нескольким важным функциям веб-платформы:
scroll-snap-points
— взаимодействие жестами и с помощью клавиатуры, а также правильные позиции остановки прокрутки;- ссылки на контент посредством URL-хешей — поддержка встроенной прокрутки в браузере и передачи ссылок;
- поддержка программ чтения с экрана: разметка элементами
<a>
иid="#hash"
; prefers-reduced-motion
— плавные переходы и мгновенная прокрутка внутри страницы;- предложенная функция
@scroll-timeline
для динамического подчеркивания и изменения цвета выбранной вкладки.
HTML-код
Принцип работы здесь такой: нажимаем ссылку, в которой URL-адрес представляет состояние вложенной страницы, а затем обновляем область контента, пока браузер прокручивает до соответствующего элемента.
У нас используются элементы структурного контента: ссылки и :target
. Нам нужен список ссылок (для чего отлично подходит <nav>
) и список элементов <article>
(здесь подходит <section>
). Каждому хешу ссылки будет соответствовать section
, поэтому браузер сможет делать прокрутку по ссылке.
Например, в Chrome 89 при нажатии на ссылку фокус автоматически переключается на article
-элемент :target
— и не нужно ничего писать на JS. Пользователь может прокрутить содержимое article
обычным образом с помощью имеющегося устройства ввода. Это дополнительный контент, как указано в разметке.
Например, в Chrome 89 при нажатии на ссылку фокус автоматически переключается на article
-элемент :target
— и не нужно ничего писать на JS. Пользователь может прокрутить содержимое article
обычным образом с помощью имеющегося устройства ввода. Это дополнительный контент, как указано в разметке.
<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>
Затем я заполнил каждый article
различным количеством «рыбы», а ссылки — заголовками различной длины с изображениями для заголовков. Контент у нас есть — можно приступать к работе над макетом.
Макеты с прокруткой
В этом компоненте есть области прокрутки трех типов:
- Блок навигации (розовый цвет) использует горизонтальную прокрутку.
- Область контента (синий цвет) также использует горизонтальную прокрутку.
- Элементы
article
(зеленый цвет) используют вертикальную прокрутку.
При прокрутке используются элементы двух типов:
- Окно.
Прямоугольник с заданными размерами и стилем свойстваoverflow
. - Безразмерная поверхность.
В этом макете это списочные контейнеры: ссылкиnav
, элементыarticle
в разделахsection
и содержимоеarticle
.
Макет для <snap-tabs>
В качестве макета верхнего уровня я выбрал flex
(адаптируемый блок) с направлением column
— чтобы заголовок и section
располагались вертикально. Это первое окно прокрутки; оно скрывает всё с помощью overflow: hidden
. Заголовок и и section
будут использовать прокрутку за границы в виде отдельных зон.
<snap-tabs> <header></header> <section></section> </snap-tabs>
snap-tabs { display: flex; flex-direction: column; /* устанавливаем первичный контейнер */ overflow: hidden; position: relative; & > section { /* указываем использовать всё место */ block-size: 100%; } & > header { /* защита от случая, когдатребует 100 % */ flex-shrink: 0; /* учет особенностей различных браузеров */ 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
должен перемещаться горизонтально вместе с группой ссылок, и такой макет для header
позволяет этого добиться. Ни одного элемента с абсолютным размещением!
Далее — стили прокрутки. Оказывается, один стиль можно использовать в двух областях горизонтальной прокрутки (header
и section
), поэтому я сделал вспомогательный класс: .scroll-snap-x
.
.scroll-snap-x {
/* браузер решает, можно ли прокручивать и отображать полосы по X, Y скрыто */
overflow: auto hidden;
/* не даем создать цепочку прокрутки по X */
overscroll-behavior-x: contain;
/* прокрутка должна привязываться к дочернему элементу по X */
scroll-snap-type: x mandatory;
@media (hover: none) {
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
}
В каждом случае нужен overflow
по оси x, contain
для захвата выхода за границы прокрутки, скрытые полосы прокрутки для сенсорных устройств и, наконец, scroll-snap
для фиксации областей показа контента. Удобный порядок вкладок при использовании клавиатуры позволяет переключать фокус естественный образом. У контейнеров scroll-snap
красивый «карусельный» стиль взаимодействия при использовании с клавиатуры.
Макет для <nav>
в заголовке
Ссылки nav
должны располагаться строкой, без разрывов строк, с центрированием по вертикали, причем каждый элемент ссылки должен привязываться к контейнеру scroll-snap
. 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; } }
Стили и размеры ссылок задаются автоматически, поэтому в макете nav
нужно указать только направление и структуру наполнения — flow
. Благодаря различной ширине элементов nav
за переходом между вкладками интересно наблюдать: ширина индикатора подстраивается к новой цели. Отображение полосы прокрутки браузером будет зависеть от количества элементов.
Макет для <section>
Этот раздел представляет собой элемент flex
и должен быть основным потребителем места. Ему также необходимо создать столбцы для размещения статей. И 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
указывает сетке всегда располагать дочерние элементы в горизонтальную линию, без переноса (как нам и нужно), что позволяет заполнить родительское окно с выходом за его границы.
Иногда мне бывает трудно понять, что к чему! Этот элемент section
вписан в прямоугольник, но при этом также создает набор прямоугольников. Надеюсь, рисунки и текст помогут вам разобраться.
Макет для <article>
Пользователю необходимо дать возможность прокручивать содержимое article
, причем полосы прокрутки должны появляются только при переполнении. Эти элементы 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; }
Я решил, что article
будут привязаны к их родительскому компоненту прокрутки. Мне нравится, что элементы link
навигации и элементы article
привязываются к началу соответствующих контейнеров прокрутки: создается ощущение гармоничных взаимоотношений.
Элемент article
является дочерней сеткой, причем ее размер предопределен как область просмотра, в которой нам нужна прокрутка. Это означает, что стили высота и ширины здесь не нужны — достаточно определить переполнение. Для overflow-y
я задаю auto
, а затем захватываю взаимодействие прокрутки с помощью удобного свойства overscroll-behavior
.
Резюме по трем областям прокрутки
Ниже в настройках системы у меня выбрано «всегда показывать полосы прокрутки». Мне кажется важным сделать так, что макет работал, когда этот параметр включен: это позволяет проверить макет и оркестровку прокрутки.
Я думаю, что наличие контейнера для полос прокрутки в этом компоненте помогает четко показать, где находятся области прокрутки, в каком направлении они работают и как взаимодействуют друг с другом. Каждый из этих фреймов окна прокрутки также является родительским элементом flex
или grid
по отношению к макету.
DevTools помогают визуализировать структуру и поведение макета:
Макеты прокрутки готовы: с привязкой, ссылками на контент и возможностью использования с клавиатуры. Прочная основа для улучшения удобства использования, стиля и отличная возможность насладиться результатом.
О функциях
Дочерние элементы с привязкой к прокрутке сохраняют зафиксированное положение при изменении размера. Это означает, что коду JavaScript не нужно ничего отображать при повороте устройства или изменении размера браузера. Перейдите в Режим устройства (Device Mode) в Chromium DevTools и выберите любой режим, кроме отзывчивого (Responsive), а затем измените размер фрейма устройства. Элемент остается в поле зрения и фиксируется вместе с содержимым. Эта функция работает так с того момента, как Chromium обновил реализацию в соответствии со спецификацией. Можете почитать запись в блоге об этом.
Анимация
Цель анимации здесь — четко связать работу ссылок с откликом интерфейса. Так мы поможем пользователю с удобством (надеюсь) просмотреть весь контент. Я буду добавлять анимацию движения с определенной целью и и условиями. Надо помнить, что пользователи могут задавать предпочтения по движению в операционной системе, и я с удовольствием учитываю их в проектируемых мной интерфейсах.
Я свяжу подчеркивание вкладки с положением прокрутки элемента article
. Привязка обеспечивает не только красивое выравнивание, но и соотнесение с началом и окончанием анимации. Это позволяет элементу <nav>
, который действует как мини-карта, не терять связь с контентом. Предпочтения пользователя по движению будем проверять и с помощью CSS, и с помощью JS. В некоторых местах придется проявить особую внимательность!
Поведение прокрутки
Мы можем улучшить поведение :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
. Также обратите внимание на взаимодействие вкладок: активный элемент nav
выделяется не только подчеркиванием, но и более тёмным цветом текста. У активного элемента более высокий цветовой контраст текста и яркий акцент подчеркивания.
Всего пара строк CSS-кода — и мы позаботились о каждом (в том смысле, что учитываем предпочтения пользователей по движению на странице). Мне нравится.
@scroll-timeline
Выше я показал, как работаю со стилями плавного перехода, когда нужно ограничить движение. В этом разделе я покажу, как связать индикатор и область прокрутки. Далее будет несколько интересных экспериментов — надеюсь, вы сгораете от нетерпения.
const { matches:motionOK } = window.matchMedia(
'(prefers-reduced-motion: no-preference)'
);
Сначала я с помощью JavaScript проверяю предпочтения пользователя по движению. Если результат — false
(пользователь хочет видеть меньше движения), тогда мы не будем запускать эффекты привязки прокрутки.
if (motionOK) {
// код анимации с использованием движения
}
На момент написания статьи поддержки @scroll-timeline
в браузерах нет. Функция пребывает в виде черновой спецификации — есть только экспериментальные реализации. Однако для нее есть полифил, который я и применяю в этой демонстрации.
ScrollTimeline
И CSS, и JavaScript позволяют делать ScrollTimeline
для прокрутки, однако я выбрал JavaScript — чтобы использовать в анимации актуальные размеры элементов.
const sectionScrollTimeline = new ScrollTimeline({
scrollSource: tabsection, // snap-tabs > section
orientation: 'inline', // прокрутка в направлении потока букв
fill: 'both', // двунаправленное связывание
});
Я хочу, чтобы один элемент следовал за положением прокрутки другого. Создав ScrollTimeline
, я определяю ведущий элемент для связки с прокруткой — scrollSource
. Обычно анимация в веб-дизайне запускается в соответствии с глобальным тиком интервала времени, но с помощью sectionScrollTimeline
это можно изменить.
tabindicator.animate({
transform: ...,
width: ...,
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
Прежде чем я перейду к ключевым кадрам анимации, думаю, важно упомянуть, что ведомый элемент прокрутки — tabindicator
— будет анимироваться по специальной временной шкале — прокрутке нашего section
. Здесь мы заканчиваем привязку, но у нас отсутствует последний ингредиент — точки с отслеживанием состояния, между которыми происходит анимация. Их еще называют ключевыми кадрами.
Динамические ключевые кадры
Есть, конечно, очень мощный чисто декларативный способ делать анимацию в CSS с помощью @scroll-timeline
, но нужная мне анимация была слишком динамичной. CSS не позволяет делать переход между шириной со значением 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
. Так мы получаем четыре ключевых кадра преобразования для анимации. То же делаем и с шириной: запрашиваем ее у каждого элемента и используем как значение ключевого кадра.
Вот пример вывода с моими шрифтами и настройками браузера:
Ключевые кадры translateX
:
[...tabnavitems].map(({offsetLeft}) =>
`translateX(${offsetLeft}px)`)
// возвращает четыре элемента массива, представляющие собой четыре состояния ключевых кадров
// ["translateX(0px)", "translateX(121px)", "translateX(238px)", "translateX(464px)"]
Ключевые кадры ширины:
[...tabnavitems].map(({offsetWidth}) =>
`${offsetWidth}px`)
// возвращает четыре элемента массива, представляющие собой четыре состояния ключевых кадров
// ["121px", "117px", "226px", "67px"]
Стратегия вкратце: индикатор вкладки будет анимироваться по четырем ключевым кадрам в зависимости от положения scroll-snap
компонента прокрутки section
. Точки привязки задают четкое разграничение между ключевыми кадрами и делают анимацию более синхронной по ощущениям.
Пользователь управляет анимацией своими действиями и видит, как ширина и положение индикатора меняются от одного section
к другому, в точности следуя за прокруткой.
Возможно, вы не заметили, но я очень горжусь тем, как получилось сделать переход цвета при выборе выделенного элемента навигации.
Невыбранный элемент светло-серого цвета становится еще менее выразительным в сравнении с более контрастным выделенным. Обычно меняют цвет текста — например, при наведении курсора и выделении, — а вот делать переход цвета при прокрутке синхронно с индикатором подчеркивания — это уже следующий уровень.
Как это делается:
tabnavitems.forEach(navitem => {
navitem.animate({
color: [...tabnavitems].map(item =>
item === navitem
? `var(--text-active-color)`
: `var(--text-color)`)
}, {
duration: 1000,
fill: 'both',
timeline: sectionScrollTimeline,
}
);
});
Для каждой ссылки nav
на вкладке нужна новая анимация цвета по той же временно́й шкале, что и у индикатора подчеркивания. Я использую ту же временну́ю шкалу, что и раньше: ее задача — выдавать при прокрутке тик, поэтому мы можем использовать его в анимации любого нужного нам типа. Как и раньше, я создаю четыре ключевых кадра в цикле и получаю цвета.
[...tabnavitems].map(item =>
item === navitem
? `var(--text-active-color)`
: `var(--text-color)`)
// возвращает четыре элемента массива, представляющие собой четыре состояния ключевых кадров
// [
"var(--text-active-color)",
"var(--text-color)",
"var(--text-color)",
"var(--text-color)",
]
Ключевой кадр с цветом var(--text-active-color)
выделяет ссылку. В остальных случаях это стандартный цвет текста. Вложенный цикл делает процедуру вполне понятной: внешний цикл — это каждый элемент навигации, а внутренний — это их ключевые кадры. Я проверяю, совпадает ли элемент внешнего цикла с элементом внутреннего цикла и таким образом узнаю, когда он выбран.
Писать этот код — одно удовольствие.
Как еще улучшить наш JavaScript
Напомню: то, что я здесь показываю, в основе своей работает и без JavaScript. Однако давайте посмотрим, что можно улучшить, используя JS.
Ссылки на контент
Ссылки на контент — это скорее мобильный термин, но, думаю, что их цели вполне соответствует случай, когда URL-адрес передается непосредственно в содержимое вкладки. Браузер будет переходить на странице к идентификатору, совпадающим с хешем URL. Я обнаружил, что этот обработчик onload
действует на всех платформах.
window.onload = () => {
if (location.hash) {
tabsection.scrollLeft = document
.querySelector(location.hash)
.offsetLeft;
}
}
Синхронизация с окончанием прокрутки
Пользователи не всегда будут нажимать на вкладки или использовать клавиатуру — иногда они просто будут использовать прокрутку. Когда компонент прокрутки в section
останавливается, его положение должно совпадать с состоянием верхней панели навигации.
Так мы ожидаем окончания прокрутки:
tabsection.addEventListener('scroll', () => {
clearTimeout(tabsection.scrollEndTimer);
tabsection.scrollEndTimer = setTimeout(determineActiveTabSection, 100);
});
Каждый раз при прокрутке элементов section
мы сбрасываем время ожидания (если оно есть) и начинаем новый отсчет. Когда прокрутка элемента section
останавливается, мы не сбрасываем время ожидания, а запускаем отсчет 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();
};
Сначала мы деактивируем вкладку, которая является активной сейчас, а затем даем полученному элементу nav
атрибут активного состояния. Здесь стоит отметить вызов 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 scroll-snap
мы вложили запрос медиа, который применяет прокрутку типа smooth
, если пользователь разрешает элементы с движением. JavaScript может легко вызывать элементы прокрутки в представление, а CSS может декларативно управлять интерфейсом. Очаровательная парочка.
Заключение
Я рассказал свое видение решения этой задачи. А как ее решали бы вы? Получилась очень интересная архитектура компонентов! Кто же первый сделает версию с блек-джеком в своем любимом фреймворке? 🙂
Давайте разнообразим наши подходы и рассмотрим самые разные реализации для веб-сайтов. Создайте свою версию на Glitch, твитните мне, и я добавлю добавлю ее в раздел Ремиксы сообщества ниже.
Ремиксы сообщества
- Версия @devnook, @rob_dodson и @DasSurma с веб-компонентами (статья).
- Версия @jhvanderschee с кнопками: Codepen.