Сучасний CSS у 2026: Фічі, що змінили мій підхід до стилів
Container queries, CSS layers, :has(), color-mix(), вкладеність, scroll-driven анімації та anchor positioning. CSS-фічі, завдяки яким я перестав тягнутися до JavaScript.
Я видалив свій останній Sass-файл пів року тому. Не тому, що робив заяву. Тому що він мені реально більше не був потрібен.
Понад десятиліття CSS була мовою, за яку ми перепрошували. Нам потрібні були препроцесори для вкладеності та змінних. Нам потрібен був JavaScript для розмірів на основі контейнера, анімацій прив'язаних до скролу, вибору батьківського елемента та половини лейаут-патернів, які нам давали дизайнери. Ми будували цілі рантайм-системи — CSS-in-JS бібліотеки, утилітарні фреймворки, ланцюжки PostCSS-плагінів — щоб компенсувати те, що мова не могла робити нативно.
Ця ера закінчилась. Не "майже закінчилась." Не "от-от." Закінчилась.
Фічі, що з'явились у браузерах між 2024 і 2026 роками, не просто додали зручності. Вони змінили ментальну модель. CSS більше не мова стилів, проти якої ти борешся. Це мова стилів, яка реально думає про компоненти, управління специфічністю, адаптивний дизайн на рівні елементів та анімацію без рантайму.
Ось що змінилось, чому це важливо, і що ти можеш перестати робити завдяки цьому.
Container Queries: Кінець "Яка ширина в'юпорту?"#
Це та фіча, що фундаментально змінила те, як я думаю про адаптивний дизайн. Двадцять років у нас були медіа-запити. Медіа-запити питають: "яка ширина в'юпорту?" Container queries питають: "яка ширина контейнера, в якому живе цей компонент?"
Ця різниця звучить тонко. Вона не тонка. Це різниця між компонентами, що працюють тільки в одному контексті лейауту, і компонентами, що працюють скрізь.
Проблема, що існувала два десятиліття#
Уяви компонент картки. У сайдбарі він повинен укладатись вертикально з маленьким зображенням. В основній зоні контенту — горизонтально з більшим зображенням. В повноширинній hero-секції — щось зовсім інше.
З медіа-запитами ти б написав щось таке:
/* 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 Units#
Цю штуку недооцінюють. Container query units (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 використовує Layers#
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;Сім шарів — це достатньо. Мені ніколи не потрібно було більше.
Стилі без шарів перемагають все#
Одна пастка: стилі, що не в жодному шарі, мають найвищий пріоритет. Це насправді корисно — це означає, що твої одноразові 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: Нативна, нарешті стабільна#
Я не буду перехвалювати цю фічу. Вкладеність 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 року, всі основні браузери підтримують bare element nesting: .parent { p { ... } } працює нормально.
Вкладені медіа-запити#
Це кілер-фіча вкладеності CSS, на мою думку. Не вкладеність селекторів — можливість розміщувати медіа-запити всередині блоку правил:
.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 анімації: Без JavaScript#
Це фіча, яка змусила мене видалити найбільше JavaScript. Анімації, прив'язані до скролу — прогрес-бари, паралакс-ефекти, анімації появи, sticky-хедери з переходами — раніше вимагали 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() робить все.
Поява при скролі#
Елементи, що з'являються з ефектом fade, коли входять у в'юпорт:
.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;
}Іменовані запасні позиції#
Для більшого контролю над запасними позиціями ти можеш визначити кастомні 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.
Прив'язка з 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;
}Все. Браузер тепер робить crossfade між сторінками при навігації. Перехід за замовчуванням — плавний fade непрозорості. Без 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);
});Браузер робить знімок старого стану, виконує твоє оновлення, робить знімок нового стану й анімує між ними. Іменовані елементи отримують індивідуальні переходи; все інше отримує crossfade за замовчуванням.
Повага до налаштувань користувача#
Завжди поважай 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 працює тільки в compile-time), а кастомні властивості можна змінювати динамічно з JavaScript або медіа-запитів.
JavaScript для scroll-анімацій#
Видали свій 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-запити для адаптивності компонента, ти скоріш за все робиш це неправильно. Container queries мають бути твоїм дефолтом для адаптивності на рівні компонентів. Залишай @media-запити для рішень на рівні сторінки: зміни лейауту, патерни навігації, стилі друку.
/* 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() + layers — використані разом — дають досвід написання 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 мав робити весь час.
Платформа наздогнала. Нарешті. І це було варте очікування.