---
layout: post
title: Создание компонента вкладок
subhead: Общий обзор подхода к созданию компонента вкладок, аналогичного используемым в приложениях для iOS и Android.
authors:
  - adamargyle
description: Общий обзор подхода к созданию компонента вкладок, аналогичного используемым в приложениях для iOS и Android.
date: 2021-02-17
hero: image/admin/sq79nDAthaQGcdQkqazJ.png
tags:
  - blog
  - css
  - dom
  - javascript
  - layout
  - mobile
  - ux
---

В этой статье я хочу поделиться своими мыслями о создании компонента вкладок для веб-сайтов, который будет адаптивным и совместимым с различными устройствами и браузерами. Посмотрите [демопример](https://gui-challenges.web.app/tabs/dist/).

<figure data-size="full">   {% Video     src="video/vS06HQ1YTsbMKSFTIPl2iogUQP73/IBDNCMVCysfM9fYC9bnP.mp4",     autoplay="true",     loop="true",     muted="true"   %}   <figcaption>     <a href="https://gui-challenges.web.app/tabs/dist/">Демопример</a>   </figcaption></figure>

Если вы предпочитаете видео, вот версия этой статьи на YouTube:

{% YouTube 'mMBcHcvxuuA' %}

## Обзор

Вкладки — стандартный компонент систем дизайна, но они могут быть разных видов и форм. Первыми были вкладки для ПК, построенные на элементе `<frame>`, теперь же у нас есть красивые мобильные компоненты с анимацией на основе «физических» свойств. Но задача у них всех одна: сэкономить место.

Сегодня основной элемент взаимодействия с вкладками — область навигации с кнопками, которые переключают видимость контента в отображаемом фрейме. Различные области контента занимают одно и то же пространство, но отображаются условно в зависимости от кнопки, выбранной в элементе навигации.

<figure>   {% Img     src="image/vS06HQ1YTsbMKSFTIPl2iogUQP73/eAaQ44VAmzVOO9Cy5Wc8.png",     alt="Коллаж довольно хаотичный из-за огромного разнообразия стилей, которые применяются в веб-дизайне к концепции этого компонента",     width="800", height="500"   %}   <figcaption>     Коллаж, представляющий различные стили компонента вкладок в веб-дизайне за последние 10 лет   </figcaption></figure>

## Реализация на веб-платформе

В целом, сделать такой компонент оказалось довольно просто — благодаря нескольким важным функциям веб-платформы:

- `scroll-snap-points` — взаимодействие жестами и с помощью клавиатуры, а также правильные позиции остановки прокрутки;
- [ссылки на контент](https://en.wikipedia.org/wiki/Deep_linking) посредством URL-хешей — поддержка встроенной прокрутки в браузере и передачи ссылок;
- поддержка программ чтения с экрана: разметка элементами `<a>` и `id="#hash"`;
- `prefers-reduced-motion` — плавные переходы и мгновенная прокрутка внутри страницы;
- предложенная функция `@scroll-timeline` для динамического подчеркивания и изменения цвета выбранной вкладки.

### HTML-код {: #markup }

Принцип работы здесь такой: нажимаем ссылку, в которой URL-адрес представляет состояние вложенной страницы, а затем обновляем область контента, пока браузер прокручивает до соответствующего элемента.

У нас используются элементы структурного контента: ссылки и `:target`. Нам нужен список ссылок (для чего отлично подходит `<nav>`) и список элементов `<article>` (здесь подходит `<section>`). Каждому хешу ссылки будет соответствовать <code>section</code>, поэтому браузер сможет делать прокрутку по ссылке.

<figure>   {% Video     src="video/vS06HQ1YTsbMKSFTIPl2iogUQP73/Pr8BrPDjq8ga9NyoHLJk.mp4",     autoplay="true",     loop="true",     muted="true"   %}   <figcaption>     При нажатии кнопки с ссылкой меняется контент в фокусе   </figcaption></figure>

Например, в Chrome 89 при нажатии на ссылку фокус автоматически переключается на `article`-элемент <code>:target</code> — и не нужно ничего писать на JS. Пользователь может прокрутить содержимое <code>article</code> обычным образом с помощью имеющегося устройства ввода. Это дополнительный контент, как указано в разметке.

Например, в Chrome 89 при нажатии на ссылку фокус автоматически переключается на <code>article</code>-элемент <code>:target</code> — и не нужно ничего писать на JS. Пользователь может прокрутить содержимое <code>article</code> обычным образом с помощью имеющегося устройства ввода. Это дополнительный контент, как указано в разметке.

```html
<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` следующим образом:

```html/3,10
<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>
```

Затем я заполнил каждый <code>article</code> различным количеством «рыбы», а ссылки — заголовками различной длины с изображениями для заголовков. Контент у нас есть — можно приступать к работе над макетом.

### Макеты с прокруткой {: #overscroll }

В этом компоненте есть области прокрутки трех типов:

- Блок навигации <b style="color: #FF00E2;">(розовый цвет)</b> использует горизонтальную прокрутку.
- Область контента <b style="color: #008CFF;">(синий цвет)</b> также использует горизонтальную прокрутку.
- Элементы <code>article</code> <b>(зеленый цвет)</b> используют вертикальную прокрутку.

<figure>   {% Img     src="image/vS06HQ1YTsbMKSFTIPl2iogUQP73/qVmUKMwbeoCBffP0aY55.png",     alt="Три цветных прямоугольника со стрелками соответствующего цвета, которые указывают область и направление прокрутки",     width="800", height="450"   %}</figure>

При прокрутке используются элементы двух типов:

1. **Окно.** <br>Прямоугольник с заданными размерами и стилем свойства `overflow`.
2. **Безразмерная поверхность.** <br>В этом макете это списочные контейнеры: ссылки <code>nav</code>, элементы <code>article</code> в разделах <code>section</code> и содержимое <code>article</code>.

#### Макет для `<snap-tabs>` {: #tabs-layout }

В качестве макета верхнего уровня я выбрал `flex` (адаптируемый блок) с направлением <code>column</code> — чтобы заголовок и <code>section</code> располагались вертикально. Это первое окно прокрутки; оно скрывает всё с помощью <code>overflow: hidden</code>. Заголовок и и <code>section</code> будут использовать прокрутку за границы в виде отдельных зон.

{% Compare 'better', 'HTML' %}

```html
<snap-tabs>
  <header></header>
  <section></section>
</snap-tabs>
```

{% endCompare %}

{% Compare 'better', 'CSS' %}

```css
snap-tabs {
  display: flex;
  flex-direction: column;

  /* устанавливаем первичный контейнер */
  overflow: hidden;
  position: relative;

  & > section {
    /* указываем использовать всё место */
    block-size: 100%;
  }

  & > header {
    /* защита от случая, когда <section> требует 100 % */
    flex-shrink: 0;
    /* учет особенностей различных браузеров */
    min-block-size: fit-content;
  }
}
```

{% endCompare %}

Возвращаясь к разноцветной схеме с тремя областями прокрутки:

- Элемент `<header>` теперь готов стать <b style="color: #FF00E2;">розовым</b> контейнером прокрутки.
- Элемент `<section>` готов стать <b style="color: #008CFF;">синим</b> контейнером прокрутки.

Фреймы, которые я выделил ниже с помощью [VisBug](https://a.nerdy.dev/gimme-visbug), помогают увидеть **окна**, созданные контейнерами прокрутки.

<figure>   {% Img     src="image/vS06HQ1YTsbMKSFTIPl2iogUQP73/Fyl0rTuETjORBigkIBx5.png",     alt="Элементы «header» и «section» помечены ярко-розовыми ярлыками и выделены рамкой, ограничивающей место, которое они занимают в компоненте",     width="800", height="620"   %}</figure>

#### Макет для `<header>` {: #tabs-header }

Следующий макет почти такой же: я создаю вертикальную упорядоченную структуру с помощью <code>flex</code>.

<div class="switcher">
{% Compare 'better', 'HTML' %}
```html/1-4
<snap-tabs>
  <header>
    <nav></nav>
    <span class="snap-indicator"></span>
  </header>
  <section></section>
</snap-tabs>
```
{% endCompare %}

{% Compare 'better', 'CSS' %}

```css/1-2
header {
  display: flex;
  flex-direction: column;
}
```
{% endCompare %}
</div>

Элемент `.snap-indicator` должен перемещаться горизонтально вместе с группой ссылок, и такой макет для <code>header</code> позволяет этого добиться. Ни одного элемента с абсолютным размещением!

<figure>   {% Img     src="image/vS06HQ1YTsbMKSFTIPl2iogUQP73/EGNIrpw4gEzIZEcsAt5R.png",     alt="Элементы «nav» и «span.indicator» помечены ярко-розовыми ярлыками и выделены рамкой, ограничивающей место, которое они занимают в компоненте",     width="800", height="368"   %}</figure>

Далее — стили прокрутки. Оказывается, один стиль можно использовать в двух областях горизонтальной прокрутки (`header` и <code>section</code>), поэтому я сделал вспомогательный класс: <code>.scroll-snap-x</code>.

```css
.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;
    }
  }
}
```

В каждом случае нужен <code>overflow</code> по оси x, <code>contain</code> для захвата выхода за границы прокрутки, скрытые полосы прокрутки для сенсорных устройств и, наконец, <code>scroll-snap</code> для фиксации областей показа контента. Удобный порядок вкладок при использовании клавиатуры позволяет переключать фокус естественный образом. У контейнеров <code>scroll-snap</code> красивый «карусельный» стиль взаимодействия при использовании с клавиатуры.

#### Макет для `<nav>` в заголовке {: #tabs-header-nav }

Ссылки <code>nav</code> должны располагаться строкой, без разрывов строк, с центрированием по вертикали, причем каждый элемент ссылки должен привязываться к контейнеру <code>scroll-snap</code>. CSS 2021 отлично с этим справляется!

<div class="switcher">
{% Compare 'better', 'HTML' %}
```html/1-4
<nav>
  <a></a>
  <a></a>
  <a></a>
  <a></a>
</nav>
```
{% endCompare %}

{% Compare 'better', 'CSS' %}

```css
nav {
  display: flex;

  & a {
    scroll-snap-align: start;

    display: inline-flex;
    align-items: center;
    white-space: nowrap;
  }
}
```
{% endCompare %}
</div>

Стили и размеры ссылок задаются автоматически, поэтому в макете <code>nav</code> нужно указать только направление и структуру наполнения — <code>flow</code>. Благодаря различной ширине элементов <code>nav</code> за переходом между вкладками интересно наблюдать: ширина индикатора подстраивается к новой цели. Отображение полосы прокрутки браузером будет зависеть от количества элементов.

<figure>   {% Img     src="image/vS06HQ1YTsbMKSFTIPl2iogUQP73/P7Vm3EvhO1wrTK1boU6y.png",     alt="Элементы «nav» помечены ярко-розовыми ярлыками, выделены рамкой, указывающей место, которое они занимают в компоненте, и снабжены стрелкой для направления развертывания",     width="800", height="327"   %}</figure>

#### Макет для `<section>` {: #tabs-section }

Этот раздел представляет собой элемент `flex` и должен быть основным потребителем места. Ему также необходимо создать столбцы для размещения статей. И CSS 2021 снова отлично справляется с задачей! С помощью `block-size: 100%` элемент растягивается на весь родительский объект, а затем для собственного макета создает несколько столбцов с шириной, равной <code>100%</code> родительского объекта. Здесь проценты использовать удобно, поскольку для «родителя» мы указали строгие ограничения.

<div class="switcher">
{% Compare 'better', 'HTML' %}
```html/1-4
<section>
  <article></article>
  <article></article>
  <article></article>
  <article></article>
</section>
```
{% endCompare %}

{% Compare 'better', 'CSS' %}

```css
section {
  block-size: 100%;

  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 100%;
}
```
{% endCompare %}
</div>

Это можно перевести как «расширять по вертикали, насколько это возможно» (вспомните заголовок, для которого мы установили на `flex-shrink: 0`: это защита от такого принудительного расширения). Так мы устанавливаем высоту строки для столбцов полной высоты. При этом стиль `auto-flow` указывает сетке всегда располагать дочерние элементы в горизонтальную линию, без переноса (как нам и нужно), что позволяет заполнить родительское окно с выходом за его границы.

<figure>   {% Img     src="image/vS06HQ1YTsbMKSFTIPl2iogUQP73/FYroCMocutCGg1X8kfdG.png",     alt="Элементы «article» помечены ярко-розовыми ярлыками, выделены рамкой, указывающей место, которое они занимают в компоненте, и снабжены стрелкой для направления развертывания",     width="800", height="512"   %}</figure>

Иногда мне бывает трудно понять, что к чему! Этот элемент <code>section</code> вписан в прямоугольник, но при этом также создает набор прямоугольников. Надеюсь, рисунки и текст помогут вам разобраться.

#### Макет для `<article>` {: #tabs-article }

Пользователю необходимо дать возможность прокручивать содержимое <code>article</code>, причем полосы прокрутки должны появляются только при переполнении. Эти элементы <code>article</code> находятся в интересном состоянии: они одновременно являются и родительскими, и дочерними элементами прокрутки. Браузер сам обрабатывает разнообразные взаимодействия с сенсорным экраном, мышью и клавиатурой, так что нам об этом беспокоиться не нужно.

<div class="switcher">
{% Compare 'better', 'HTML' %}
```html
<article>
  <h2></h2>
  <p></p>
  <p></p>
  <h2></h2>
  <p></p>
  <p></p>
  ...
</article>
```
{% endCompare %}

{% Compare 'better', 'CSS' %}

```css
article {
  scroll-snap-align: start;

  overflow-y: auto;
  overscroll-behavior-y: contain;
}
```
{% endCompare %}
</div>

Я решил, что <code>article</code> будут привязаны к их родительскому компоненту прокрутки. Мне нравится, что элементы <code>link</code> навигации и элементы <code>article</code> привязываются к началу соответствующих контейнеров прокрутки: создается ощущение гармоничных взаимоотношений.

<figure>   {% Img     src="image/vS06HQ1YTsbMKSFTIPl2iogUQP73/O8gJp7AxBty8yND4fFGr.png",     alt="Элемент «article» и его дочерние элементы помечены ярко-розовыми ярлыками, выделены рамкой, указывающей место, которое они занимают в компоненте, и снабжены стрелкой для направления развертывания",     width="800", height="808"   %}</figure>

Элемент <code>article</code> является дочерней сеткой, причем ее размер предопределен как область просмотра, в которой нам нужна прокрутка. Это означает, что стили высота и ширины здесь не нужны — достаточно определить переполнение. Для <code>overflow-y</code> я задаю <code>auto</code>, а затем захватываю взаимодействие прокрутки с помощью удобного свойства <code>overscroll-behavior</code>.

#### Резюме по трем областям прокрутки {: #scroll-areas-recap }

Ниже в настройках системы у меня выбрано «всегда показывать полосы прокрутки». Мне кажется важным сделать так, что макет работал, когда этот параметр включен: это позволяет проверить макет и оркестровку прокрутки.

<figure>   {% Img     src="image/vS06HQ1YTsbMKSFTIPl2iogUQP73/6I6TI9PI4rvrJ9lr8T99.png",     alt="Включен показ трех полос прокрутки. Они занимают место в макете, но наш компонент по-прежнему выглядит отлично",     width="500", height="607"   %}</figure>

Я думаю, что наличие контейнера для полос прокрутки в этом компоненте помогает четко показать, где находятся области прокрутки, в каком направлении они работают и как взаимодействуют друг с другом. Каждый из этих фреймов окна прокрутки также является родительским элементом <code>flex</code> или <code>grid</code> по отношению к макету.

DevTools помогают визуализировать структуру и поведение макета:

<figure>   {% Img     src="image/vS06HQ1YTsbMKSFTIPl2iogUQP73/GFJwc3IggHY4G5fBMiu9.png",     alt="Области прокрутки с обозначением инструментов `grid` и `flex`, указывающим место, которое они занимают в компоненте, и направление развертывания",     width="800", height="455"   %}   <figcaption>     Chromium DevTools: макет для элемента `nav` адаптируемого блока, содержащий элементы со ссылками,     макет `section` в виде сетки с элементами `article`, а также элементы `article`     с абзацами и элементами заголовков.   </figcaption></figure>

Макеты прокрутки готовы: с привязкой, ссылками на контент и возможностью использования с клавиатуры. Прочная основа для улучшения удобства использования, стиля и отличная возможность насладиться результатом.

#### О функциях

Дочерние элементы с привязкой к прокрутке сохраняют зафиксированное положение при изменении размера. Это означает, что коду JavaScript не нужно ничего отображать при повороте устройства или изменении размера браузера. Перейдите в [Режим устройства](https://developer.chrome.com/docs/devtools/device-mode/) (Device Mode) в Chromium DevTools и выберите любой режим, кроме **отзывчивого** (Responsive), а затем измените размер фрейма устройства. Элемент остается в поле зрения и фиксируется вместе с содержимым. Эта функция работает так с того момента, как Chromium обновил реализацию в соответствии со спецификацией. Можете почитать [запись в блоге](/snap-after-layout/) об этом.

### Анимация {: #animation }

Цель анимации здесь — четко связать работу ссылок с откликом интерфейса. Так мы поможем пользователю с удобством (надеюсь) просмотреть весь контент. Я буду добавлять анимацию движения с определенной целью и и условиями. Надо помнить, что пользователи могут задавать [предпочтения по движению](/prefers-reduced-motion/) в операционной системе, и я с удовольствием учитываю их в проектируемых мной интерфейсах.

Я свяжу подчеркивание вкладки с положением прокрутки элемента `article`. Привязка обеспечивает не только красивое выравнивание, но и соотнесение с началом и окончанием анимации. Это позволяет элементу <code>&lt;nav&gt;</code>, который действует как <a>мини-карта</a>, не терять связь с контентом. Предпочтения пользователя по движению будем проверять и с помощью CSS, и с помощью JS. В некоторых местах придется проявить особую внимательность!

<figure>   {% Video     src="video/vS06HQ1YTsbMKSFTIPl2iogUQP73/D4zfhetqvhqlcPdTRtLZ.mp4",     autoplay="true",     loop="true",     muted="true"   %}</figure>

#### Поведение прокрутки {: #scroll-behavior }

Мы можем улучшить поведение `:target` и `element.scrollIntoView()`. По умолчанию переход мгновенный: браузер просто устанавливает положение прокрутки. Допустим, мы хотим сделать переход в положение прокрутки не мгновенным.

```css
@media (prefers-reduced-motion: no-preference) {
  .scroll-snap-x {
    scroll-behavior: smooth;
  }
}
```

<figure>   {% Video     src="video/vS06HQ1YTsbMKSFTIPl2iogUQP73/Q4JDplhM9gEd4PoiXqs6.mp4",     autoplay="true",     loop="true",     muted="true"   %}</figure>

Поскольку у нас здесь появляется движение — причем такое, которое пользователь не контролирует (прокрутка), мы применяем этот стиль, только  если у пользователя в операционной системе не заданы предпочтения по ограничению отображаемого движения. То есть, мы анимируем прокрутку только для тех пользователей, которые не против этого.

#### Индикатор вкладок {: #tabs-indicator }

Цель этой анимации — связать индикатор с состоянием контента. Для пользователей, предпочитающих ограничение движения, я решил делать переход цвета в стилях `border-bottom`, а для остальных — связанный с прокруткой сдвиг и анимацию смены цвета.

Переключая это предпочтение в Chromium DevTools, я демонстрирую оба стиля переходов. Работать над ними было очень интересно!

<figure>   {% Video     src="video/vS06HQ1YTsbMKSFTIPl2iogUQP73/NVoLHgjGjf7fZw5HFpF6.mp4",     autoplay="true",     loop="true",     muted="true"   %}</figure>

```css
@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`. Также обратите внимание на взаимодействие вкладок: активный элемент <code>nav</code> выделяется не только подчеркиванием, но и более тёмным цветом текста. У активного элемента более высокий цветовой контраст текста и яркий акцент подчеркивания.

Всего пара строк CSS-кода — и мы позаботились о каждом (в том смысле, что учитываем предпочтения пользователей по движению на странице). Мне нравится.

#### `@scroll-timeline` {: #scroll-timeline }

Выше я показал, как работаю со стилями плавного перехода, когда нужно ограничить движение. В этом разделе я покажу, как связать индикатор и область прокрутки. Далее будет несколько интересных экспериментов — надеюсь, вы сгораете от нетерпения.

```js
const { matches:motionOK } = window.matchMedia(
  '(prefers-reduced-motion: no-preference)'
);
```

Сначала я с помощью JavaScript проверяю предпочтения пользователя по движению. Если результат — `false` (пользователь хочет видеть меньше движения), тогда мы не будем запускать эффекты привязки прокрутки.

```js
if (motionOK) {
  // код анимации с использованием движения
}
```

На момент написания статьи [поддержки `@scroll-timeline` в браузерах](https://caniuse.com/css-scroll-timeline) нет. Функция пребывает в виде [черновой спецификации](https://drafts.csswg.org/scroll-animations-1/) — есть только экспериментальные реализации. Однако для нее есть полифил, который я и применяю в этой демонстрации.

##### ` ScrollTimeline`

И CSS, и JavaScript позволяют делать <code>ScrollTimeline</code> для прокрутки, однако я выбрал JavaScript — чтобы использовать в анимации актуальные размеры элементов.

```js
const sectionScrollTimeline = new ScrollTimeline({
  scrollSource: tabsection,  // snap-tabs > section
  orientation: 'inline',     // прокрутка в направлении потока букв
  fill: 'both',              // двунаправленное связывание
});
```

Я хочу, чтобы один элемент следовал за положением прокрутки другого. Создав `ScrollTimeline`, я определяю ведущий элемент для связки с прокруткой — `scrollSource`. Обычно анимация в веб-дизайне запускается в соответствии с глобальным тиком интервала времени, но с помощью `sectionScrollTimeline` это можно изменить.

```js
tabindicator.animate({
    transform: ...,
    width: ...,
  }, {
    duration: 1000,
    fill: 'both',
    timeline: sectionScrollTimeline,
  }
);
```

Прежде чем я перейду к ключевым кадрам анимации, думаю, важно упомянуть, что ведомый элемент прокрутки — `tabindicator` — будет анимироваться по специальной временной шкале — прокрутке нашего <code>section</code>. Здесь мы заканчиваем привязку, но у нас отсутствует последний ингредиент — точки с отслеживанием состояния, между которыми происходит анимация. Их еще называют ключевыми кадрами.

#### Динамические ключевые кадры

Есть, конечно, очень мощный чисто декларативный способ делать анимацию в CSS с помощью `@scroll-timeline`, но нужная мне анимация была слишком динамичной. CSS не позволяет делать переход между шириной со значением `auto` и динамически создавать ключевые кадры в зависимости от длины дочерних элементов.

Однако получить эту информацию можно с помощью JavaScript. Поэтому мы будем проходить по дочерним элементам сами и захватывать вычисленные значения во время выполнения кода:

```js
tabindicator.animate({
    transform: [...tabnavitems].map(({offsetLeft}) =>
      `translateX(${offsetLeft}px)`),
    width: [...tabnavitems].map(({offsetWidth}) =>
      `${offsetWidth}px`)
  }, {
    duration: 1000,
    fill: 'both',
    timeline: sectionScrollTimeline,
  }
);
```

Для каждого `tabnavitem` деструктурируем положение `offsetLeft` и возвращаем строку, которая использует его как значение `translateX`. Так мы получаем четыре ключевых кадра преобразования для анимации. То же делаем и с шириной: запрашиваем ее у каждого элемента и используем как значение ключевого кадра.

Вот пример вывода с моими шрифтами и настройками браузера:

Ключевые кадры <code>translateX</code>:

```js
[...tabnavitems].map(({offsetLeft}) =>
  `translateX(${offsetLeft}px)`)

// возвращает четыре элемента массива, представляющие собой четыре состояния ключевых кадров
// ["translateX(0px)", "translateX(121px)", "translateX(238px)", "translateX(464px)"]
```

Ключевые кадры ширины:

```js
[...tabnavitems].map(({offsetWidth}) =>
  `${offsetWidth}px`)

// возвращает четыре элемента массива, представляющие собой четыре состояния ключевых кадров
// ["121px", "117px", "226px", "67px"]
```

Стратегия вкратце: индикатор вкладки будет анимироваться по четырем ключевым кадрам в зависимости от положения <code>scroll-snap</code> компонента прокрутки <code>section</code>. Точки привязки задают четкое разграничение между ключевыми кадрами и делают анимацию более синхронной по ощущениям.

<figure>   {% Img     src="image/vS06HQ1YTsbMKSFTIPl2iogUQP73/jV5X2JMkgUQSIpcivvTJ.png",     alt="Активная и неактивная вкладки с оверлеями VisBug, которые показывают оценку контрастности и соответствие требованиям для них",     width="540", height="400"   %}</figure>

Пользователь управляет анимацией своими действиями и видит, как ширина и положение индикатора меняются от одного <code>section</code> к другому, в точности следуя за прокруткой.

Возможно, вы не заметили, но я очень горжусь тем, как получилось сделать переход цвета при выборе выделенного элемента навигации.

<figure>   {% Video     src="video/vS06HQ1YTsbMKSFTIPl2iogUQP73/qoxGO8SR2t6GPuCWhwvu.mp4",     autoplay="true",     loop="true",     muted="true"   %}</figure>

Невыбранный элемент светло-серого цвета становится еще менее выразительным в сравнении с более контрастным выделенным. Обычно меняют цвет текста — например, при наведении курсора и выделении, — а вот делать переход цвета при прокрутке синхронно с индикатором подчеркивания — это уже следующий уровень.

Как это делается:

```js
tabnavitems.forEach(navitem => {
  navitem.animate({
      color: [...tabnavitems].map(item =>
        item === navitem
          ? `var(--text-active-color)`
          : `var(--text-color)`)
    }, {
      duration: 1000,
      fill: 'both',
      timeline: sectionScrollTimeline,
    }
  );
});
```

Для каждой ссылки <code>nav</code> на вкладке нужна новая анимация цвета по той же временно́й шкале, что и у индикатора подчеркивания. Я использую ту же временну́ю шкалу, что и раньше: ее задача — выдавать при прокрутке тик, поэтому мы можем использовать его в анимации любого нужного нам типа. Как и раньше, я создаю четыре ключевых кадра в цикле и получаю цвета.

```js
[...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 {: #js }

Напомню: то, что я здесь показываю, в основе своей работает и без JavaScript. Однако давайте посмотрим, что можно улучшить, используя JS.

#### Ссылки на контент

Ссылки на контент — это скорее мобильный термин, но, думаю, что их цели вполне соответствует случай, когда URL-адрес передается непосредственно в содержимое вкладки. Браузер будет переходить на странице к идентификатору, совпадающим с хешем URL. Я обнаружил, что этот обработчик `onload` действует на всех платформах.

```js
window.onload = () => {
  if (location.hash) {
    tabsection.scrollLeft = document
      .querySelector(location.hash)
      .offsetLeft;
  }
}
```

#### Синхронизация с окончанием прокрутки

Пользователи не всегда будут нажимать на вкладки или использовать клавиатуру — иногда они просто будут использовать прокрутку. Когда компонент прокрутки в <code>section</code> останавливается, его положение должно совпадать с состоянием верхней панели навигации.

<figure>   {% Video     src="video/vS06HQ1YTsbMKSFTIPl2iogUQP73/syltOES9Gxc0ihOsgTIV.mp4",     autoplay="true",     loop="true",     muted="true"   %}</figure>

Так мы ожидаем окончания прокрутки:

```js
tabsection.addEventListener('scroll', () => {
  clearTimeout(tabsection.scrollEndTimer);
  tabsection.scrollEndTimer = setTimeout(determineActiveTabSection, 100);
});
```

Каждый раз при прокрутке элементов <code>section</code> мы сбрасываем время ожидания (если оно есть) и начинаем новый отсчет. Когда прокрутка элемента <code>section</code> останавливается, мы не сбрасываем время ожидания, а запускаем отсчет 100 мс с момента начала бездействия. По окончании отсчета вызываем функцию, которая определяет, где пользователь остановился.

```js
const determineActiveTabSection = () => {
  const i = tabsection.scrollLeft / tabsection.clientWidth;
  const matchingNavItem = tabnavitems[i];

  matchingNavItem && setActiveTab(matchingNavItem);
};
```

Поскольку у нас прокрутка с привязкой, то, разделив текущее положение прокрутки на ширину области прокрутки, мы должны получить целое, а не десятичное число. Затем я пытаюсь взять <code>navitem</code> из кеша с помощью вычисленного здесь индекса, и, если подходящий находится, делаю его активным.

```js
const setActiveTab = tabbtn => {
  tabnav
    .querySelector(':scope a[active]')
    .removeAttribute('active');

  tabbtn.setAttribute('active', '');
  tabbtn.scrollIntoView();
};
```

Сначала мы деактивируем вкладку, которая является активной сейчас, а затем даем полученному элементу `nav` атрибут активного состояния. Здесь стоит отметить вызов <code>scrollIntoView()</code>, который интересным образом взаимодействует с CSS.

<figure>   {% Video     src="video/vS06HQ1YTsbMKSFTIPl2iogUQP73/nsiyMgZ2QGF2fx9gVRgu.mp4",     autoplay="true",     loop="true",     muted="true"   %}</figure>

```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 <code>scroll-snap</code> мы <a>вложили</a> запрос медиа, который применяет прокрутку типа <code>smooth</code>, если пользователь разрешает элементы с движением. JavaScript может легко вызывать элементы прокрутки в представление, а CSS может декларативно управлять интерфейсом. Очаровательная парочка.

### Заключение

Я рассказал свое видение решения этой задачи. А как ее решали бы вы? Получилась очень интересная архитектура компонентов! Кто же первый сделает версию с блек-джеком в своем любимом фреймворке? 🙂

Давайте разнообразим наши подходы и рассмотрим самые разные реализации для веб-сайтов. Создайте свою версию [на Glitch](https://glitch.com), [твитните мне](https://twitter.com/argyleink), и я добавлю добавлю ее в раздел [Ремиксы сообщества](#community-remixes) ниже.

## Ремиксы сообщества

- Версия [@devnook](https://twitter.com/devnook), [@rob_dodson](https://twitter.com/rob_dodson) и [@DasSurma](https://twitter.com/DasSurma) с веб-компонентами ([статья](https://developers.google.com/web/fundamentals/web-components/examples/howto-tabs)).
- Версия [@jhvanderschee](https://twitter.com/jhvanderschee) с кнопками: [Codepen](https://codepen.io/joosts/pen/PoKdZYP).
