css ja asettelu

Container Queries: komponentti, joka tuntee säiliönsä, ei näyttöikkunaa

Container Queries: komponentti, joka tuntee säiliönsä, ei näyttöikkunaa

Sinulla on tuotekortti. Kuva otsikon yläpuolella, hinta, "Lisää ostoskoriin" -painike. Kaupan pääruudukossa, kun näytön leveys ylittää 768px, vaihdat sen media querylla vaakasuoraan asetteluun – kuva vasemmalla, sisältö oikealla, koska näin käytettävissä oleva tila hyödynnetään paremmin. Toimii hienosti. Sitten tuotepäällikkö näkee saman kortin ja haluaa sen kapeaan "suositellut tuotteet" -sivupalkkiin, artikkelin pääsisällön viereen. Pudotat komponentin paikalleen ilman muutoksia – ja se hajoaa. Vaakasuora asettelu, joka on suunniteltu yli 700 pikselille, yrittää mahtua 260 pikselin levyiseen sarakkeeseen. Kuva puristuu kapeaksi raidaksi, teksti rivittyy yksittäisiksi sanoiksi riviä kohti.

Et tehnyt virhettä CSS:ssä. Media querysi toimii juuri niin kuin pitääkin – se vain vastaa väärään kysymykseen. @media (min-width: 768px) kysyy: "kuinka leveä näyttöikkuna on?". Tuotekortin ei näyttääkseen hyvältä pitäisi koskaan olla riippuvainen näyttöikkunan leveydestä – sen pitäisi olla riippuvainen siitä, kuinka paljon tilaa se sai vanhemmaltaan. Nämä ovat kaksi eri kysymystä, joita CSS on kahden vuosikymmenen ajan kohdellut yhtenä, koska sillä ei ollut tapaa esittää tuota toista.

Miksi media query ei riitä

Media query on globaali kysely. Sillä ei ole väliä, istuuko komponenttisi 1200 pikselin levyisessä sarakkeessa vai 260 pikselin levyisessä sivupalkin kortissa – @media (min-width: 768px) palauttaa molemmissa tapauksissa saman true-arvon, koska se kysyy selainikkunan kokoa, ei elementin vanhempaa. Pelkän media queryn varaan rakennettu komponentti on siis oikein vain siinä kontekstissa, johon se alun perin suunniteltiin – ja lakkaa olemasta oikein heti, kun sama koodi päätyy mihin tahansa muualle. Tämä rikkoo perustavanlaatuisesti lupauksen, jonka komponenttien pitäisi antaa: että niitä voi turvallisesti käyttää uudelleen missä tahansa asettelun kohdassa.

Ennen vuotta 2023 ainoa toimiva kiertotie oli JavaScriptin ResizeObserver – tarkkailet elementtiä, mittaat sen leveyden joka muutoksella, viet tuloksen tilaan ja lisäät ehdollisesti CSS-luokan. Se toimii, mutta maksaa: ylimääräinen komponentin tila, uudelleenrenderöinti joka kokomuutoksella, koodia, joka pitää kirjoittaa käsin jokaiselle komponentille erikseen ja joka ei ole olemassa ennen kuin JavaScript on suoritettu – joten palvelinpuolen renderöinnissä ensimmäinen kehys näyttää asettelun joka tapauksessa "sokkona", ennen kuin ResizeObserver ehtii mitata mitään.

jsx

// Kiertotie ennen Container Queries -ominaisuutta – toimii, mutta maksaa
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 ratkaisee saman ongelman ilman ainuttakaan riviä JavaScriptia, ilman tilaa ja ilman uudelleenrenderöintiä – koska kysymys "kuinka paljon tilaa minulla on käytössä" palaa takaisin sinne, missä CSS on sen aina esittänyt: tyylitiedostoon.

Miten tämä oikeasti toimii konepellin alla: containment, ei vain uusi syntaksi

@container-syntaksi itsessään näyttää @media-säännöltä eri nimellä – mutta ero on muualla: siinä, mitä täytyy tapahtua ennen kuin selain ylipäätään suostuu vastaamaan tällaiseen kyselyyn. Jotta elementti voisi olla "kyselysäiliö", sinun täytyy ilmoittaa se sille eksplisiittisesti:

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 ei ole pelkkä kyselytilan kytkin – se on CSS Containment -määrittelyn ilmoitus, erillisen spesifikaation, joka kertoo selaimelle: "tämän elementin sisältö ei vaikuta sen kokoon inline-akselilla, joten voit turvallisesti kohdella sitä itsenäisenä saarekkeena asettelussa". Tämä ei ole merkityksetön toteutusyksityiskohta – se on välttämätön ehto sille, että säiliökyselyt voivat ylipäätään olla olemassa ilman silmukoitumista. Jos selain sallisi elementin samanaikaisesti muuttaa kokoaan reaktiona omaan leveyteensä ja vaikuttaa tuohon leveyteen sisältönsä kautta, syntyisi kehämäinen riippuvuus: sisältö muuttaa säiliön kokoa → koon muutos kytkee @container-säännön → uudet säännöt muuttavat sisältöä → sisältö muuttaa säiliön kokoa uudelleen. Containment katkaisee tämän silmukan juuresta pakottamalla, että säiliön koko kyseisellä akselilla määräytyy riippumatta siitä, mitä sisällä on.

Tällä on konkreettinen, käytännöllinen seuraus container-type-arvon valinnassa:

  • inline-size – containment vain inline-akselilla (yleensä vaakasuunnassa). Elementin korkeus määräytyy edelleen vapaasti sisällöstä. Tämä on arvo, johon tartut 95 %:ssa tapauksista – aivan kuten tuotekorttiesimerkissä.
  • size – containment molemmilla akseleilla samanaikaisesti. Elementti lakkaa täysin nojaamasta sisältöönsä kokoa määrittäessään – mikä tarkoittaa, että sille täytyy antaa eksplisiittinen korkeus (esim. height- tai aspect-ratio-ominaisuudella), koska muuten containment katkaisee sen sisällön luonnollisesta korkeudesta ja korkeus käytännössä romahtaa nollaan.
  • normal – oletusarvo, ei containmentia, elementti ei ole kyselysäiliö.

container-name on valinnainen, mutta kannattaa ottaa tavaksi – ilman sitä @container (min-width: 400px) kysyy lähintä säiliönä toimivaa esi-isää, mikä se sitten onkin. Yksinkertaisessa komponentissa tämä on harmitonta, mutta sisäkkäisessä rakenteessa (kortti paneelin sisällä, paneeli sarakkeen sisällä – ja sekä paneeli että sarake ovat säiliöitä) nimetty säiliö kertoo yksiselitteisesti, mistä esi-isästä tarkalleen on kyse, sen sijaan että luotettaisiin DOM-puun sattumanvaraiseen läheisyyteen.

Käytännön esimerkki: yksi kortti, kaksi kontekstia, ei yhtään JavaScriptia

Palataan johdannon tuotekorttiin – tällä kertaa rakennettuna niin, että sama komponentti renderöityy oikein sekä leveässä ruudukossa että kapeassa sivupalkissa, ilman minkäänlaista tietoa siitä, mihin se on kulloinkin upotettu.

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">
Lisää ostoskoriin
</button>
</div>
</article>
</div>
);
export default ProductCard;

css

/* Komponentin vanhempi ilmoittautuu kyselysäiliöksi */
.product-card-slot {
container-type: inline-size;
container-name: product-card;
}
/* Oletusasettelu: kuva sisällön yläpuolella – turvallinen kapeassa tilassa */
.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;
}
/* Yli 360px SÄILIÖN leveyttä, ei näyttöikkunan – vaihda vaakasuoraan asetteluun */
@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%;
}
}

Tässä ratkaiseva päätös piilee rakenteessa, ei CSS:ssä: container-type on asetettu elementille .product-card-slot eli kortin vanhemmalle, ei itse .product-card-elementille. Tämä ei ole sattumaa eikä ylivarovaisuutta – spesifikaatio nimenomaisesti sulkee pois mahdollisuuden, että elementti kyselisi omaa kokoaan @container-säännöllä (jälleen sama kehäriippuvuusongelma: elementti ei voi samanaikaisesti määritellä säiliötä ja saada tyylinsä reaktiona sen kokoon). Siksi tyypillinen, toistuva kaava on ohut wrapper-säiliö ulkopuolella ja varsinainen komponentti sisällä, tyylitettynä @container-lohkon sisällä. Sama CSS-tiedosto, pudotettuna kerran tuoteruudukkoon (jossa slotti on 480px) ja kerran sivupalkkiin (jossa slotti on 240px), tuottaa lopputuloksena kaksi erilaista, oikeaa asettelua – ilman propsia, ilman muokkainluokkaa, ilman ainuttakaan riviä JavaScriptia.

Säiliöyksiköt: sulavuutta ilman näyttöikkunaa

Container Queries toi mukanaan myös toisen, vähemmän tunnetun palan samaa spesifikaatiota: säiliöön suhteutetut yksiköt, ei näyttöikkunaan – cqw (1 % säiliön leveydestä), cqh (1 % korkeudesta), cqi ja cqb (1 % inline-/block-akselilla, eli toimivat oikein myös pystysuunnassa kirjoitettavissa kielissä) sekä cqmin/cqmax, jotka vastaavat vmin/vmax-yksiköitä, mutta lasketaan säiliön pienemmästä tai suuremmasta mitasta.

Niiden luonteva käyttökohde on sulava typografia komponentin sisällä, riippumatta siitä, kuinka leveä käyttäjän näyttö kulloinkin on:

css

.product-card__title {
/* Skaalautuu kortin leveyden mukaan, ei näytön leveyden */
font-size: clamp(1rem, 0.85rem + 2cqi, 1.375rem);
}

Ero jo tuttuun clamp()-temppuun vw-yksiköllä on hienovarainen, mutta käytännössä perustavanlaatuinen: vw-yksiköillä skaalattu otsikko muuttaa kokoaan koko sivun mukana, joten kaksi kopiota samasta kortista – toinen täysleveässä ruudukossa, toinen kapeassa sivupalkissa – saavat identtisen fonttikoon, koska molemmat näkevät saman näyttöikkunan. cqi-yksiköllä skaalattu otsikko sen sijaan reagoi kyseiselle kortille todellisuudessa annettuun tilaan – sivupalkissa se on pienempi, ruudukossa suurempi, vaikka näyttöikkuna pysyy koko ajan täsmälleen samanlevyisenä.

Sudenkuopat ja hyvät käytännöt

  • container-type: size ilman eksplisiittistä korkeutta syö sisällön. Containment block-akselilla katkaisee elementin lasten luonnollisesta korkeudesta – jos et anna height- tai aspect-ratio-arvoa, säiliö kutistuu käytännössä nollakorkuiseksi ja sen lapset katoavat visuaalisesti, vaikka ne ovat yhä DOM:ssa. Valtaosassa tapauksista inline-size riittää, eikä tätä ongelmaa ole.
  • Säiliö ei voi kysellä itseään. Jos taistelet sen kanssa, että @container "ei toimi", ensimmäinen epäilys on juuri tämä: container-type ja @container-säännön tyylittämä selektori ovat samalla elementillä. Erota ne toisistaan – container-type wrapperille, kohdetyylit lapselle, aivan kuten yllä olevassa tuotekorttiesimerkissä.
  • Container queryt eivät korvaa media querya – ne täydentävät sitä. Media query on edelleen oikea työkalu sivutason päätöksiin (esim. näytetäänkö sivupalkki ylipäätään vai vaihdetaanko navigaatio hampurilaisvalikoksi). Container query vastaa komponenttitason päätöksistä, riippumatta siitä, minne komponentti päätyy. Hyvä asettelu vuonna 2026 käyttää yleensä molempia yhtä aikaa, kumpaakin siellä, mihin sen esittämä kysymys todella sopii.
  • Älä tee jokaisesta divistä säiliötä varmuuden vuoksi. Containmentilla on todellinen kustannus renderöintimoottorille – se on signaali "kohtele minua eristettynä asetteluyksikkönä", hyödyllinen siellä, missä sinulla todella on komponentti, joka reagoi omaan kokoonsa, ei oletusasetus koko DOM-puulle.
  • Selaintuki ei ole enää ongelma. container-type ja @container saavuttivat Baseline-statuksen (laajasti saatavilla) vuoden 2023 alussa – ne toimivat natiivisti Chromessa, Safarissa ja Firefoxissa ilman polyfillejä ja ilman @supports-sääntöä. Yksiköillä cqw/cqi ja koko muulla perheellä on täsmälleen sama kattavuus.

Yhteenveto

Uudelleenkäytettävä komponentti ei ole sellainen, jolla on tarpeeksi propseja ja muokkainluokkia hoitamaan käsin jokainen paikka, johon se voi päätyä – se on sellainen, joka itse tietää, kuinka paljon tilaa sillä on, ja reagoi siihen ilman kenenkään apua. Media query oli kahden vuosikymmenen ajan CSS:n ainoa työkalu responsiivisuuteen, joten luonnollisesti käytimme sitä väärin myös silloin, kun kyse oli oikeasti vanhemmasta, ei näyttöikkunasta. Container Queries ei ole saman mekanismin uusi syntaktinen muunnelma – se on eri kysymys, esitetty DOM-puun oikeassa kohdassa, containmentin tukemana, joka takaa, ettei vastaus siihen koskaan jää silmukkaan. Tämän artikkelin koodi – yksi komponentti, yksi CSS-tiedosto, ei yhtään JavaScriptia – toimii oikein ruudukossa, sivupalkissa ja jokaisessa muussa paikassa, josta et vielä tiedä, että tulet sen joskus sinne laittamaan.