javascript

CSS в 2026 году: где JavaScript уже не обязателен

  • пятница, 11 сентября 2026 г. в 00:00:13
https://habr.com/ru/companies/vk/articles/1080386/

Когда работаешь с такими масштабными продуктами, как мы делаем в 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 всё чаще закрывает задачи, которые действительно должен закрывать.