Хочу поделиться кейсом, когда разработка типичного 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)
ссылка на оригинал статьи https://habr.com/ru/articles/1047114/