Анатомия «живой» кнопки: 5 слоёв, GPU-анимация и трюки с CSS

от автора

Хочу поделиться кейсом, когда разработка типичного tab-switcher превращается в настоящее архитектурное решение.

Когда дизайнер приносит макет с декоративной кнопкой — тисненая текстура, фигурные края, анимированый индикатор — первая мысль «нарежем это на картинки и вставим». Но это не самое хорошее решение если нужно поддерживать гибкость в длине текста. Более правильный путь — это CSS-первый подход.

Demo на CodeSandbox

Итак, задача: два таба, активный элемент плавно скользит между ними. Кнопка выглядит как объект из физического мира — тиснёная кожа, фигурные торцы.

Реализация будет состоять из 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 — смешивание происходит только внутри него, не с внешними слоями.

Существует два способа фикса:

  1. isolation: isolate — явно создаём stacking context

  2. Детект 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/