CSS в 2026 году: где JavaScript уже не обязателен
- пятница, 11 сентября 2026 г. в 00:00:13

Когда работаешь с такими масштабными продуктами, как мы делаем в VK, быстро начинаешь ценить код, который можно не писать. Особенно если речь о небольших сценариях: открыть поповер, анимировать появление блока или подстроить поле под контент. Ещё недавно для таких задач мы почти автоматически тянулись к JavaScript. Но за последние два года CSS сильно расширил свои возможности для задач, связанных с интерфейсом.
Я Максим Кузнецов, занимаюсь оптимизацией производительности и улучшением стабильности веб-версий ВКонтакте. В этой статье расскажу, что уже можно использовать как альтернативу JS, что стоит попробовать аккуратно, а за чем пока лучше просто следить.
Начнём с возможностей, которые чаще всего влияют на повседневную разработку: состояние DOM, формы, всплывающие элементы и анимации. Это не самые новые фичи, но именно они чаще всего помогают работать уже без использования JavaScript.
Поддержка: 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() в этом плане часто оказывается проще.
Поддержка: 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; }

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

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

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

Это особенно полезно для dialog, popover, меню и элементов, которые появляются после изменения атрибута или состояния отображения.
Поддержка: 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.
Поддержка: Chrome и Safari — есть, Firefox — ещё нет
Анимации, завязанные на прокрутку, долго делали через обработчики scroll: вычисляли позицию элемента, обновляли CSS-переменные, синхронизировали прогресс и следили за производительностью.
Сейчас часть таких задач можно описать через CSS Scroll-driven animations: scroll-timeline, view-timeline, animation-timeline.
Типовые сценарии:
— индикатор прогресса чтения
— появление блока при попадании в область видимости
— декоративные scroll-эффекты
— анимация, завязанная на положение контейнера прокрутки

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

Это особенно полезно для декоративных списков, каскадных анимаций и компонентов, где визуальное поведение зависит от порядка элементов.
Поддержка: только 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; ); } }
Поддержка: только Chrome
@function позволяет описывать собственные CSS-функции и переиспользовать вычисления без препроцессора.
Пример — функция для сетки:
@function --grid-columns(--count) { result: repeat(var(--count), 1fr); } .cards { display: grid; grid-template-columns: --grid-columns(3); }
Это направление особенно интересно для команд, которые до сих пор держат препроцессор только ради функций или миксинов.
Поддержка: только Chrome
Выпадающие элементы часто открываются слишком резко. Пользователь наводит курсор на кнопку, элемент сразу появляется, затем исчезает, если курсор не успел перейти на всплывающую область. В таких сценариях раньше обычно добавляли задержку через JavaScript.
interest-delay позволяет задавать задержку для interest-сценариев декларативно.
[interestfor] { interest-delay: 200ms; }

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

Но contrast-color() нельзя считать заменой дизайн-системе и проверкам доступности. Функция выбирает только между чёрным и белым, а не подбирает цвет из палитры продукта. Абсолютный чёрный и абсолютный белый не всегда подходят визуально. А автоматический выбор контраста не отменяет дизайн-токены, тесты и ручную проверку цветовых пар.
Поддержка: слабая
Здесь поддержка хуже всего, поэтому пока это скорее возможность, за развитием которой стоит следить, а не брать в основу интерфейса.
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%); } }
Поддержка: только 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 всё чаще закрывает задачи, которые действительно должен закрывать.