Хочу поделиться кейсом, когда разработка типичного tab-switcher превращается в настоящее архитектурное решение.
Когда дизайнер приносит макет с декоративной кнопкой — тисненая текстура, фигурные края, анимированый индикатор — первая мысль «нарежем это на картинки и вставим». Но это не самое хорошее решение если нужно поддерживать гибкость в длине текста. Более правильный путь — это CSS-первый подход.
Итак, задача: два таба, активный элемент плавно скользит между ними. Кнопка выглядит как объект из физического мира — тиснёная кожа, фигурные торцы.
Реализация будет состоять из 5 слоев, давайте разберем каждый.
Слой 1: 9-slice паттерн (у нас три части)
Фон всего tab-switcher делится на 3 части (боковые края и центральная часть). Это упрощенная реализация паттерн 9-slice, который пришел из game development. Его суть в том, что Изображение делится на 9 зон. Это кросс-платформенный стандарт: Android реализует его через .9.png, CSS через border-image, Unity через тип спрайта Sliced. Везде одна и та же идея — углы фиксированы, а края тянутся.
Поскольку мой tab-switcher тянется только по горизонтали, достаточно 3 зон.
В CSS — это три background-image на одном элементе:
.tabs {
position: relative; /* контекст для absolute детей */
background-image:
url("tabs-frame-l.webp"), /* левый торец */
url("tabs-frame-r.webp"), /* правый торец */
url("tabs-frame-m.webp"); /* середина */
background-position:
left center,
right center,
34px center; /* offset = ширина левого торца */
background-repeat: no-repeat, no-repeat, no-repeat;
background-size:
36px 100%,
36px 100%,
calc(100% - 68px) 100%; /* 100% минус два торца */
}
Файлы торцов — 68px шириной (2× для retina), середина — любая ширина, тянется.
Почему не border-image?
border-image — браузерный 9-slice и необходимо меньше кода. Но:
Плохой контроль z-порядка с другими backgrounds
Нельзя смешивать с pseudo-element слоями
Поведение padding-области непредсказуемо
Multi-background даёт полный контроль, поэтому я выбрала его.
Слой 2: тёмная подложка, которая задает цвет
Внутри tab-switcher нужен цвет фонa, который реализован через pseudo-element. Это отлично подходит поскольку это виртуальный HTML-элемент который браузер создаёт в памяти — в DOM его нет, в разметке не нужно создавать отдельный div, но он рендерится как обычный блок.
.tabs::before {
content: "";
position: absolute;
inset: 5px;
border-radius: 9999px;
background: #42302e;
box-shadow: inset 0 0 3px rgba(0,0,0,0.8);
z-index: 0;
}
inset: 5px — отступ со всех сторон, подложка чуть меньше frame. border-radius: 9999px — любое большое число = pill-shape без пересчёта при изменении размера.
Слой 3: текстура через mix-blend-mode
Для создания реалистичной фоновой текстуры завела еще один слой также с помощью pseudo-element.
.tabs::after {
content: "";
position: absolute;
inset: 5px;
border-radius: 9999px;
background: url("tabs-texture.webp") 0 0 / 603px 259px;
mix-blend-mode: overlay;
z-index: 0;
}
mix-blend-mode в CSS — это механизм определяющий как будут отображаться слои при наложении. Пиксели элемента математически смешиваются с пикселями под ним. Значения пикселей — от 0 до 1.
В нашем случае: есть серо-бежевая текстура кожи через overlay на тёмно-зеленом фоне — получается эффект тиснения. Без mix-blend-mode текстура просто перекрыла бы всё.
Нужно помнить критическое ограничение Safari: mix-blend-mode ломается если элемент находится внутри контейнера с одновременно position: fixed + transform. Safari создаёт изолированный stacking context — смешивание происходит только внутри него, не с внешними слоями.
Существует два способа фикса:
isolation: isolate — явно создаём stacking context
Детект Safari через @supports как запасной вариант
@supports (hanging-punctuation: first) {
.tabs::after {
mix-blend-mode: normal;
background: rgba(255, 200, 100, 0.08); /*rgba-аппроксимация */
}
}
isolation: isolate решает проблему в большинстве случаев. @supports-хак — когда нужен точный rgba-fallback.
Слой 4: Sliding indicator
Этот слой для выделения активного таба. Он реализован ввиде отдельного div с абсолютным позиционированнием, тоже состоит из 3-частей и занимает ровно половину контейнера:
.tab-indicator {
position: absolute;
left: 8px;
top: 8px;
bottom: 8px;
width: calc(50% - 8px); /* половина минус отступ */
z-index: 1;
}
При переключении — класс меняется, transform едет:
.tab-indicator {
transform: translateX(0);
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.tab-indicator--right {
transform: translateX(100%);
}
Почему transform, а не left?
Главное правило анимаций в вебе: анимировать только transform и opacity.
left / top / width / height вызывают reflow — браузер пересчитывает геометрию всей страницы на каждом кадре. На 60fps это 60 пересчётов в секунду. На слабых мобильных устройствах — заметное подёргивание.
transform выполняется на composite layer. Браузер выносит анимацию на GPU, не трогая DOM. Плавно даже на бюджетных телефонах.
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
cubic-bezier(0.4, 0, 0.2, 1) — Material Design Standard easing. Быстрый разгон в начале, плавное торможение к концу. Ощущается как физичное движение — не роботизированное linear, не «резиновое» ease-in-out.
Слой 5: текст
Кнопки с текстом лежат поверх всего благодаря z-index: 2, position: relative. Цвет синхронно меняется с движением индикатора:
.tab-button {
color: #fce7b6; /* золотой — неактивный */
transition: color 0.35s ease-in-out;
}
.tab-button.active {
color: #221c18; /* тёмный — активный (читается на светлом indicator) */
}
Два независимых перехода — transform индикатора и color текста — одинаковая длительность 0.35s. Браузер запускает их параллельно, человек воспринимает как единое движение.
Таким образом, итоговая схема слоёв выглядит так:
z-index 2 | .tab-button (текст на кнопках)
z-index 1 | .tab-indicator (sliding frame, transform)
z-index 0 | .tabs::after (текстура, mix-blend-mode: overlay)
z-index 0 | .tabs::before (тёмная подложка)
.tabs background-image (3-slice frame)




















