Современный CSS в 2026: фичи, которые изменили мой подход к стилям
Container queries, CSS layers, :has(), color-mix(), нестинг, анимации на основе скролла и anchor positioning. CSS-фичи, благодаря которым я перестал тянуться к JavaScript.
Я удалил свой последний Sass-файл полгода назад. Не потому что хотел сделать заявление. Потому что он мне реально больше не нужен.
Больше десяти лет CSS был языком, за который мы извинялись. Нам нужны были препроцессоры для нестинга и переменных. Нам нужен был JavaScript для контейнерного масштабирования, анимаций, привязанных к скроллу, выбора родителя и половины паттернов вёрстки, которые нам приносили дизайнеры. Мы строили целые рантайм-системы — CSS-in-JS библиотеки, утилитарные фреймворки, цепочки плагинов PostCSS — чтобы компенсировать то, что язык не мог делать нативно.
Эта эра закончилась. Не «почти закончилась». Не «движемся к этому». Закончилась.
Фичи, которые появились в браузерах между 2024 и 2026 годами, не просто добавили удобства. Они изменили ментальную модель. CSS — это больше не язык стилей, с которым ты борешься. Это язык стилей, который реально думает о компонентах, управлении специфичностью, адаптивном дизайне на уровне элементов и анимациях без рантайма.
Вот что изменилось, почему это важно, и что ты можешь перестать делать благодаря этому.
Container Queries: конец эпохи «какой ширины вьюпорт?»#
Это та фича, которая фундаментально изменила моё мышление об адаптивном дизайне. Двадцать лет у нас были media queries. Media queries спрашивают: «какой ширины вьюпорт?» Container queries спрашивают: «какой ширины контейнер, в котором живёт этот компонент?»
Это различие звучит тонко. Это не так. Это разница между компонентами, которые работают только в одном контексте лейаута, и компонентами, которые работают везде.
Проблема, существовавшая два десятилетия#
Рассмотрим компонент карточки. В сайдбаре она должна укладываться вертикально с маленькой картинкой. В основной области контента она должна быть горизонтальной с большей картинкой. В полноширинной hero-секции — чем-то совершенно другим.
С media queries ты бы писал что-то вроде:
/* The old way: coupling component styles to page layout */
.card {
display: flex;
flex-direction: column;
}
@media (min-width: 768px) {
.sidebar .card {
/* Still vertical in sidebar */
}
.main-content .card {
flex-direction: row;
/* Horizontal in main area */
}
}
@media (min-width: 1200px) {
.hero .card {
/* Yet another layout */
}
}Карточка знает о .sidebar, .main-content и .hero. Она знает о странице. Она больше не компонент — она фрагмент, привязанный к странице. Перенеси её на другую страницу, и всё ломается.
Container Queries полностью это решают#
/* The container query way: component knows only about itself */
.card-wrapper {
container-type: inline-size;
container-name: card;
}
.card {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@container card (min-width: 400px) {
.card {
grid-template-columns: 200px 1fr;
}
}
@container card (min-width: 700px) {
.card {
grid-template-columns: 300px 1fr;
gap: 2rem;
font-size: 1.125rem;
}
}Карточка не знает, где она живёт. Ей всё равно. Помести её в 300px сайдбар — она вертикальная. В 700px основную область — горизонтальная. В полноширинную секцию — адаптируется. Нулевое знание о лейауте страницы.
inline-size vs size#
Почти всегда тебе нужен container-type: inline-size. Это включает запросы по инлайн-оси (ширина в горизонтальных режимах письма). container-type: size включает запросы по обеим осям, но требует, чтобы контейнер имел явный размер в обоих измерениях, что нарушает нормальный документный поток в большинстве случаев.
/* This is what you want 99% of the time */
.wrapper {
container-type: inline-size;
}
/* This requires explicit height — rarely what you want */
.wrapper-both {
container-type: size;
height: 500px; /* required, or it collapses */
}Именованные контейнеры для вложенных контекстов#
Когда вкладываешь контейнеры, именование становится критичным:
.page-layout {
container-type: inline-size;
container-name: layout;
}
.sidebar {
container-type: inline-size;
container-name: sidebar;
}
/* Target the sidebar specifically, not the nearest ancestor */
@container sidebar (max-width: 250px) {
.nav-item {
font-size: 0.875rem;
padding: 0.25rem;
}
}
/* Target the page layout */
@container layout (min-width: 1200px) {
.page-header {
font-size: 2.5rem;
}
}Без имён @container запрашивает ближайшего предка с containment. При вложенности это часто не тот контейнер, который тебе нужен. Именуй их. Всегда.
Единицы измерения Container Query#
Эта штука недооценена. Единицы container query (cqi, cqb, cqw, cqh) позволяют размерять вещи относительно контейнера, а не вьюпорта:
@container (min-width: 400px) {
.card-title {
font-size: clamp(1rem, 4cqi, 2rem);
}
}4cqi — это 4% от инлайн-размера контейнера. Заголовок масштабируется с контейнером, а не с окном. Вот какой fluid-типографика должна была быть с самого начала.
CSS Layers: война специфичности окончена#
Если container queries изменили моё мышление об адаптивном дизайне, @layer изменил моё мышление об архитектуре CSS. Впервые у нас есть адекватный, декларативный способ управления специфичностью по всему проекту.
Проблема#
Специфичность CSS — это балльная система, которой плевать на твои намерения. Утилитарный класс .text-red проигрывает .card .title, потому что у последнего выше специфичность. Решение было всегда одним: сделай селекторы более специфичными, добавь !important или перестрой всё.
Мы создали целые методологии (BEM, SMACSS, ITCSS) и тулчейны только для избежания конфликтов специфичности. Всё это было обходным путём для отсутствующей языковой фичи.
Порядок слоёв#
@layer позволяет объявить порядок, в котором группы стилей рассматриваются, независимо от специфичности внутри этих групп:
/* Declare layer order — this single line controls everything */
@layer reset, base, components, utilities, overrides;
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
@layer base {
body {
font-family: system-ui, sans-serif;
line-height: 1.6;
color: oklch(20% 0 0);
}
h1, h2, h3 {
line-height: 1.2;
text-wrap: balance;
}
}
@layer components {
.card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px oklch(0% 0 0 / 0.12);
}
.card .title {
font-size: 1.25rem;
font-weight: 600;
color: oklch(25% 0.05 260);
}
}
@layer utilities {
.text-red {
color: oklch(55% 0.25 25);
}
.hidden {
display: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
}Хотя .card .title имеет более высокую специфичность, чем .text-red, утилита побеждает, потому что слой utilities объявлен после components. Никаких !important. Никаких хаков со специфичностью. Порядок слоёв — последнее слово.
Как Tailwind CSS v4 использует слои#
Tailwind v4 активно опирается на @layer. Когда пишешь @import "tailwindcss", получаешь:
@layer theme, base, components, utilities;Каждая утилита Tailwind живёт в слое utilities. Твои кастомные стили компонентов идут в components. Вот почему класс text-red-500 может переопределить цвет компонента без !important — он в более позднем слое.
Если ты строишь свою дизайн-систему без Tailwind, укради эту архитектуру. Она правильная:
@layer reset, tokens, base, layouts, components, utilities, overrides;Семь слоёв достаточно. Мне никогда не нужно было больше.
Стили без слоя побеждают всё#
Один подвох: стили, не находящиеся ни в одном слое, имеют наивысший приоритет. Это на самом деле полезно — значит, твои одноразовые переопределения для конкретной страницы автоматически побеждают:
@layer components {
.modal {
background: white;
}
}
/* Not in any layer — wins over everything in layers */
.special-page .modal {
background: oklch(95% 0.02 260);
}Но это также означает, что сторонний CSS, не осведомлённый о слоях, может переопределить всю твою систему. Оберни сторонние стили в слой, чтобы контролировать их:
@layer third-party {
@import url("some-library.css");
}Селектор :has(): селектор родителя, о котором мы всегда мечтали#
Буквально десятилетиями разработчики просили селектор родителя. «Я хочу стилизовать родителя на основе его дочерних элементов.» Ответ всегда был «CSS не может этого» с последующим обходным путём на JavaScript. :has() полностью это меняет, и оказывается, он ещё мощнее того, о чём мы просили.
Базовый выбор родителя#
/* Style a form group when its input has focus */
.form-group:has(input:focus) {
border-color: oklch(55% 0.2 260);
box-shadow: 0 0 0 3px oklch(55% 0.2 260 / 0.15);
}
/* Style a card differently when it contains an image */
.card:has(img) {
padding-top: 0;
}
.card:has(img) .card-content {
padding: 1.5rem;
}
/* A card without an image gets different treatment */
.card:not(:has(img)) {
border-left: 4px solid oklch(55% 0.2 260);
}Состояния валидации форм без JavaScript#
Вот где :has() становится по-настоящему захватывающим. В сочетании с псевдоклассами HTML-валидации ты можешь строить UI форм, реагирующие на состояние валидности, с нулевым количеством JavaScript:
/* The field wrapper reacts to its input's validity */
.field:has(input:invalid:not(:placeholder-shown)) {
--field-color: oklch(55% 0.25 25);
}
.field:has(input:valid:not(:placeholder-shown)) {
--field-color: oklch(55% 0.2 145);
}
.field {
--field-color: oklch(70% 0 0);
border: 2px solid var(--field-color);
border-radius: 0.5rem;
padding: 0.75rem;
transition: border-color 0.2s;
}
.field label {
color: var(--field-color);
font-size: 0.875rem;
font-weight: 500;
}
.field .error-message {
display: none;
color: oklch(55% 0.25 25);
font-size: 0.8rem;
margin-top: 0.25rem;
}
.field:has(input:invalid:not(:placeholder-shown)) .error-message {
display: block;
}Часть :not(:placeholder-shown) критична — она предотвращает появление стилей валидации на пустых полях, которые ещё не были тронуты.
Лейаут на основе количества дочерних элементов#
Этот паттерн абсурдно полезен и был реально невозможен до :has():
/* Adjust grid columns based on how many items exist */
.grid-auto:has(> :nth-child(4)) {
grid-template-columns: repeat(2, 1fr);
}
.grid-auto:has(> :nth-child(7)) {
grid-template-columns: repeat(3, 1fr);
}
.grid-auto:has(> :nth-child(13)) {
grid-template-columns: repeat(4, 1fr);
}Грид меняет количество колонок в зависимости от того, сколько у него дочерних элементов. Никакого JavaScript. Никакого ResizeObserver. Никакого переключения классов.
Детекция сайдбара#
Один из моих любимых паттернов — изменение лейаута в зависимости от наличия сайдбара:
.page-layout {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
/* If the layout contains a sidebar, switch to two columns */
.page-layout:has(.sidebar) {
grid-template-columns: 1fr 300px;
}
/* Adjust main content width when sidebar is present */
.page-layout:has(.sidebar) .main-content {
max-width: 65ch;
}Добавь компонент сайдбара в DOM — лейаут подстроится. Убери его — подстроится обратно. CSS является источником истины, а не переменная состояния.
Комбинирование :has() с другими селекторами#
:has() прекрасно компонуется со всем остальным:
/* Style an article only when it has a specific class of figure */
article:has(figure.full-bleed) {
overflow: visible;
}
/* A navigation that changes when it contains a search input */
nav:has(input[type="search"]:focus) {
background: oklch(98% 0 0);
box-shadow: 0 2px 8px oklch(0% 0 0 / 0.08);
}
/* Enable dark-mode at the component level */
.theme-switch:has(input:checked) ~ main {
color-scheme: dark;
background: oklch(15% 0 0);
color: oklch(90% 0 0);
}Поддержка браузерами сейчас отличная. Каждый современный браузер поддерживает :has() с начала 2024 года. Нет причин его не использовать.
CSS Nesting: нативный, наконец стабильный#
Не буду перехваливать эту штуку. CSS-нестинг — приятная фича. Она не революционная, как container queries или :has(). Но она убирает одну из последних причин использовать Sass, и это важно.
Синтаксис#
.card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
.title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.description {
color: oklch(40% 0 0);
line-height: 1.7;
}
&:hover {
box-shadow: 0 4px 12px oklch(0% 0 0 / 0.1);
}
&.featured {
border: 2px solid oklch(60% 0.2 260);
}
}Это валидный CSS. Никакого этапа сборки. Никакого препроцессора. Никакого плагина PostCSS. Браузер обрабатывает это нативно.
Отличия от Sass#
Есть несколько синтаксических различий, которые стоит знать:
/* CSS Nesting — works now in all browsers */
.parent {
/* Direct class/element nesting works without & */
.child {
color: red;
}
/* & is required for pseudo-classes and compound selectors */
&:hover {
background: blue;
}
&.active {
font-weight: bold;
}
/* Nested media queries — this is great */
@media (width >= 768px) {
flex-direction: row;
}
}В ранних реализациях нужен был & перед селекторами элементов (вроде & p вместо просто p). Это ограничение было снято. С 2025 года все основные браузеры поддерживают голый нестинг элементов: .parent { p { ... } } работает нормально.
Вложенные Media Queries#
Это, на мой взгляд, киллер-фича CSS-нестинга. Не нестинг селекторов — возможность помещать media queries внутрь блока правил:
.hero {
padding: 2rem;
font-size: 1rem;
@media (width >= 768px) {
padding: 4rem;
font-size: 1.25rem;
}
@media (width >= 1200px) {
padding: 6rem;
font-size: 1.5rem;
}
}Сравни со старым подходом, где стили .hero были разбросаны по трём разным блокам @media, возможно, на сотни строк друг от друга. Нестинг держит адаптивное поведение рядом с компонентом. Читаемость улучшается кардинально.
Не вкладывай слишком глубоко#
Одно предупреждение: то, что ты можешь вкладывать на шесть уровней, не значит, что нужно. Тот же совет из Sass применим здесь. Если нестинг создаёт селекторы типа .page .section .card .content .text .highlight, ты создал монстра специфичности и кошмар для поддержки. Два-три уровня — идеальный баланс.
/* Good — two levels */
.nav {
.link {
color: inherit;
&:hover {
color: oklch(55% 0.2 260);
}
}
}
/* Bad — specificity nightmare */
.page {
.layout {
.sidebar {
.nav {
.list {
.item {
.link {
color: red; /* good luck overriding this */
}
}
}
}
}
}
}Цветовые функции: oklch() и color-mix() меняют всё#
У hsl() был хороший забег. Он был интуитивнее rgb(). Но у него фундаментальный недостаток: он не перцептуально однородный. hsl(60, 100%, 50%) (жёлтый) выглядит для человеческого глаза значительно светлее, чем hsl(240, 100%, 50%) (синий), хотя у них одинаковое значение светлоты.
Почему побеждает oklch()#
oklch() перцептуально однородный. Одинаковые значения светлоты выглядят одинаково светлыми. Это критически важно при генерации цветовых палитр, создании тем и обеспечении доступного контраста:
:root {
/* oklch(lightness chroma hue) */
--color-primary: oklch(55% 0.2 260); /* blue */
--color-secondary: oklch(55% 0.2 330); /* purple */
--color-success: oklch(55% 0.2 145); /* green */
--color-danger: oklch(55% 0.25 25); /* red */
--color-warning: oklch(75% 0.18 85); /* yellow — higher L for equal perception */
/* These all look equally "medium" to the human eye */
/* With HSL, you'd need different lightness values for each hue */
}Три значения интуитивны, когда ты их понимаешь:
- Lightness (0% до 100%): насколько светлый или тёмный. 0% — чёрный, 100% — белый.
- Chroma (0 до ~0.37): насколько насыщенный. 0 — серый, больше — более насыщенный.
- Hue (0 до 360): угол цвета. 0/360 — розовато-красный, 145 — зелёный, 260 — синий.
color-mix() для производных цветов#
color-mix() позволяет создавать цвета из других цветов в рантайме. Никакой функции Sass darken(). Никакого JavaScript. Просто CSS:
:root {
--brand: oklch(55% 0.2 260);
/* Lighten by mixing with white */
--brand-light: color-mix(in oklch, var(--brand) 30%, white);
/* Darken by mixing with black */
--brand-dark: color-mix(in oklch, var(--brand) 70%, black);
/* Create a subtle background */
--brand-bg: color-mix(in oklch, var(--brand) 8%, white);
/* Semi-transparent version */
--brand-overlay: color-mix(in oklch, var(--brand) 50%, transparent);
}
.button {
background: var(--brand);
color: white;
&:hover {
background: var(--brand-dark);
}
&:active {
background: color-mix(in oklch, var(--brand) 60%, black);
}
}
.button.secondary {
background: var(--brand-bg);
color: var(--brand-dark);
&:hover {
background: color-mix(in oklch, var(--brand) 15%, white);
}
}Часть in oklch важна. Смешивание в srgb даёт мутные промежуточные цвета. Смешивание в oklch даёт перцептуально ровные результаты. Всегда миксуй в oklch.
Создание полной палитры с oklch()#
Вот как я генерирую целую палитру оттенков из одного hue:
:root {
--hue: 260;
--chroma: 0.2;
--color-50: oklch(97% calc(var(--chroma) * 0.1) var(--hue));
--color-100: oklch(93% calc(var(--chroma) * 0.2) var(--hue));
--color-200: oklch(85% calc(var(--chroma) * 0.4) var(--hue));
--color-300: oklch(75% calc(var(--chroma) * 0.6) var(--hue));
--color-400: oklch(65% calc(var(--chroma) * 0.8) var(--hue));
--color-500: oklch(55% var(--chroma) var(--hue));
--color-600: oklch(48% var(--chroma) var(--hue));
--color-700: oklch(40% calc(var(--chroma) * 0.9) var(--hue));
--color-800: oklch(32% calc(var(--chroma) * 0.8) var(--hue));
--color-900: oklch(25% calc(var(--chroma) * 0.7) var(--hue));
--color-950: oklch(18% calc(var(--chroma) * 0.5) var(--hue));
}Поменяй --hue на 145 — получишь зелёную палитру. На 25 — красную. Шаги светлоты перцептуально ровные. Chroma сужается на краях, чтобы самые светлые и тёмные оттенки не были перенасыщены. Это то, что раньше требовало дизайн-инструмента или функции Sass. Теперь это восемь строк CSS.
Scroll-Driven Animations: JavaScript не нужен#
Это фича, благодаря которой я удалил больше всего JavaScript. Привязанные к скроллу анимации — прогресс-бары, параллакс-эффекты, анимации появления, липкие хедеры с переходами — раньше требовали IntersectionObserver, слушателей события scroll или библиотеку типа GSAP. Теперь это CSS.
Индикатор прогресса чтения#
Классический «прогресс-бар чтения» в верхней части страницы статьи:
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 3px;
background: oklch(55% 0.2 260);
transform-origin: left;
z-index: 1000;
animation: grow-progress linear both;
animation-timeline: scroll();
}
@keyframes grow-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}Вот и всё. Весь индикатор прогресса чтения. Никакого JavaScript. Никакого слушателя события скролла. Никакого requestAnimationFrame. Никакого вычисления «процента прокрутки». Привязка animation-timeline: scroll() делает всё.
Появление при скролле#
Элементы, которые плавно появляются при входе во вьюпорт:
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}animation-timeline: view() привязывает анимацию к видимости элемента во вьюпорте. animation-range: entry 0% entry 100% означает, что анимация запускается с момента, когда элемент начинает входить во вьюпорт, до полной видимости.
Параллакс без библиотеки#
.parallax-bg {
animation: parallax linear both;
animation-timeline: scroll();
}
@keyframes parallax {
from {
transform: translateY(-20%);
}
to {
transform: translateY(20%);
}
}
.parallax-section {
overflow: hidden;
position: relative;
}Фоновое изображение движется с другой скоростью, чем скролл, создавая эффект параллакса. Плавно, производительно (браузер может композитить на GPU), и ноль JavaScript.
Именованные Scroll Timelines#
Для большего контроля можно именовать scroll timelines и ссылаться на них из других элементов:
.scroller {
overflow-y: auto;
scroll-timeline-name: --my-scroller;
scroll-timeline-axis: block;
}
/* An element anywhere in the DOM can reference this timeline */
.indicator {
animation: progress linear both;
animation-timeline: --my-scroller;
}
@keyframes progress {
from { width: 0%; }
to { width: 100%; }
}Это работает, даже когда индикатор и скроллер не родитель/потомок. Любой элемент может привязаться к любому именованному scroll timeline. Это мощно для дашборд-интерфейсов, где скроллируемая панель управляет индикатором в фиксированном хедере.
Anchor Positioning: тултипы и поповеры сделаны правильно#
До anchor positioning привязка тултипа к элементу-триггеру требовала JavaScript. Ты вычислял позиции через getBoundingClientRect(), обрабатывал смещения скролла, управлял коллизиями с вьюпортом и пересчитывал при ресайзе. Библиотеки типа Popper.js (теперь Floating UI) существовали специально потому, что это было так сложно сделать правильно.
CSS anchor positioning делает это декларативным:
.trigger {
anchor-name: --my-trigger;
}
.tooltip {
position: fixed;
position-anchor: --my-trigger;
/* Position the tooltip's top-center at the trigger's bottom-center */
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
/* Fallback positioning if it overflows the viewport */
position-try-fallbacks: flip-block, flip-inline;
}Автоматическая обработка коллизий с вьюпортом#
Свойство position-try-fallbacks — это та часть, которая потребовала бы 200 строк JavaScript. Оно говорит браузеру: «если тултип выходит за вьюпорт снизу, переверни его наверх. Если выходит справа, переверни влево.»
.dropdown-menu {
position: fixed;
position-anchor: --menu-button;
/* Default: below the button, aligned to the left edge */
top: anchor(bottom);
left: anchor(left);
/* If it doesn't fit below, try above. If it doesn't fit left-aligned, try right-aligned */
position-try-fallbacks: flip-block, flip-inline;
/* Add a gap between the anchor and the dropdown */
margin-top: 4px;
}Именованные Position Fallbacks#
Для большего контроля над запасными позициями можно определить кастомные try-опции:
@position-try --above-right {
bottom: anchor(top);
right: anchor(right);
top: auto;
left: auto;
margin-top: 0;
margin-bottom: 4px;
}
@position-try --left-center {
right: anchor(left);
top: anchor(center);
left: auto;
bottom: auto;
translate: 0 -50%;
margin-top: 0;
margin-right: 4px;
}
.tooltip {
position: fixed;
position-anchor: --trigger;
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
margin-top: 4px;
position-try-fallbacks: --above-right, --left-center;
}Браузер пробует каждый фолбэк по порядку, пока не найдёт тот, который удерживает элемент внутри вьюпорта. Это тот вид пространственных рассуждений, который был по-настоящему мучительным в JavaScript.
Anchoring с Popover API#
Anchor positioning идеально сочетается с новым Popover API:
<button popovertarget="my-popover" style="anchor-name: --btn">Settings</button>
<div popover id="my-popover" style="
position-anchor: --btn;
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
margin-top: 8px;
">
Popover content here
</div>Никакого JavaScript для показа/скрытия (Popover API обрабатывает это). Никакого JavaScript для позиционирования (anchor positioning обрабатывает это). Никакого JavaScript для light-dismiss поведения (Popover API обрабатывает и это тоже). Весь паттерн тултипа/поповера/дропдауна — паттерн, который питал целые npm-пакеты — теперь это HTML и CSS.
CSS Grid Subgrid: вложенное выравнивание, которое реально работает#
Grid мощный, но у него было одно раздражающее ограничение: дочерний грид не мог выравнивать свои элементы по родительскому гриду. Если у тебя был ряд карточек и ты хотел, чтобы заголовок, контент и футер каждой карточки выравнивались между карточками, ты был в пролёте. Внутренний грид каждой карточки был независимым.
Subgrid исправляет это.
Проблема выравнивания карточек#
/* Parent grid */
.card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
/* Each card becomes a subgrid, aligning rows to the parent */
.card {
display: grid;
grid-row: span 3; /* Card spans 3 rows in the parent */
grid-template-rows: subgrid;
gap: 0; /* Card controls its own internal gap */
}
.card-title {
font-weight: 600;
padding: 1rem 1rem 0.5rem;
}
.card-body {
padding: 0 1rem;
color: oklch(40% 0 0);
}
.card-footer {
padding: 0.5rem 1rem 1rem;
margin-top: auto;
border-top: 1px solid oklch(90% 0 0);
}Теперь заголовки всех трёх карточек выравниваются на одной строке. Тела выравниваются. Футеры выравниваются. Даже когда у одной карточки двухстрочный заголовок, а у другой однострочный, выравнивание поддерживается родительским гридом.
До Subgrid: хаки#
Без subgrid для достижения такого выравнивания требовалось либо:
- Фиксированные высоты (хрупко, ломается с динамическим контентом)
- Измерение через JavaScript (медленно, мигание невыровненности)
- Смириться и принять невыровненность (распространённо, некрасиво)
/* The old hack — fragile and breaks with dynamic content */
.card-title {
min-height: 3rem; /* pray that no title exceeds this */
}
.card-body {
min-height: 8rem; /* even more prayer */
}Subgrid делает хак ненужным. Родительский грид распределяет высоты строк на основе самого высокого контента в каждой строке по всем карточкам.
Column Subgrid для лейаутов форм#
Subgrid работает и по колонкам, что идеально для лейаутов форм:
.form {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem 1rem;
}
.form-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
.form-row label {
grid-column: 1;
text-align: right;
font-weight: 500;
}
.form-row input {
grid-column: 2;
padding: 0.5rem;
border: 1px solid oklch(80% 0 0);
border-radius: 0.375rem;
}Все лейблы выравниваются. Все инпуты выравниваются. Колонка лейблов автоматически подстраивается под самый широкий лейбл. Никаких захардкоженных ширин.
View Transitions API: SPA-подобная навигация без фреймворка#
Это самая амбициозная фича в этом списке. View Transitions API позволяет анимировать переходы между страницами — включая межстраничные переходы (обычные клики по ссылкам в многостраничном приложении). Твой статический HTML-сайт теперь может иметь плавные, анимированные переходы между страницами.
Межстраничные переходы#
Чтобы включить межстраничные view transitions, добавь одно CSS-правило на обеих страницах:
@view-transition {
navigation: auto;
}Вот и всё. Браузер теперь будет плавно перетекать между страницами при навигации. Переход по умолчанию — плавное затухание по прозрачности. Никакого JavaScript. Никакого фреймворка. Просто две строки CSS на каждой странице.
Настройка переходов#
Ты можешь настроить, какие переходы происходят, именуя конкретные элементы:
/* On both pages */
.page-header {
view-transition-name: header;
}
.main-content {
view-transition-name: content;
}
.hero-image {
view-transition-name: hero;
}
/* Transition animations */
::view-transition-old(content) {
animation: slide-out 0.3s ease-in both;
}
::view-transition-new(content) {
animation: slide-in 0.3s ease-out both;
}
::view-transition-old(hero) {
animation: fade-out 0.2s ease-in both;
}
::view-transition-new(hero) {
animation: none; /* The new hero just appears */
}
@keyframes slide-out {
to {
transform: translateX(-100%);
opacity: 0;
}
}
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes fade-out {
to {
opacity: 0;
}
}При навигации между двумя страницами, которые обе имеют .hero-image с view-transition-name: hero, браузер автоматически анимирует изображение от его позиции на старой странице к позиции на новой. Это паттерн «shared element transition» из мобильной разработки, теперь в браузере.
SPA View Transitions#
Для одностраничных приложений (React, Vue, Svelte и т. д.) JavaScript API прост:
/* CSS side — define your transition animations */
::view-transition-old(root) {
animation: fade-out 0.2s ease-in;
}
::view-transition-new(root) {
animation: fade-in 0.2s ease-out;
}// JS side — wrap your DOM update in startViewTransition
document.startViewTransition(() => {
// Update the DOM here — React render, innerHTML swap, etc.
updateContent(newPageData);
});Браузер делает снимок старого состояния, выполняет обновление, делает снимок нового состояния и анимирует между ними. Именованные элементы получают индивидуальные переходы; всё остальное получает кроссфейд по умолчанию.
Уважение к пользовательским предпочтениям#
Всегда уважай prefers-reduced-motion:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}Что можно перестать использовать#
Эти фичи в совокупности устраняют множество инструментов, библиотек и паттернов, на которые мы полагались годами. Вот что я убрал или перестал к ним тянуться:
Sass/SCSS для нестинга и переменных#
В CSS есть нативный нестинг. В CSS есть кастомные свойства (и они есть уже годами). Две главные причины, по которым люди тянулись к Sass, теперь есть в языке. Если ты всё ещё используешь Sass только ради $variables и нестинга, можешь прекратить.
/* Before: Sass */
$primary: #3b82f6;
.card {
background: white;
border: 1px solid lighten($primary, 40%);
&:hover {
border-color: $primary;
}
.title {
color: darken($primary, 15%);
}
}
/* After: Native CSS */
.card {
--primary: oklch(55% 0.2 260);
background: white;
border: 1px solid color-mix(in oklch, var(--primary) 20%, white);
&:hover {
border-color: var(--primary);
}
.title {
color: color-mix(in oklch, var(--primary) 70%, black);
}
}CSS-версия мощнее — oklch даёт перцептуально однородное манипулирование цветом, color-mix работает в рантайме (Sass lighten — только на этапе компиляции), а кастомные свойства можно менять динамически из JavaScript или media queries.
JavaScript для скролл-анимаций#
Удали свой IntersectionObserver код для reveal-on-scroll. Удали JavaScript прогресс-бара скролла. Удали обработчик параллакса. animation-timeline: scroll() и animation-timeline: view() обрабатывают всё это с лучшей производительностью (поток композитора, не основной поток).
// Before: JavaScript you can delete
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 }
);
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));/* After: CSS that replaces all of the above */
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes reveal {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}Popper.js / Floating UI для позиционирования#
Если ты используешь Floating UI (бывший Popper.js) только для базового позиционирования тултипов/поповеров, CSS anchor positioning его заменяет. Ты теряешь некоторые продвинутые фичи (виртуальные элементы, кастомный middleware), но для 90% случаев — «поставь этот поповер рядом с кнопкой и удержи во вьюпорте» — CSS делает это нативно.
Хаки центрирования с margin: auto#
Это не ново, но я до сих пор вижу margin: 0 auto повсюду. Современные инструменты лейаута делают это ненужным в большинстве случаев:
/* Old way */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
/* Better: CSS Grid or Flexbox */
.page {
display: grid;
place-items: center;
}
/* Or: container queries make the container itself responsive */
.content {
container-type: inline-size;
width: min(100% - 2rem, 1200px);
margin-inline: auto; /* OK, still auto, but margin-inline is clearer */
}margin: 0 auto всё ещё работает. Но если ты используешь его для вертикального центрирования или сложного выравнивания, тянись к flexbox или grid.
Media Queries для адаптивности на уровне компонентов#
Вот главное. Если ты пишешь @media queries для адаптивности компонента, ты, скорее всего, делаешь это неправильно теперь. Container queries должны быть по умолчанию для адаптивности на уровне компонентов. Зарезервируй @media queries для решений на уровне страницы: изменения лейаута, паттерны навигации, стили печати.
/* Before: Media query for component styling (wrong scope) */
@media (min-width: 768px) {
.product-card {
flex-direction: row;
}
}
/* After: Container query (right scope) */
.product-card-wrapper {
container-type: inline-size;
}
@container (min-width: 400px) {
.product-card {
flex-direction: row;
}
}Общая картина#
Эти фичи не существуют изолированно. Они компонуются. Container queries + :has() + нестинг + oklch() + слои — используемые вместе — дают опыт написания CSS, который был бы неузнаваем пять лет назад:
@layer components {
.card-wrapper {
container-type: inline-size;
}
.card {
--accent: oklch(55% 0.2 260);
display: grid;
gap: 1rem;
padding: 1.5rem;
border-radius: 0.5rem;
background: white;
border: 1px solid oklch(90% 0 0);
&:has(img) {
padding-top: 0;
img {
border-radius: 0.5rem 0.5rem 0 0;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
}
&:has(.badge.new) {
border-color: var(--accent);
}
.title {
font-size: 1.125rem;
font-weight: 600;
color: oklch(20% 0 0);
text-wrap: balance;
}
.meta {
font-size: 0.875rem;
color: oklch(50% 0 0);
}
@container (min-width: 500px) {
grid-template-columns: 200px 1fr;
&:has(img) {
padding: 0;
img {
border-radius: 0.5rem 0 0 0.5rem;
height: 100%;
aspect-ratio: auto;
}
}
.content {
padding: 1.5rem;
}
}
}
}Этот единственный блок обрабатывает:
- Адаптивный лейаут на уровне компонента (container queries)
- Условную стилизацию на основе контента (
:has()) - Чистую организацию селекторов (нестинг)
- Перцептуально однородные цвета (
oklch) - Предсказуемое управление специфичностью (
@layer)
Никакого препроцессора. Никакого CSS-in-JS рантайма. Никаких утилитарных классов для этого. Просто CSS, делающий то, что CSS должен был делать всё это время.
Платформа догнала. Наконец. И ожидание того стоило.