Container Queries: een component dat zijn eigen container kent, niet de viewport
Container Queries: een component dat zijn eigen container kent, niet de viewport
Je hebt een productkaart. Een afbeelding boven de titel, een prijs, een knop "In winkelwagen". In de hoofdgrid van de winkel, bij een schermbreedte boven 768px, schakel je haar via een media query naar een horizontale layout – afbeelding links, inhoud rechts, omdat dat de beschikbare breedte beter benut. Werkt prima. Dan wil de productafdeling dezelfde kaart in een smalle sidebar "aanbevolen producten", naast de hoofdinhoud van een artikel. Je plaatst het component zonder aanpassingen – en het breekt. De horizontale layout, ontworpen met 700+ pixels in gedachten, probeert zich te persen in een kolom van 260 pixels. De afbeelding wordt samengeknepen tot een streepje, de tekst breekt af in losse woorden per regel.
Je hebt geen fout gemaakt in je CSS. Je media query doet precies wat hij hoort te doen – hij beantwoordt alleen de verkeerde vraag. @media (min-width: 768px) vraagt: "hoe breed is de viewport?". Om er goed uit te zien, zou een productkaart nooit moeten geven om de breedte van de viewport – ze zou moeten geven om hoeveel ruimte ze van haar ouder heeft gekregen. Dat zijn twee verschillende vragen die CSS twee decennia lang als één behandelde, omdat er geen manier was om die tweede te stellen.
Waarom een media query niet volstaat
Een media query is een globale vraag. Het maakt niet uit of je component in een kolom van 1200 pixels zit of in een smalle zijkaart van 260 – @media (min-width: 768px) geeft in beide gevallen dezelfde true terug, omdat hij vraagt naar het browservenster, niet naar de ouder van het element. Een component die uitsluitend op media queries steunt, is dus alleen correct binnen de context waarvoor hij oorspronkelijk is ontworpen – en houdt op correct te zijn zodra diezelfde code ergens anders terechtkomt. Dat ondermijnt fundamenteel de belofte die componenten horen te doen: dat je ze veilig opnieuw kunt gebruiken, waar dan ook in de layout.
Vóór 2023 was de enige echte workaround een ResizeObserver in JavaScript – je observeert een element, meet zijn breedte bij elke wijziging, zet het resultaat in state en voegt voorwaardelijk een CSS-klasse toe. Het werkt, maar het kost iets: extra component-state, een re-render bij elke formaatverandering, code die je voor elk component apart handmatig moet schrijven, en die niet bestaat zolang JavaScript nog niet is uitgevoerd – dus bij server-side rendering toont de eerste frame sowieso een layout "in het wilde weg", voordat de ResizeObserver iets heeft kunnen meten.
jsx
// Workaround van vóór Container Queries – werkt, maar kost watimport { 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 lossen hetzelfde probleem op zonder ook maar één regel JavaScript, zonder state en zonder re-render – omdat de vraag "hoeveel ruimte heb ik tot mijn beschikking" teruggaat naar waar CSS die altijd al stelde: het stylesheet.
Hoe het echt werkt onder de motorkap: containment, niet alleen nieuwe syntax
De syntax @container zelf ziet eruit als @media met een andere naam – maar het verschil zit ergens anders, in wat er moet gebeuren voordat de browser er überhaupt mee instemt om zo'n vraag te beantwoorden. Om een element een "query-container" te kunnen laten zijn, moet je dat expliciet declareren:
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 is niet zomaar een schakelaar voor query-modus – het is een declaratie van CSS Containment, een aparte specificatie die de browser vertelt: "de inhoud van dit element beïnvloedt zijn eigen formaat in de inline-as niet, dus je mag het bij het layouten veilig behandelen als een onafhankelijk eiland". Dat is geen onbelangrijk implementatiedetail – het is een noodzakelijke voorwaarde om container queries überhaupt te laten bestaan zonder in een lus terecht te komen. Als de browser een element tegelijk zou laten reageren op zijn eigen breedte en die breedte via zijn inhoud zou laten beïnvloeden, ontstaat een cirkelvormige afhankelijkheid: inhoud verandert het formaat van de container → formaatverandering schakelt @container om → nieuwe regels veranderen de inhoud → inhoud verandert opnieuw het formaat van de container. Containment doorbreekt die lus bij de bron, door af te dwingen dat het formaat van de container in die as onafhankelijk wordt bepaald van wat erin zit.
Dat heeft een concreet, praktisch gevolg bij het kiezen van een waarde voor container-type:
inline-size– containment alleen in de inline-as (meestal horizontaal). De hoogte van het element volgt nog steeds vrij uit de inhoud. Dit is de waarde waar je in 95% van de gevallen naar grijpt – precies zoals in het voorbeeld met de productkaart.size– containment in beide assen tegelijk. Het element steunt bij het bepalen van zijn formaat helemaal niet meer op zijn inhoud – wat betekent dat je verplicht een expliciete hoogte moet meegeven (bijvoorbeeld viaheightofaspect-ratio), anders snijdt containment het los van zijn natuurlijke inhoudshoogte en zakt de hoogte effectief naar nul.normal– de standaardwaarde, geen containment, het element is geen query-container.
container-name is optioneel, maar de moeite van de gewoonte waard – zonder naam bevraagt @container (min-width: 400px) de dichtstbijzijnde voorouder die een container is, wat dat ook mag zijn. In een plat component is dat onschuldig, maar in een geneste layout (een kaart binnen een paneel, een paneel binnen een kolom – waarbij zowel het paneel als de kolom containers zijn) vertelt een genoemde container ondubbelzinnig welke voorouder je precies bevraagt, in plaats van te vertrouwen op toevallige nabijheid in de DOM-boom.
Praktisch voorbeeld: één kaart, twee contexten, geen JavaScript
Terug naar de productkaart uit de inleiding – deze keer gebouwd zodat hetzelfde component correct rendert zowel in een brede grid als in een smalle sidebar, zonder enige kennis van waar het toevallig is geplaatst.
jsx
const ProductCard = ({ product }) => (<div className="product-card-slot"><article className="product-card"><imgsrc={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">In winkelwagen</button></div></article></div>);export default ProductCard;
css
/* De ouder van het component declareert zichzelf als query-container */.product-card-slot {container-type: inline-size;container-name: product-card;}/* Standaardlayout: afbeelding boven de inhoud – veilig in krappe ruimte */.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;}/* Boven 360px breedte van de CONTAINER, niet de viewport – schakel naar horizontaal */@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%;}}
De cruciale beslissing zit hier verstopt in de structuur, niet in de CSS: container-type staat op .product-card-slot, dus op de ouder van de kaart, en niet op .product-card zelf. Dat is geen toeval of overdreven voorzichtigheid – de specificatie sluit expliciet uit dat een element zijn eigen formaat via @container bevraagt (weer datzelfde probleem van cirkelvormige afhankelijkheid: een element kan niet tegelijk de container definiëren en gestyled worden op basis van zijn eigen formaat). Daarom is het typische, herhaalbare patroon een dunne wrapper-container aan de buitenkant en het eigenlijke component erbinnen, gestyled binnen een @container-blok. Datzelfde CSS-bestand, één keer geplaatst in de productgrid (waar de slot 480px is) en één keer in de sidebar (waar de slot 240px is), levert twee verschillende, correcte layouts op – zonder prop, zonder modifier-klasse, zonder één regel JavaScript.
Container-eenheden: vloeiendheid zonder viewport
Container Queries brachten een tweede, minder bekend stukje van dezelfde specificatie mee: eenheden die relatief zijn aan de container in plaats van de viewport – cqw (1% van de containerbreedte), cqh (1% van de hoogte), cqi en cqb (1% in de inline/block-as, dus ook correct bij verticaal geschreven talen), en cqmin/cqmax, analoog aan vmin/vmax, maar berekend op basis van de kleinste of grootste afmeting van de container.
Hun natuurlijke toepassing is vloeiende typografie binnen een component, onafhankelijk van hoe breed het scherm van de gebruiker toevallig is:
css
.product-card__title {/* Schaalt mee met de breedte van de kaart, niet met de breedte van het scherm */font-size: clamp(1rem, 0.85rem + 2cqi, 1.375rem);}
Het verschil met de bekende clamp()-truc met de vw-eenheid is subtiel, maar in de praktijk fundamenteel: een titel die schaalt met vw verandert van formaat samen met de hele pagina, dus twee kopieën van dezelfde kaart – de ene in een grid op volle breedte, de andere in een smalle sidebar – krijgen identiek dezelfde tekstgrootte, omdat beide dezelfde viewport zien. Een titel die schaalt met cqi reageert op de werkelijke ruimte die aan die specifieke kaart is toegekend – in de sidebar wordt hij kleiner, in de grid groter, terwijl de viewport de hele tijd exact dezelfde breedte houdt.
Valkuilen en goede praktijken
container-type: sizezonder expliciete hoogte vreet de inhoud op. Containment in de block-as snijdt een element los van de natuurlijke hoogte van zijn kinderen – als je geenheightofaspect-ratioopgeeft, krimpt de container effectief naar nul hoogte en verdwijnen zijn kinderen visueel, ook al staan ze nog gewoon in de DOM. In verreweg de meeste gevallen volstaatinline-sizeen speelt dit probleem niet.- Een container kan zichzelf niet bevragen. Als je worstelt met een
@containerdie "niet werkt", is dit het eerste vermoeden:container-typeen de selector die door@containerwordt gestyled staan op hetzelfde element. Scheid ze –container-typeop de wrapper, de doelstijlen op het kind, precies zoals in het voorbeeld met de productkaart hierboven. - Container queries vervangen media queries niet – ze vullen ze aan. Een media query blijft het juiste instrument voor beslissingen op paginaniveau (bijvoorbeeld of je een sidebar überhaupt toont, of navigatie omschakelt naar een hamburgermenu). Een container query is verantwoordelijk voor beslissingen op componentniveau, waar dat component ook belandt. Een goede layout in 2026 gebruikt doorgaans beide tegelijk, elk precies waar de vraag die het stelt ook past.
- Maak niet uit voorzorg van elke div een container. Containment heeft een reële kost voor de renderingengine – het is een signaal van "behandel mij als een geïsoleerde layout-eenheid", nuttig waar je daadwerkelijk een component hebt dat op zijn eigen formaat reageert, niet als standaardinstelling voor de hele DOM-boom.
- Browserondersteuning is geen probleem meer.
container-typeen@containerbereikten begin 2023 Baseline-status (breed beschikbaar) – ze werken native in Chrome, Safari en Firefox, zonder polyfills en zonder@supports. De eenhedencqw/cqien de rest van de familie hebben precies dezelfde dekking.
Samenvatting
Een herbruikbaar component is niet een component met genoeg props en modifier-klassen om elke plek waar het kan belanden handmatig te ondersteunen – het is een component dat zelf weet hoeveel ruimte het heeft, en daarop reageert zonder hulp van wie dan ook. Media queries waren twee decennia lang het enige CSS-instrument voor responsiviteit, dus gebruikten we ze vanzelfsprekend ook te veel op plekken waar het eigenlijk om de ouder ging, niet om de viewport. Container Queries zijn geen zoveelste syntactische variant van hetzelfde mechanisme – het is een andere vraag, gesteld op de juiste plek in de DOM-boom, ondersteund door containment dat garandeert dat het antwoord nooit in een lus terechtkomt. De code uit dit artikel – één component, één CSS-bestand, geen JavaScript – werkt correct in de grid, in de sidebar en op elke andere plek waarvan je nog niet weet dat je het daar ooit zult plaatsen.