
Когда работаешь с такими масштабными продуктами, как мы делаем в VK, быстро начинаешь ценить код, который можно не писать. Особенно если речь о небольших сценариях: открыть поповер, анимировать появление блока или подстроить поле под контент. Ещё недавно для таких задач мы почти автоматически тянулись к JavaScript. Но за последние два года CSS сильно расширил свои возможности для задач, связанных с интерфейсом.
Я Максим Кузнецов, занимаюсь оптимизацией производительности и улучшением стабильности веб-версий ВКонтакте. В этой статье расскажу, что уже можно использовать как альтернативу JS, что стоит попробовать аккуратно, а за чем пока лучше просто следить.
Что уже полезно в реальных проектах
Начнём с возможностей, которые чаще всего влияют на повседневную разработку: состояние DOM, формы, всплывающие элементы и анимации. Это не самые новые фичи, но именно они чаще всего помогают работать уже без использования JavaScript.
:has(): стилизация контейнера по состоянию вложенных элементов
Поддержка: Chrome, Safari, Firefox
:has() далеко не новая тема, разговоры про него идут давно, и во многих проектах он уже активно используется. Но именно сейчас он стал восприниматься как полноценный рабочий инструмент.
:has() решает одну из старых проблем CSS: как изменить стиль родителя, если внутри него есть элемент в определённом состоянии.
Раньше для этого часто использовали JS. Например, если внутри блока выбран checkbox, скрипт добавлял класс на родительский элемент. Если выбор снимался, класс удалялся. В итоге появлялась вспомогательная логика, которая отвечала только за визуальное состояние.
С :has() такой сценарий можно описать сразу в CSS:
.card:has(input:checked) {
border-color: var(--color-accent);
}
Ещё несколько типичных примеров:
.form-field:has(input:invalid) {
border-color: var(--color-error);
}
.card:has(:focus-visible) {
outline: 2px solid var(--color-focus);
}
.tabs:has([aria-selected="true"]) {
--tabs-state: active;
}

:has() хорошо подходит для локального визуального состояния: выбранный checkbox, невалидное поле, фокус внутри карточки, активный элемент внутри контейнера, наличие определённого дочернего элемента.
В нашем коде такие сценарии уже встречаются, и это хороший пример того, как CSS забирает на себя не бизнес-логику, а именно визуальную часть поведения. Если состояние нужно для данных, аналитики, маршрутизации или бизнес-правил, оно остаётся в JS. Но если класс раньше добавлялся только ради оформления, :has() в этом плане часто оказывается проще.
field-sizing: авторазмер полей без помощи JavaScript
Поддержка: Chrome и Safari — есть, Firefox — ещё нет
Авторазмер полей формы долго оставался задачей, которую обычно решали через JavaScript. Для textarea приходилось подписываться на input, читать высоту содержимого, сбрасывать текущее значение высоты и выставлять новое. Для input использовали похожие обходные решения: скрытые элементы, измерение текста или дополнительные обёртки.
field-sizing закрывает часть этих сценариев на уровне CSS. При значении content поле может подстраиваться под введённое значение.
textarea,
input {
field-sizing: content;
}
Это полезно для инлайн-редактирования, небольших форм и интерфейсов, где размер поля должен зависеть от содержимого.
При этом свойство не отменяет нормальную работу с ограничениями. Для реального интерфейса всё равно нужно задать минимальный и максимальный размер, проверить переносы, длинные значения, мобильную клавиатуру и доступность.
.message-field {
field-sizing: content;
min-block-size: 40px;
max-block-size: 160px;
}

Вывод простой: если поле должно просто подстраиваться под контент, для этого уже не всегда нужен отдельный скрипт.
Popover API: всплывающие элементы без лишнего кода
Поддержка: Chrome, Safari, Firefox
Всплывающие элементы долго требовали большого количества JavaScript. Popover API забирает часть этого базового поведения на себя.
<button popovertarget="menu">Открыть меню</button>
<div id="menu" popover>
<button>Профиль</button>
<button>Настройки</button>
</div>

Для простых меню, тултипов и небольших поповеров это снижает объём кода, потому что базовое открытие и закрытие можно описать декларативно.
Конечно, в сложных компонентах JS всё равно может понадобиться — например, для сложного управления состоянием, вложенных сценариев. Но для базового поведения теперь есть нативная основа, и это уже меняет подход к таким компонентам.
CSS Anchor Positioning: позиционирование относительно якоря
Поддержка: Chrome и Safari — есть, Firefox — нет
Тултипы, меню и превью обычно должны появляться рядом с конкретным элементом: кнопкой, аватаром, полем ввода. Раньше для этого часто считали координаты через JS, следили за прокруткой, изменением размеров окна и выходом элемента за границы экрана.
CSS Anchor Positioning позволяет связать всплывающий элемент с якорем и описать позицию декларативно.
.trigger {
anchor-name: --menu-trigger;
}
.menu {
position-anchor: --menu-trigger;
position-area: bottom span-right;
}

Главная идея здесь не в том, что все утилитарные методы уже завтра станут не нужны. Идея в том, что для части сценариев браузер уже начинает закрывать то, что раньше писали вручную.
@starting-style: анимация появления без промежуточного состояния
Поддержка: Chrome, Safari, Firefox
Когда элемент впервые появляется в DOM или переходит из display: none в видимое состояние, браузер не всегда знает, от какого значения запускать переход. Поэтому в интерфейсах часто заводят промежуточные состояния: mounted, visible, closing, таймеры и ручное удаление элемента после анимации.
@starting-style позволяет задать начальное состояние для такого перехода.
dialog {
opacity: 1;
transition: opacity 200ms;
}
@starting-style {
dialog {
opacity: 0;
}
}

Это особенно полезно для dialog, popover, меню и элементов, которые появляются после изменения атрибута или состояния отображения.
transition-behavior: переходы для дискретных свойств
Поддержка: Chrome, Safari, Firefox
Некоторые CSS-свойства не анимируются плавно. Например, display не имеет промежуточных значений, так как элемент либо отображается, либо нет.
transition-behavior: allow-discrete позволяет включить переходы для дискретных свойств и аккуратнее описывать открытие и закрытие элементов.
[popover] {
opacity: 0;
transition:
opacity 200ms,
display 200ms allow-discrete;
}
[popover]:popover-open {
opacity: 1;
}
@starting-style {
[popover]:popover-open {
opacity: 0;
}
}
В связке с @starting-style это помогает описывать появление и скрытие поповеров, диалогов и меню без дополнительного слоя состояний в JS.
Scroll-driven animations: анимации на основе прокрутки
Поддержка: Chrome и Safari — есть, Firefox — ещё нет
Анимации, завязанные на прокрутку, долго делали через обработчики scroll: вычисляли позицию элемента, обновляли CSS-переменные, синхронизировали прогресс и следили за производительностью.
Сейчас часть таких задач можно описать через CSS Scroll-driven animations: scroll-timeline, view-timeline, animation-timeline.
Типовые сценарии:
— индикатор прогресса чтения
— появление блока при попадании в область видимости
— декоративные scroll-эффекты
— анимация, завязанная на положение контейнера прокрутки

Это не замена всем scroll-сценариям. Если прокрутка влияет на данные, сбор аналитики, состояние приложения или иную побочную логику, JavaScript остаётся незаменимым. Но если задача визуальная, CSS-вариант следует рассмотреть первым.
Новые и экспериментальные возможности
Теперь перейдём к возможностям, которые интересны, но требуют большей осторожности; часть из них пока доступна не везде, часть лучше использовать как постепенное улучшение, а другая часть показывает, куда в целом развивается CSS.
Все возможности из этого раздела перед внедрением нужно отдельно проверять по целевым браузерам.
sibling-index() и sibling-count(): позиция элемента без ручной передачи индекса
Поддержка: только Chrome
В повторяющихся элементах, например списках или повседневных сетках, часто нужно знать позицию элемента: для задержки анимации, расчёта смещения, распределения элементов или декоративного эффекта.
До появления новых функций обычно использовали один из трёх подходов:
— прописывали несколько правил через :nth-child()
— передавали индекс через CSS-переменную из шаблона
— считали позицию через JavaScript
sibling-index() возвращает номер текущего элемента среди соседей, а sibling-count() — общее количество соседних элементов.
.item {
animation-delay: calc(sibling-index() * 60ms);
}
Ещё один пример — распределение элементов по ширине:
.gallery-item {
inline-size: calc(100% / sibling-count());
}

Это особенно полезно для декоративных списков, каскадных анимаций и компонентов, где визуальное поведение зависит от порядка элементов.
if(): условные значения внутри CSS-свойств
Поддержка: только Chrome
if() добавляет в CSS возможность выбирать значение свойства по условию. Это не заменяет медиазапросы, @supports или состояние приложения, но позволяет держать часть локальной логики рядом со свойством.
media() внутри if() проверяет условия среды, например ширину экрана или тип устройства ввода. А @supports внутри if() проверяет поддержку конкретного CSS-свойства или значения.
Например, можно выбрать размер кнопки в зависимости от типа устройства ввода:
.button {
min-block-size: if(
media(any-pointer: fine): 32px;
else: 44px;
);
}
Здесь media(any-pointer: fine) означает: если у пользователя точное устройство ввода, например мышь или трекпад, можно сделать кнопку компактнее. В остальных случаях оставляем более крупный размер.
Можно опираться на ширину вьюпорта:
.page {
--content-gap: if(
media(width < 700px): 16px;
else: 32px;
);
}
Можно проверять поддержку возможности браузером:
.card {
padding: if(
supports(margin-inline: 1rem): 24px;
else: 16px;
);
}
А можно выбирать значение на основе другого CSS-свойства:
.block {
--theme: dark;
color: if(
style(--theme: dark): white;
else: black;
);
}

Главная польза if() не в том, что CSS превращается в язык программирования. Польза в локализации условий, иными словами, связанная логика остаётся рядом с тем свойством, на которое влияет.
Для боевого кода всё равно нужны запасные значения и проверка через @supports.
.button {
min-block-size: 44px;
}
@supports (min-block-size: if(media(width < 700px): 44px; else: 32px)) {
.button {
min-block-size: if(
media(any-pointer: fine): 32px;
else: 44px;
);
}
}
@function: переиспользуемые вычисления в CSS
Поддержка: только Chrome
@function позволяет описывать собственные CSS-функции и переиспользовать вычисления без препроцессора.
Пример — функция для сетки:
@function --grid-columns(--count) {
result: repeat(var(--count), 1fr);
}
.cards {
display: grid;
grid-template-columns: --grid-columns(3);
}
Это направление особенно интересно для команд, которые до сих пор держат препроцессор только ради функций или миксинов.
interest-delay: задержка открытия и закрытия для hover-сценариев
Поддержка: только Chrome
Выпадающие элементы часто открываются слишком резко. Пользователь наводит курсор на кнопку, элемент сразу появляется, затем исчезает, если курсор не успел перейти на всплывающую область. В таких сценариях раньше обычно добавляли задержку через JavaScript.
interest-delay позволяет задавать задержку для interest-сценариев декларативно.
[interestfor] {
interest-delay: 200ms;
}

Это может быть полезно для подсказок, hover-карточек, предпросмотров и элементов, которые не должны открываться или закрываться мгновенно.
Декоративные и нишевые возможности
Здесь я собрал фичи для более точной настройки интерфейса (цвета, нестандартные формы и курсор ввода). Они не меняют общий подход к приложению, но могут быть полезны в конкретных местах.
contrast-color(): автоматический контраст для простых случаев
Поддержка: только Safari
contrast-color() принимает цвет и возвращает контрастный к нему цвет. В текущей модели функция выбирает между чёрным и белым в зависимости от того, какой вариант лучше читается на переданном фоне.
.badge {
--badge-bg: #660066;
background: var(--badge-bg);
color: contrast-color(var(--badge-bg));
}

Но contrast-color() нельзя считать заменой дизайн-системе и проверкам доступности. Функция выбирает только между чёрным и белым, а не подбирает цвет из палитры продукта. Абсолютный чёрный и абсолютный белый не всегда подходят визуально. А автоматический выбор контраста не отменяет дизайн-токены, тесты и ручную проверку цветовых пар.
border-shape: декоративные формы без части ограничений clip-path
Поддержка: слабая
Здесь поддержка хуже всего, поэтому пока это скорее возможность, за развитием которой стоит следить, а не брать в основу интерфейса.
clip-path давно используют для нестандартных форм. Но у него есть ограничение: он обрезает не только сам элемент, но и декоративные эффекты. Например, box-shadow может исчезнуть вместе с областью, которую срезал clip-path.
.card {
inline-size: 200px;
block-size: 200px;
background: tomato;
clip-path: circle(50%);
box-shadow: 0 0 10px #222;
}
Один из распространённых обходов — filter: drop-shadow(). Но это отдельный приём со своими ограничениями по качеству отрисовки и производительности.
border-shape решает похожую задачу иначе: задаёт форму границы и позволяет сохранить декоративные свойства.
.card {
inline-size: 200px;
block-size: 200px;
background: tomato;
border-shape: circle(50%);
box-shadow: 0 0 10px #222;
}

Свойство поддерживает знакомые функции форм:
.shape-a {
border-shape: inset(10px 20px 5px 15px);
}
.shape-b {
border-shape: circle(50%);
}
.shape-c {
border-shape: ellipse(30px 50px);
}
.shape-d {
border-shape: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
}
.shape-e {
border-shape: path("M 0 200 L 0,75 A 5,5 0,0,1 150,75 L 200 200 z");
}
.shape-f {
border-shape: shape(from 50% 0, line to 100% 100%, line to 0 100%, close);
}
Но пока border-shape лучше внедрять как прогрессивное улучшение: если браузер не поддерживает свойство, интерфейс должен оставаться нормальным.
.avatar {
border-radius: 50%;
box-shadow: 0 0 10px #222;
}
@supports (border-shape: circle(50%)) {
.avatar {
border-radius: 0;
border-shape: circle(50%);
}
}
caret-shape: форма текстовой каретки
Поддержка: только Chrome
В CSS давно есть caret-color, который задаёт цвет текстовой каретки. caret-shape добавляет ещё один уровень настройки — форму курсора ввода.
.code-input {
caret-shape: block;
}
Доступны три базовых значения:
.input-a {
caret-shape: bar;
}
.input-b {
caret-shape: block;
}
.input-c {
caret-shape: underscore;
}

Это нишевая возможность. В обычных формах лучше не менять каретку без причины: пользователи ожидают стандартное поведение поля ввода. Но для редакторов кода, терминальных интерфейсов, обучающих сред и специальных полей свойство может быть полезным. caret-shape позволяет аккуратнее настроить детали интерфейса там, где форма курсора действительно важна.
Что в итоге?
CSS не стал (и не должен был стать) заменой JavaScript. Но они начали лучше дополнять друг друга: JS остаётся там, где нужна логика приложения, а CSS всё чаще закрывает задачи, которые действительно должен закрывать.
Автор: GolovinDS
