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 питають: "яка ширина контейнера, в якому живе цей компонент?"
Ця різниця звучить тонко. Вона не тонка. Це різниця між компонентами, що працюють тільки в одному контексті лейауту, і компонентами, що працюють скрізь.
Уяви компонент картки. У сайдбарі він повинен укладатись вертикально з маленьким зображенням. В основній зоні контенту — горизонтально з більшим зображенням. В повноширинній 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. Вона знає про сторінку. Це більше не компонент — це фрагмент, прив'язаний до сторінки. Перемісти його на іншу сторінку і все зламається.
/* 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 (cqi, cqb, cqw, cqh) дають змогу розмірювати речі відносно контейнера, а не в'юпорту:
@container (min-width: 400px) {
.card-title {
font-size: clamp(1rem, 4cqi, 2rem);
}
}4cqi — це 4% від інлайн-розміру контейнера. Заголовок масштабується з контейнером, а не з вікном. Ось якою fluid-типографіка мала бути з самого початку.
Якщо 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 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);
}Ось де :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 — це приємно. Це не революційно, як 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-плагіна. Браузер обробляє це нативно.
Є кілька синтаксичних відмінностей, про які варто знати:
/* 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 */
}Три значення інтуїтивні, коли їх зрозумієш:
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.
Це фіча, яка змусила мене видалити найбільше 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 і посилатися на них з інших елементів:
.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, прив'язка тултіпа до його тригер-елемента вимагала 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.
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.
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 для досягнення цього вирівнювання потрібно було або:
/* 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 робить хак непотрібним. Батьківський грід розподіляє висоти рядків на основі найвищого контенту в кожному ряду по всіх картках.
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 дає змогу анімувати перехід між навігаціями сторінок — включно з крос-документними навігаціями (звичайні кліки по посиланнях у багатосторінковому застосунку). Твій статичний 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" з мобільної розробки, тепер у браузері.
Для односторінкових застосунків (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;
}
}Ці фічі разом усувають багато інструментів, бібліотек і патернів, на які ми покладались роками. Ось від чого я відмовився або перестав тягнутися:
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 або медіа-запитів.
Видали свій 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);
}
}Якщо ти використовуєш 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;
}
}
}
}Цей один блок обробляє:
:has())oklch)@layer)Жодного препроцесора. Жодного CSS-in-JS рантайму. Жодних утилітарних класів для цього. Просто CSS, що робить те, що CSS мав робити весь час.
Платформа наздогнала. Нарешті. І це було варте очікування.