css i układy

Container Queries: komponent, który zna swój kontener, nie viewport

Container Queries: komponent, który zna swój kontener, nie viewport

Masz kartę produktu. Obraz nad tytułem, cena, przycisk „Dodaj do koszyka”. W głównym gridzie sklepu, przy szerokości ekranu powyżej 768px, przełączasz ją media query na układ poziomy – obraz z lewej, treść z prawej, bo tak lepiej wykorzystuje dostępną szerokość. Działa świetnie. Potem produktowy widzi tę samą kartę i chce ją w wąskim sidebarze „polecane produkty”, obok głównej treści artykułu. Wrzucasz komponent bez zmian – i on się łamie. Poziomy układ, zaprojektowany z myślą o 700+ pikselach, próbuje się zmieścić w 260-pikselowej kolumnie. Obraz ściśnięty do paska, tekst zawija się w pojedyncze słowa na linię.

Nie popełniłeś błędu w CSS. Twoja media query działa dokładnie tak, jak powinna – tyle że odpowiada na złe pytanie. @media (min-width: 768px) pyta: „jak szeroki jest viewport?”. Karcie produktu, żeby dobrze wyglądać, nigdy nie powinno zależeć na szerokości viewportu – powinno jej zależeć na tym, ile miejsca dostała od rodzica. To dwa różne pytania, które przez dwie dekady CSS traktował jak jedno, bo nie miał sposobu, żeby zapytać o to drugie.

Dlaczego media query nie wystarcza

Media query to zapytanie globalne. Nie ma znaczenia, czy Twój komponent siedzi w kolumnie na 1200 pikseli, czy w wąskiej karcie bocznej na 260 – @media (min-width: 768px) w obu przypadkach zwróci to samo true, bo pyta o okno przeglądarki, a nie o rodzica elementu. Komponent napisany w oparciu wyłącznie o media query jest więc poprawny tylko w kontekście, w którym go pierwotnie zaprojektowano – i przestaje być poprawny, gdy ten sam kod trafia gdziekolwiek indziej. To fundamentalnie psuje obietnicę, jaką mają dawać komponenty: że da się je bezpiecznie użyć ponownie, w dowolnym miejscu układu.

Przed 2023 rokiem jedynym realnym obejściem był ResizeObserver w JavaScripcie – obserwujesz element, mierzysz jego szerokość przy każdej zmianie, wrzucasz wynik do stanu, warunkowo dokładasz klasę CSS. Działa, ale kosztuje: dodatkowy stan komponentu, re-render przy każdej zmianie rozmiaru, kod, który trzeba pisać ręcznie dla każdego komponentu z osobna, i który nie istnieje, dopóki JavaScript się nie wykona – więc przy renderowaniu po stronie serwera pierwsza klatka i tak pokaże układ „na ślepo”, zanim ResizeObserver zdąży cokolwiek zmierzyć.

jsx

// Obejście sprzed Container Queries – działa, ale kosztuje
import { useEffect, useRef, useState } from "react";
const ProductCard = ({ product }) => {
const cardRef = useRef(null);
const [isWide, setIsWide] = useState(false);
useEffect(() => {
const el = cardRef.current;
const observer = new ResizeObserver((entries) => {
setIsWide(entries[0].contentRect.width > 320);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<article ref={cardRef} className={`card ${isWide ? "card--wide" : ""}`}>
{/* ... */}
</article>
);
};

Container Queries rozwiązują ten sam problem bez ani jednej linijki JavaScriptu, bez stanu i bez re-renderu – bo pytanie „ile miejsca mam do dyspozycji” trafia z powrotem tam, gdzie CSS zawsze je zadawał: do arkusza stylów.

Jak to naprawdę działa pod spodem: containment, nie tylko nowa składnia

Sama składnia @container wygląda jak @media z inną nazwą – ale różnica leży gdzie indziej, w tym, co musi się wydarzyć, zanim przeglądarka w ogóle zgodzi się odpowiadać na takie zapytanie. Żeby element mógł być „kontenerem zapytania”, musisz mu to jawnie zadeklarować:

css

.card-slot {
container-type: inline-size;
container-name: card;
}
@container card (min-width: 400px) {
.card {
grid-template-columns: 40% 1fr;
}
}

container-type: inline-size to nie tylko włącznik trybu zapytań – to deklaracja CSS Containment, osobnej specyfikacji, która mówi przeglądarce: „zawartość tego elementu nie wpływa na jego rozmiar w osi inline, więc możesz bezpiecznie potraktować go jako niezależną wyspę przy layoutowaniu”. To nie jest szczegół implementacyjny bez znaczenia – to warunek konieczny, żeby zapytania kontenerowe w ogóle mogły istnieć bez zapętlenia się. Gdyby przeglądarka pozwoliła elementowi jednocześnie zmieniać rozmiar w reakcji na własną szerokość i wpływać na tę szerokość przez swoją zawartość, powstałaby zależność cykliczna: zawartość zmienia rozmiar kontenera → zmiana rozmiaru przełącza @container → nowe reguły zmieniają zawartość → zawartość znowu zmienia rozmiar kontenera. Containment przecina tę pętlę u źródła, wymuszając, że rozmiar kontenera w danej osi jest ustalany niezależnie od tego, co jest w środku.

To ma konkretną, praktyczną konsekwencję przy wyborze wartości container-type:

  • inline-size – containment tylko w osi inline (zwykle poziomej). Wysokość elementu nadal swobodnie wynika z zawartości. To wartość, po którą sięgasz w 95% przypadków – dokładnie tak, jak w przykładzie z kartą produktu.
  • size – containment w obu osiach naraz. Element całkowicie przestaje polegać na swojej zawartości przy ustalaniu rozmiaru – co oznacza, że musisz nadać mu jawną wysokość (np. przez height albo aspect-ratio), bo inaczej containment odetnie go od naturalnej wysokości zawartości i wysokość efektywnie spadnie do zera.
  • normal – wartość domyślna, brak containment, element nie jest kontenerem zapytania.

container-name jest opcjonalny, ale wart nawyku – bez niego @container (min-width: 400px) odpytuje najbliższego przodka będącego kontenerem, cokolwiek by nim nie było. W płaskim komponencie to nieszkodliwe, ale w zagnieżdżonym układzie (karta wewnątrz panelu, panel wewnątrz kolumny – i panel, i kolumna są kontenerami) nazwany kontener jednoznacznie mówi, o którego przodka konkretnie pytasz, zamiast polegać na przypadkowej bliskości w drzewie DOM.

Praktyczny przykład: jedna karta, dwa konteksty, zero JavaScriptu

Wracamy do karty produktu z wprowadzenia – tym razem zbudowanej tak, żeby ten sam komponent poprawnie renderował się zarówno w szerokim gridzie, jak i w wąskim sidebarze, bez żadnej wiedzy o tym, gdzie akurat został osadzony.

jsx

const ProductCard = ({ product }) => (
<div className="product-card-slot">
<article className="product-card">
<img
src={product.image}
alt={product.name}
className="product-card__image"
/>
<div className="product-card__body">
<h3 className="product-card__title">{product.name}</h3>
<p className="product-card__price">{product.price}</p>
<button type="button" className="product-card__cta">
Dodaj do koszyka
</button>
</div>
</article>
</div>
);
export default ProductCard;

css

/* Rodzic komponentu deklaruje się jako kontener zapytania */
.product-card-slot {
container-type: inline-size;
container-name: product-card;
}
/* Domyślny układ: obraz nad treścią – bezpieczny w wąskiej przestrzeni */
.product-card {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.product-card__image {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: 8px;
}
/* Powyżej 360px szerokości KONTENERA, nie viewportu – przełącz na układ poziomy */
@container product-card (min-width: 360px) {
.product-card {
grid-template-columns: 40% 1fr;
align-items: center;
}
.product-card__image {
aspect-ratio: 1 / 1;
height: 100%;
}
}

Kluczowa decyzja jest tu ukryta w strukturze, nie w CSS: container-type siedzi na .product-card-slot, czyli rodzicu karty, a nie na samej .product-card. To nie przypadek ani styl na wyrost – specyfikacja wprost wyklucza możliwość, żeby element odpytywał własny rozmiar przez @container (znów ten sam problem cyklicznej zależności: element nie może jednocześnie definiować kontenera i być stylowany w reakcji na jego rozmiar). Dlatego typowy, powtarzalny wzorzec to cienki wrapper-kontener na zewnątrz i właściwy komponent w środku, stylowany wewnątrz bloku @container. Ten sam plik CSS, wrzucony raz do gridu produktów (gdzie slot ma 480px) i raz do sidebaru (gdzie slot ma 240px), da w efekcie dwa różne, poprawne układy – bez propsa, bez klasy modyfikatora, bez jednej linijki JavaScriptu.

Jednostki kontenerowe: płynność bez viewportu

Container Queries przyniosły ze sobą drugi, mniej znany kawałek tej samej specyfikacji: jednostki względne względem kontenera, nie viewportu – cqw (1% szerokości kontenera), cqh (1% wysokości), cqi i cqb (1% w osi inline/block, czyli poprawne również w językach pisanych pionowo), oraz cqmin/cqmax, analogiczne do vmin/vmax, ale liczone od mniejszego lub większego wymiaru kontenera.

Ich naturalne zastosowanie to płynna typografia wewnątrz komponentu, niezależna od tego, jak szeroki jest akurat ekran użytkownika:

css

.product-card__title {
/* Skaluje się z szerokością karty, nie z szerokością ekranu */
font-size: clamp(1rem, 0.85rem + 2cqi, 1.375rem);
}

Różnica względem znanego już triku clamp() z jednostką vw jest subtelna, ale w praktyce fundamentalna: tytuł skalowany vw-ami zmienia rozmiar razem z całą stroną, więc dwie kopie tej samej karty – jedna w gridzie na pełną szerokość, druga w wąskim sidebarze – dostaną identyczny rozmiar czcionki, bo obie widzą ten sam viewport. Tytuł skalowany cqi reaguje na rzeczywistą przestrzeń przyznaną konkretnej karcie – w sidebarze będzie mniejszy, w gridzie większy, mimo że viewport przez cały czas ma dokładnie tę samą szerokość.

Pułapki i dobre praktyki

  • container-type: size bez jawnej wysokości zjada zawartość. Containment w osi block odcina element od naturalnej wysokości potomków – jeśli nie podasz height ani aspect-ratio, kontener efektywnie skurczy się do zera wysokości, a jego dzieci wizualnie znikną, mimo że w DOM wciąż tam są. W zdecydowanej większości przypadków inline-size wystarcza i tego problemu nie ma.
  • Kontener nie może odpytywać samego siebie. Jeśli walczysz z tym, że @container „nie działa”, pierwsze podejrzenie to właśnie to: container-type i stylowany przez @container selektor siedzą na tym samym elemencie. Rozdziel je – container-type na wrapperze, docelowe style na dziecku, dokładnie jak w przykładzie karty produktu wyżej.
  • Container queries nie zastępują media queries – uzupełniają je. Media query wciąż jest właściwym narzędziem do decyzji na poziomie strony (np. czy w ogóle pokazać sidebar, czy przełączyć nawigację na hamburger). Container query odpowiada za decyzje na poziomie komponentu, gdziekolwiek ten komponent wyląduje. Dobry layout w 2026 roku zwykle korzysta z obu naraz, każdego tam, gdzie faktycznie pasuje pytanie, jakie zadaje.
  • Nie rób z każdego diva kontenera „na zapas”. Containment ma realny koszt dla silnika renderującego – to sygnał „traktuj mnie jako izolowaną jednostkę layoutu”, użyteczny tam, gdzie faktycznie masz komponent reagujący na swój rozmiar, a nie domyślne ustawienie dla całego drzewa DOM.
  • Wsparcie przeglądarek nie jest już problemem. container-type i @container osiągnęły status Baseline (szeroko dostępne) na początku 2023 roku – działają natywnie w Chrome, Safari i Firefoksie bez polyfilli i bez @supports. Jednostki cqw/cqi i reszta rodziny mają dokładnie to samo pokrycie.

Podsumowanie

Reużywalny komponent to nie taki, który ma wystarczająco dużo propsów i klas modyfikujących, żeby ręcznie obsłużyć każde miejsce, w którym może wylądować – to taki, który sam wie, ile ma miejsca, i reaguje na to bez niczyjej pomocy. Media query przez dwie dekady było jedynym narzędziem CSS do responsywności, więc naturalnie nadużywaliśmy go też tam, gdzie tak naprawdę chodziło o rodzica, nie o viewport. Container Queries nie są kolejnym syntaktycznym wariantem tego samego mechanizmu – to inne pytanie, zadane we właściwym miejscu drzewa DOM, poparte containment, który gwarantuje, że odpowiedź na nie nigdy się nie zapętli. Kod z tego artykułu – jeden komponent, jeden plik CSS, zero JavaScriptu – działa poprawnie w gridzie, w sidebarze i w każdym innym miejscu, o którym jeszcze nie wiesz, że go tam kiedyś wstawisz.