Przejdź do treści
·22 min czytania

Nowoczesny CSS w 2026: Funkcje, które zmieniły moje podejście do stylowania

Container queries, CSS layers, :has(), color-mix(), nesting, animacje sterowane scrollem i anchor positioning. Funkcje CSS, przez które przestałem sięgać po JavaScript.

Udostępnij:X / TwitterLinkedIn

Sześć miesięcy temu skasowałem swój ostatni plik Sass. Nie dlatego, że chciałem zrobić jakieś oświadczenie. Po prostu naprawdę go już nie potrzebowałem.

Przez ponad dekadę CSS był językiem, za który się przepraszało. Potrzebowaliśmy preprocesorów do zagnieżdżania i zmiennych. Potrzebowaliśmy JavaScriptu do rozmiarowania opartego na kontenerze, animacji powiązanych ze scrollem, selekcji rodzica i połowy wzorców layoutu, które wręczali nam projektanci. Budowaliśmy całe systemy runtime'owe — biblioteki CSS-in-JS, frameworki utility, łańcuchy pluginów PostCSS — żeby nadrobić to, czego język nie potrafił natywnie.

Ta era dobiegła końca. Nie "prawie się skończyła". Nie "zmierza ku końcowi". Skończyła się.

Funkcje, które trafiły do przeglądarek między 2024 a 2026 rokiem, nie tylko dodały wygody. Zmieniły model mentalny. CSS nie jest już językiem stylowania, z którym walczysz. To język stylowania, który faktycznie myśli o komponentach, zarządzaniu specyficznością, responsywnym designie na poziomie elementu i animacjach bez runtime'u.

Oto co się zmieniło, dlaczego to ma znaczenie i z czego możesz zrezygnować dzięki temu.

Container Queries: Koniec z "Jaką szerokość ma viewport?"#

To jest ta funkcja, która fundamentalnie zmieniła moje podejście do responsywnego designu. Przez dwadzieścia lat mieliśmy media queries. Media queries pytają: "jaką szerokość ma viewport?" Container queries pytają: "jaką szerokość ma kontener, w którym żyje ten komponent?"

Ta różnica brzmi subtelnie. Nie jest. To różnica między komponentami, które działają tylko w jednym kontekście layoutu, a komponentami, które działają wszędzie.

Problem, który istniał przez dwie dekady#

Weź pod uwagę komponent karty. W sidebarze powinien układać się pionowo z małym obrazkiem. W głównym obszarze treści powinien przejść w poziomy z większym obrazkiem. W sekcji hero na całą szerokość powinien wyglądać jeszcze inaczej.

Z media queries napisałbyś coś takiego:

css
/* 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 */
  }
}

Karta wie o .sidebar, .main-content i .hero. Wie o stronie. To już nie jest komponent — to fragment świadomy strony. Przenieś go na inną stronę i wszystko się psuje.

Container Queries naprawiają to całkowicie#

css
/* 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;
  }
}

Karta nie wie, gdzie jest umieszczona. Nie obchodzi ją to. Umieść ją w 300px sidebarze i będzie pionowa. Umieść w 700px głównym obszarze i będzie pozioma. Wrzuć do sekcji na pełną szerokość i się zaadaptuje. Zero wiedzy o layoucie strony.

inline-size vs size#

Prawie zawsze będziesz chciał container-type: inline-size. To włącza zapytania na osi inline (szerokość w poziomych trybach pisania). Użycie container-type: size włącza zapytania na obu osiach — inline i block — ale wymaga, żeby kontener miał jawnie określone wymiary w obu kierunkach, co w większości przypadków psuje normalny flow dokumentu.

css
/* 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 */
}

Nazwane kontenery dla zagnieżdżonych kontekstów#

Kiedy zagnieżdżasz kontenery, nazewnictwo staje się niezbędne:

css
.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;
  }
}

Bez nazw @container odpytuje najbliższego przodka z containmentem. Przy zagnieżdżeniu to często nie jest ten kontener, którego chcesz. Nazywaj je. Zawsze.

Jednostki Container Query#

Ten element jest niedoceniany. Jednostki container query (cqi, cqb, cqw, cqh) pozwalają wymiarować rzeczy względem kontenera, a nie viewportu:

css
@container (min-width: 400px) {
  .card-title {
    font-size: clamp(1rem, 4cqi, 2rem);
  }
}

4cqi to 4% rozmiaru inline kontenera. Tytuł skaluje się z kontenerem, nie z oknem. To jest to, czym fluid typography powinno być od samego początku.

CSS Layers: Wojna o specyficzność dobiegła końca#

Jeśli container queries zmieniły moje podejście do responsywnego designu, @layer zmienił moje podejście do architektury CSS. Po raz pierwszy mamy sensowny, deklaratywny sposób zarządzania specyficznością w całym projekcie.

Problem#

Specyficzność CSS to system punktowy, który nie liczy się z twoimi intencjami. Klasa utility .text-red przegrywa z .card .title, bo ta druga ma wyższą specyficzność. Rozwiązanie było zawsze takie samo: twórz bardziej specyficzne selektory, dodawaj !important albo przebudowuj wszystko.

Zbudowaliśmy całe metodologie (BEM, SMACSS, ITCSS) i toolchainy tylko po to, żeby unikać konfliktów specyficzności. Wszystko to było obejściem brakującej funkcji języka.

Kolejność warstw#

@layer pozwala zadeklarować kolejność, w jakiej grupy stylów są rozpatrywane, niezależnie od specyficzności wewnątrz tych grup:

css
/* 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);
  }
}

Mimo że .card .title ma wyższą specyficzność niż .text-red, utility wygrywa, bo warstwa utilities jest zadeklarowana po components. Żadnego !important. Żadnych hacków specyficzności. Kolejność warstw jest ostateczna.

Jak Tailwind CSS v4 wykorzystuje warstwy#

Tailwind v4 mocno opiera się na @layer. Kiedy piszesz @import "tailwindcss", dostajesz:

css
@layer theme, base, components, utilities;

Każda klasa utility Tailwinda żyje w warstwie utilities. Twoje własne style komponentów trafiają do components. Dlatego klasa text-red-500 może nadpisać kolor komponentu bez potrzeby !important — jest w późniejszej warstwie.

Jeśli budujesz swój własny design system bez Tailwinda, ukradnij tę architekturę. To ta właściwa:

css
@layer reset, tokens, base, layouts, components, utilities, overrides;

Siedem warstw w zupełności wystarczy. Nigdy nie potrzebowałem więcej.

Style bez warstw biorą górę nad wszystkim#

Jedna pułapka: style, które nie są w żadnej warstwie, mają najwyższy priorytet. To jest w sumie przydatne — oznacza, że twoje jednorazowe nadpisania specyficzne dla strony automatycznie wygrywają:

css
@layer components {
  .modal {
    background: white;
  }
}
 
/* Not in any layer — wins over everything in layers */
.special-page .modal {
  background: oklch(95% 0.02 260);
}

Ale oznacza to też, że zewnętrzny CSS, który nie jest świadomy warstw, może nadpisać cały twój system. Opakowuj style third-party w warstwę, żeby je kontrolować:

css
@layer third-party {
  @import url("some-library.css");
}

Selektor :has(): Selektor rodzica, na który zawsze czekaliśmy#

Przez dosłownie dekady deweloperzy prosili o selektor rodzica. "Chcę ostylować rodzica na podstawie jego dzieci." Odpowiedź zawsze brzmiała "CSS tego nie potrafi", a potem szło obejście w JavaScript. :has() zmienia to całkowicie i okazuje się, że jest jeszcze potężniejszy niż to, o co prosiliśmy.

Podstawowa selekcja rodzica#

css
/* 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);
}

Stany walidacji formularzy bez JavaScriptu#

To jest moment, w którym :has() staje się naprawdę ekscytujący. W połączeniu z pseudoklasami walidacji HTML możesz budować interfejsy formularzy reagujące na stan walidacji przy zerowej ilości JavaScriptu:

css
/* 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;
}

Część :not(:placeholder-shown) jest kluczowa — zapobiega pojawieniu się stylów walidacji na pustych polach, które nie zostały jeszcze dotknięte.

Layout oparty na liczbie dzieci#

Ten wzorzec jest absurdalnie przydatny i był naprawdę niemożliwy przed :has():

css
/* 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);
}

Grid zmienia liczbę kolumn w zależności od tego, ile ma dzieci. Bez JavaScriptu. Bez ResizeObservera. Bez przełączania klas.

Wykrywanie sidebara#

Jeden z moich ulubionych wzorców — zmiana layoutu w zależności od tego, czy sidebar istnieje:

css
.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;
}

Dodaj komponent sidebara do DOM, layout się dostosuje. Usuń go, dostosuje się z powrotem. CSS jest źródłem prawdy, nie zmienna stanu.

Łączenie :has() z innymi selektorami#

:has() komponuje się pięknie ze wszystkim innym:

css
/* 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);
}

Wsparcie przeglądarek jest teraz doskonałe. Każda nowoczesna przeglądarka obsługuje :has() od początku 2024 roku. Nie ma powodu, żeby tego nie używać.

CSS Nesting: Natywne, wreszcie stabilne#

Nie zamierzam przesadzać z tym punktem. CSS nesting jest fajny. Nie jest rewolucyjny jak container queries czy :has(). Ale usuwa jeden z ostatnich powodów, żeby używać Sassa, a to ma znaczenie.

Składnia#

css
.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);
  }
}

To jest poprawny CSS. Bez kroku budowania. Bez preprocesora. Bez pluginu PostCSS. Przeglądarka obsługuje to natywnie.

Różnice względem Sassa#

Jest kilka różnic w składni, o których warto wiedzieć:

css
/* 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;
  }
}

We wczesnych implementacjach trzeba było używać & przed selektorami elementów (np. & p zamiast samego p). To ograniczenie zostało zniesione. Od 2025 roku wszystkie główne przeglądarki obsługują zagnieżdżanie prostych elementów: .parent { p { ... } } działa bez problemu.

Zagnieżdżone media queries#

To jest killer feature CSS nestingu, moim zdaniem. Nie zagnieżdżanie selektorów — możliwość umieszczania media queries wewnątrz bloku reguły:

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;
  }
}

Porównaj to ze starym sposobem, gdzie style .hero były rozsiane po trzech różnych blokach @media, być może setki linii od siebie. Nesting utrzymuje responsywne zachowanie razem z komponentem. Czytelność poprawia się dramatycznie.

Nie zagnieżdżaj za głęboko#

Jedno ostrzeżenie: to, że możesz zagnieżdżać sześć poziomów w głąb, nie znaczy, że powinieneś. Ta sama rada z Sassa obowiązuje tutaj. Jeśli twoje zagnieżdżenie tworzy selektory typu .page .section .card .content .text .highlight, stworzyłeś potwora specyficzności i koszmar konserwacji. Dwa lub trzy poziomy to optimum.

css
/* 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 */
            }
          }
        }
      }
    }
  }
}

Funkcje kolorów: oklch() i color-mix() zmieniają wszystko#

hsl() miał dobrą passę. Był bardziej intuicyjny niż rgb(). Ale ma fundamentalną wadę: nie jest percepcyjnie jednorodny. hsl(60, 100%, 50%) (żółty) wygląda dramatycznie jaśniej dla ludzkiego oka niż hsl(240, 100%, 50%) (niebieski), mimo że mają tę samą wartość jasności.

Dlaczego oklch() wygrywa#

oklch() jest percepcyjnie jednorodny. Równe wartości jasności wyglądają równie jasno. To ma ogromne znaczenie przy generowaniu palet kolorów, tworzeniu motywów i zapewnianiu dostępnego kontrastu:

css
: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 */
}

Trzy wartości są intuicyjne, gdy je zrozumiesz:

  • Lightness (0% do 100%): Jak jasny lub ciemny. 0% to czarny, 100% to biały.
  • Chroma (0 do ~0.37): Jak żywy. 0 to szary, wyższe to bardziej nasycone.
  • Hue (0 do 360): Kąt koloru. 0/360 to różowawy czerwony, 145 to zielony, 260 to niebieski.

color-mix() dla kolorów pochodnych#

color-mix() pozwala tworzyć kolory z innych kolorów w runtime. Żadnej funkcji darken() z Sassa. Żadnego JavaScriptu. Tylko CSS:

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);
  }
}

Część in oklch ma znaczenie. Mieszanie w srgb daje błotniste pośrednie kolory. Mieszanie w oklch daje percepcyjnie równomierne wyniki. Zawsze mieszaj w oklch.

Budowanie kompletnej palety z oklch()#

Oto jak generuję całą paletę odcieni z jednego hue:

css
: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));
}

Zmień --hue na 145 i masz zieloną paletę. Zmień na 25 i masz czerwoną. Kroki jasności są percepcyjnie równomierne. Chroma maleje na skrajnościach, więc najjaśniejsze i najciemniejsze odcienie nie są przesycone. To jest rodzaj rzeczy, która kiedyś wymagała narzędzia projektowego albo funkcji Sass. Teraz to osiem linii CSS.

Animacje sterowane scrollem: Bez JavaScriptu#

To jest funkcja, dzięki której skasowałem najwięcej JavaScriptu. Animacje powiązane ze scrollem — paski postępu, efekty paralaksy, animacje pojawiania, lepkie nagłówki z przejściami — kiedyś wymagały IntersectionObserver, listenerów zdarzenia scroll albo biblioteki jak GSAP. Teraz to CSS.

Wskaźnik postępu czytania#

Klasyczny "pasek postępu czytania" na górze strony artykułu:

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);
  }
}

To wszystko. Cały wskaźnik postępu czytania. Bez JavaScriptu. Bez listenera zdarzenia scroll. Bez requestAnimationFrame. Bez obliczania "procent przewinięcia". Powiązanie animation-timeline: scroll() robi wszystko.

Pojawianie przy scrollu#

Elementy, które pojawiają się płynnie, kiedy wchodzą w viewport:

css
.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() wiąże animację z widocznością elementu w viewporcie. animation-range: entry 0% entry 100% oznacza, że animacja biegnie od momentu, gdy element zaczyna wchodzić w viewport, do chwili, gdy jest w pełni widoczny.

Paralaksa bez biblioteki#

css
.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;
}

Obraz tła porusza się z inną prędkością niż scroll, tworząc efekt paralaksy. Płynnie, wydajnie (przeglądarka może kompytować na GPU) i zero JavaScriptu.

Nazwane osie czasu scrollowania#

Dla większej kontroli możesz nazwać osie czasu scrollowania i odwoływać się do nich z innych elementów:

css
.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%; }
}

To działa nawet wtedy, gdy wskaźnik i scroller nie są w relacji rodzic/dziecko. Każdy element może linkować do dowolnej nazwanej osi czasu scrollowania. To jest potężne w dashboardach, gdzie przewijany panel steruje wskaźnikiem w stałym nagłówku.

Anchor Positioning: Tooltipy i popovery wreszcie dobrze#

Przed anchor positioningiem połączenie tooltipa z jego elementem wyzwalającym wymagało JavaScriptu. Obliczałeś pozycje za pomocą getBoundingClientRect(), obsługiwałeś offsety scrollu, zarządzałeś kolizjami z viewportem i przeliczałeś przy resize. Biblioteki jak Popper.js (teraz Floating UI) istniały specjalnie dlatego, że to było tak trudne do zrobienia poprawnie.

CSS anchor positioning czyni to deklaratywnym:

css
.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;
}

Automatyczna obsługa kolizji z viewportem#

Właściwość position-try-fallbacks to ta część, która wymagałaby 200 linii JavaScriptu. Mówi przeglądarce: "jeśli tooltip wychodzi poza viewport na dole, odwróć go na górę. Jeśli wychodzi po prawej, odwróć w lewo."

css
.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;
}

Nazwane fallbacki pozycji#

Dla większej kontroli nad pozycjami fallback możesz zdefiniować własne opcje:

css
@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;
}

Przeglądarka próbuje każdego fallbacku po kolei, aż znajdzie taki, który utrzymuje element w obrębie viewportu. To jest rodzaj rozumowania przestrzennego, który był naprawdę bolesny w JavaScript.

Kotwiczenie z Popover API#

Anchor positioning idealnie łączy się z nowym Popover API:

html
<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>

Żadnego JavaScriptu do pokazywania/ukrywania (Popover API to obsługuje). Żadnego JavaScriptu do pozycjonowania (anchor positioning to obsługuje). Żadnego JavaScriptu do light-dismiss (Popover API to też obsługuje). Cały wzorzec tooltip/popover/dropdown — wzorzec, który napędzał całe pakiety npm — to teraz HTML i CSS.

CSS Grid Subgrid: Zagnieżdżone wyrównanie, które wreszcie działa#

Grid jest potężny, ale miał jedno frustrujące ograniczenie: dziecko grida nie mogło wyrównywać swoich elementów do grida rodzica. Jeśli miałeś rząd kart i chciałeś, żeby tytuł, treść i stopka każdej karty wyrównywały się między kartami, to nie było jak. Wewnętrzny grid każdej karty był niezależny.

Subgrid to naprawia.

Problem wyrównania kart#

css
/* 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);
}

Teraz tytuły wszystkich trzech kart wyrównują się w tym samym rzędzie. Treści się wyrównują. Stopki się wyrównują. Nawet gdy jedna karta ma dwuliniowy tytuł, a inna jednoliniowy, wyrównanie jest utrzymywane przez grid rodzica.

Przed subgridem: hacki#

Bez subgrida osiągnięcie tego wyrównania wymagało albo:

  1. Stałych wysokości (kruche, psuje się z dynamiczną treścią)
  2. Mierzenia JavaScriptem (wolne, migotanie braku wyrównania)
  3. Poddania się i zaakceptowania braku wyrównania (częste, brzydkie)
css
/* 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 sprawia, że hack jest zbędny. Grid rodzica dystrybuuje wysokości rzędów na podstawie najwyższej treści w każdym rzędzie we wszystkich kartach.

Subgrid kolumnowy dla layoutów formularzy#

Subgrid działa też na kolumnach, co jest idealne dla layoutów formularzy:

css
.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;
}

Wszystkie etykiety się wyrównują. Wszystkie inputy się wyrównują. Kolumna etykiet automatycznie dostosowuje się do najszerszej etykiety. Bez zahardkodowanych szerokości.

View Transitions API: Nawigacja jak w SPA bez frameworka#

Ten punkt to najbardziej ambitna funkcja na tej liście. View Transitions API pozwala animować przejścia między stronami — w tym nawigacje cross-document (zwykłe kliknięcia linków w wielostronicowej aplikacji). Twoja statyczna strona HTML może teraz mieć płynne, animowane przejścia między stronami.

Przejścia cross-document#

Żeby włączyć przejścia cross-document, dodajesz jedną regułę CSS na starej i nowej stronie:

css
@view-transition {
  navigation: auto;
}

To wszystko. Przeglądarka będzie teraz płynnie przełączać między stronami podczas nawigacji. Domyślne przejście to gładki fade przezroczystości. Bez JavaScriptu. Bez frameworka. Tylko dwie linie CSS na każdej stronie.

Personalizacja przejścia#

Możesz dostosować, jakie przejścia się dzieją, nadając nazwy konkretnym elementom:

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;
  }
}

Kiedy nawigujesz między dwiema stronami, które obie mają .hero-image z view-transition-name: hero, przeglądarka automatycznie animuje obraz z jego pozycji na starej stronie do pozycji na nowej stronie. To wzorzec "shared element transition" ze świata mobile, teraz w przeglądarce.

Przejścia w SPA#

Dla aplikacji single-page (React, Vue, Svelte itp.) JavaScript API jest prosty:

css
/* 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;
}
javascript
// JS side — wrap your DOM update in startViewTransition
document.startViewTransition(() => {
  // Update the DOM here — React render, innerHTML swap, etc.
  updateContent(newPageData);
});

Przeglądarka robi snapshot starego stanu, wykonuje twoją aktualizację, robi snapshot nowego stanu i animuje między nimi. Nazwane elementy dostają indywidualne przejścia; reszta dostaje domyślny crossfade.

Szanowanie preferencji użytkownika#

Zawsze szanuj prefers-reduced-motion:

css
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

Z czego możesz zrezygnować#

Te funkcje łącznie eliminują sporo narzędzi, bibliotek i wzorców, na których polegaliśmy latami. Oto z czego sam zrezygnowałem lub po co przestałem sięgać:

Sass/SCSS do zagnieżdżania i zmiennych#

CSS ma natywne zagnieżdżanie. CSS ma custom properties (i ma je od lat). Dwa główne powody, dla których ludzie sięgali po Sassa, są teraz w języku. Jeśli nadal używasz Sassa tylko do $variables i nestingu, możesz przestać.

css
/* 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);
  }
}

Wersja CSS jest potężniejsza — oklch daje percepcyjnie jednorodną manipulację kolorami, color-mix działa w runtime (Sassowy lighten to tylko compile-time), a custom properties mogą być zmieniane dynamicznie z JavaScriptu lub media queries.

JavaScript do animacji scrollowych#

Skasuj swój kod IntersectionObserver do pojawiania przy scrollu. Skasuj JavaScript paska postępu. Skasuj handler scrollowania paralaksy. animation-timeline: scroll() i animation-timeline: view() obsługują to wszystko z lepszą wydajnością (wątek kompozytora, nie główny wątek).

javascript
// 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));
css
/* 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 do pozycjonowania#

Jeśli używasz Floating UI (dawniej Popper.js) tylko do podstawowego pozycjonowania tooltipów/popoverów, CSS anchor positioning to zastępuje. Tracisz niektóre zaawansowane funkcje (elementy wirtualne, customowe middleware), ale w 90% przypadków — "umieść ten popover przy tym przycisku i utrzymaj go w viewporcie" — CSS robi to natywnie.

Hacki centrowania z margin: auto#

To nie jest nowe, ale wciąż widzę margin: 0 auto wszędzie. Nowoczesne narzędzia layoutu czynią to zbędnym w większości przypadków:

css
/* 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 nadal działa. Ale jeśli przyłapiesz się na używaniu go do centrowania w pionie lub złożonego wyrównania, sięgnij po flexbox lub grid.

Media queries do responsywności na poziomie komponentu#

To ta duża zmiana. Jeśli piszesz @media queries, żeby komponent był responsywny, prawdopodobnie teraz robisz to źle. Container queries powinny być twoim domyślnym wyborem do responsywności na poziomie komponentu. Zarezerwuj @media queries do decyzji na poziomie strony: zmiany layoutu, wzorce nawigacji, style druku.

css
/* 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;
  }
}

Szerszy obraz#

Te funkcje nie istnieją w izolacji. Komponują się. Container queries + :has() + nesting + oklch() + layers — użyte razem — dają ci doświadczenie pisania CSS, które byłoby nie do poznania pięć lat temu:

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;
      }
    }
  }
}

Ten pojedynczy blok obsługuje:

  • Responsywny layout na poziomie komponentu (container queries)
  • Warunkowe stylowanie na podstawie treści (:has())
  • Czystą organizację selektorów (nesting)
  • Percepcyjnie jednorodne kolory (oklch)
  • Przewidywalne zarządzanie specyficznością (@layer)

Bez preprocesora. Bez runtime'u CSS-in-JS. Bez klas utility do tego. Po prostu CSS, robiący to, co CSS powinien robić od zawsze.

Platforma dojrzała. Wreszcie. I było warto czekać.

Powiązane wpisy