
Container Queries: 미디어 쿼리를 넘어서
미디어 쿼리로 반응형 컴포넌트를 만들다가 한계에 부딪힌 적 있어? @container가 그 문제를 어떻게 해결하는지, 실제 카드 컴포넌트 예시로 완전히 뜯어봤다.

미디어 쿼리로 반응형 컴포넌트를 만들다가 한계에 부딪힌 적 있어? @container가 그 문제를 어떻게 해결하는지, 실제 카드 컴포넌트 예시로 완전히 뜯어봤다.
클래스 이름 짓기 지치셨나요? HTML 안에 CSS를 직접 쓰는 기괴한 방식이 왜 전 세계 프론트엔드 표준이 되었는지 파헤쳐봤습니다.

시각 장애인, 마우스가 고장 난 사용자, 그리고 미래의 나를 위한 배려. `alt` 태그 하나가 만드는 큰 차이.

분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

버튼을 눌렀는데 부모 DIV까지 클릭되는 현상. 이벤트는 물방울처럼 위로 올라갑니다(Bubbling). 반대로 내려오는 캡처링(Capturing)도 있죠.

반응형 웹 개발을 하다 보면 어느 순간 이런 상황을 만나게 돼.
ProductCard 컴포넌트를 만들었어. 사이드바에서는 세로로 쌓여야 하고, 메인 콘텐츠 영역에서는 가로로 나란히 놓여야 해. 대시보드에서는 3열, 모바일에서는 1열.
미디어 쿼리로 해결하려니:
/* 뷰포트 너비 기준 */
@media (max-width: 768px) {
.product-card { flex-direction: column; }
}
@media (min-width: 769px) and (max-width: 1024px) {
.sidebar .product-card { flex-direction: column; }
.main-content .product-card { flex-direction: row; }
}
/* 이것도 사이드바 열림/닫힘 상태엔 안 맞아... */
뷰포트 너비는 알 수 있는데, 컴포넌트가 실제로 얼마나 넓은 공간에 있는지는 모르잖아. 이게 미디어 쿼리의 근본적인 한계야.
Container Queries는 이 문제를 해결하기 위해 태어났다.
미디어 쿼리는 마치 건물 전체의 평수를 보고 방 인테리어를 결정하는 것과 같아. 건물이 100평이든 200평이든 상관없이, 방 자체가 얼마나 큰지가 중요한데 말이야.
Container Queries는 각 방의 크기를 직접 측정해서 그 방에 맞는 인테리어를 결정하는 방식이야.
/* 미디어 쿼리: 뷰포트(건물 전체)를 본다 */
@media (min-width: 768px) {
.card { display: flex; }
}
/* Container Query: 부모 컨테이너(방)를 본다 */
@container card-wrapper (min-width: 400px) {
.card { display: flex; }
}
/* 1단계: 컨테이너 정의 */
.card-wrapper {
container-type: inline-size;
/* 또는 container-name까지 함께 */
container-name: card-wrapper;
/* 단축 속성 */
container: card-wrapper / inline-size;
}
/* 2단계: 컨테이너 쿼리 작성 */
@container card-wrapper (min-width: 400px) {
.card {
display: flex;
flex-direction: row;
}
}
| 값 | 의미 | 쿼리 가능 축 |
|---|---|---|
inline-size | 인라인 방향(보통 가로) 크기 기준 | 너비 |
size | 가로 + 세로 모두 기준 | 너비, 높이 |
normal | 기본값, 쿼리 불가 | 없음 |
대부분의 경우 inline-size를 쓰면 돼. size는 높이도 고려할 때만.
/* 가장 흔한 패턴 */
.container {
container-type: inline-size;
}
/* 이름 붙이기 (권장) */
.sidebar {
container-type: inline-size;
container-name: sidebar;
}
/* 단축형 */
.card-list {
container: card-list / inline-size;
}
/* container-name 없이도 쓸 수 있다 */
.wrapper {
container-type: inline-size;
}
/* 이름 없이 쿼리하면 가장 가까운 컨테이너 조상을 봄 */
@container (min-width: 400px) {
.card { display: flex; }
}
이름이 없으면 DOM 트리에서 가장 가까운 container-type이 설정된 조상을 참조해.
<!-- 같은 컴포넌트, 다른 맥락 -->
<main>
<!-- 메인 그리드: 넓은 공간 -->
<div class="main-grid">
<div class="card-wrapper">
<article class="product-card">...</article>
</div>
</div>
</main>
<aside>
<!-- 사이드바: 좁은 공간 -->
<div class="sidebar">
<div class="card-wrapper">
<article class="product-card">...</article>
</div>
</div>
</aside>
/* 컨테이너 설정 */
.card-wrapper {
container-type: inline-size;
container-name: product-card;
}
/* 기본 스타일: 좁은 공간 (세로 레이아웃) */
.product-card {
display: grid;
grid-template-rows: auto 1fr auto;
border-radius: 12px;
overflow: hidden;
background: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.product-card__image {
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
}
.product-card__content {
padding: 1rem;
}
.product-card__title {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.product-card__price {
font-size: 1.2rem;
font-weight: bold;
color: #e44;
}
/* 400px 이상: 가로 레이아웃 */
@container product-card (min-width: 400px) {
.product-card {
display: grid;
grid-template-columns: 200px 1fr;
grid-template-rows: 1fr auto;
}
.product-card__image {
grid-row: 1 / 3;
aspect-ratio: 3/4;
height: 100%;
}
.product-card__title {
font-size: 1.2rem;
}
}
/* 600px 이상: 더 풍부한 레이아웃 */
@container product-card (min-width: 600px) {
.product-card {
grid-template-columns: 300px 1fr;
}
.product-card__content {
padding: 1.5rem;
}
.product-card__title {
font-size: 1.5rem;
}
/* 이 크기에서만 설명 텍스트 표시 */
.product-card__description {
display: block;
color: #666;
margin-bottom: 1rem;
}
}
이제 같은 카드가 사이드바에서는 세로로, 메인 그리드에서는 가로로, 넓은 영역에서는 설명까지 보여주는 풍부한 레이아웃으로 렌더링돼. 뷰포트 너비와 무관하게, 컨테이너 크기만 보고.
여러 컨테이너가 중첩될 때 이름이 중요해져.
/* 두 개의 컨테이너 설정 */
.page-layout {
container: page / inline-size;
}
.sidebar {
container: sidebar / inline-size;
}
.widget {
container: widget / inline-size;
}
/* 이름으로 특정 컨테이너 참조 */
@container page (min-width: 1200px) {
.sidebar { width: 300px; }
}
@container sidebar (max-width: 250px) {
.widget { padding: 0.5rem; }
}
@container widget (min-width: 200px) {
.widget-content { display: flex; }
}
이름 없이 쿼리하면 항상 가장 가까운 조상 컨테이너를 참조해. 명시적으로 특정 컨테이너를 참조하고 싶으면 이름을 써야 해.
Container Queries에는 전용 단위가 있어. 뷰포트 기반 vw, vh의 컨테이너 버전이야.
| 단위 | 의미 |
|---|---|
cqw | 컨테이너 너비의 1% |
cqh | 컨테이너 높이의 1% |
cqi | 컨테이너 인라인 크기의 1% |
cqb | 컨테이너 블록 크기의 1% |
cqmin | cqi와 cqb 중 더 작은 값 |
cqmax | cqi와 cqb 중 더 큰 값 |
/* 실용적인 예시 */
.card-title {
/* 컨테이너 너비에 따라 폰트 크기 자동 조절 */
font-size: clamp(1rem, 4cqw, 2rem);
}
.card-image {
/* 컨테이너 너비의 40% */
width: 40cqw;
}
.card-padding {
/* 컨테이너 크기에 비례한 패딩 */
padding: 2cqi;
}
특히 clamp() + cqw 조합은 컨테이너 크기에 따라 부드럽게 변하는 유체 타이포그래피를 구현할 때 강력해:
.hero-title {
/* 최소 1.5rem, 최대 3rem, 그 사이는 컨테이너 너비 6%에 비례 */
font-size: clamp(1.5rem, 6cqw, 3rem);
}
Container Queries가 미디어 쿼리를 대체하는 게 아니야. 서로 다른 문제를 해결해.
/* 미디어 쿼리: 전체 레이아웃 구조 */
@media (min-width: 1024px) {
.page-layout {
display: grid;
grid-template-columns: 280px 1fr;
}
}
/* Container Query: 개별 컴포넌트 내부 */
.card-wrapper {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
flex-direction: row;
}
}
미디어 쿼리가 잘 하는 것:
/* 레이아웃 */
@media (min-width: 768px) {
.app-layout {
display: grid;
grid-template-columns: 240px 1fr;
}
.sidebar { container: sidebar / inline-size; }
.main { container: main-content / inline-size; }
}
/* 컴포넌트 - 어디 있든 공간에 맞게 */
.post-card-wrapper {
container: post-card / inline-size;
}
@container post-card (min-width: 300px) {
.post-card { /* 기본 */ }
}
@container post-card (min-width: 500px) {
.post-card {
display: flex;
gap: 1rem;
}
}
# @tailwindcss/container-queries 플러그인 설치
npm install @tailwindcss/container-queries
// tailwind.config.js
module.exports = {
plugins: [
require('@tailwindcss/container-queries'),
],
}
function ProductCard({ product }) {
return (
// @container 클래스로 컨테이너 설정
<div className="@container">
<article className="flex flex-col @[400px]:flex-row gap-4 p-4 bg-white rounded-xl shadow">
<img
src={product.image}
className="w-full @[400px]:w-48 aspect-video @[400px]:aspect-square object-cover rounded-lg"
alt={product.name}
/>
<div className="flex-1">
<h3 className="text-base @[400px]:text-lg @[600px]:text-xl font-semibold">
{product.name}
</h3>
<p className="hidden @[600px]:block text-gray-600 mt-1">
{product.description}
</p>
<span className="text-red-500 font-bold text-lg mt-2 block">
{product.price}
</span>
</div>
</article>
</div>
);
}
// @container/[name] 패턴
<div className="@container/sidebar">
<nav className="flex-col @[250px]/sidebar:flex-row">
...
</nav>
</div>
Container Queries의 확장으로 스타일 쿼리(Style Queries)도 들어오고 있어. CSS custom property 값에 따라 쿼리하는 방식이야.
/* 아직 실험적이지만 미래는 이쪽 */
.card-wrapper {
container-type: style;
--variant: featured;
}
@container style(--variant: featured) {
.card {
border: 2px solid gold;
background: #fffbe6;
}
}
// JavaScript/React에서 CSS custom property로 컨텍스트 전달
function Card({ variant = 'default' }) {
return (
<div
className="card-wrapper"
style={{ '--variant': variant } as React.CSSProperties}
>
<article className="card">...</article>
</div>
);
}
CSS custom property를 통해 JavaScript 상태와 CSS 사이의 다리가 되는 거야. variant prop을 CSS만으로 처리할 수 있게 된다.
"레이아웃 계산이 더 복잡해지는 거 아냐?" 하는 걱정이 있을 수 있어.
실제로 브라우저들은 Container Queries를 효율적으로 구현했어. 컨테이너 크기가 바뀔 때만 자식 요소들의 스타일을 다시 계산하고, 컨테이너 크기가 그대로면 재계산 없어.
주의할 점:
/* 이러면 컨테이너가 자기 자신의 크기를 쿼리하는 순환 참조 발생 가능 */
.card {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
width: 600px; /* 컨테이너 크기 자체를 변경 → 무한 루프 위험 */
}
}
/* 안전한 패턴: 내부 자식 요소만 변경 */
@container (min-width: 400px) {
.card > .card-content {
display: flex; /* OK */
}
}
컨테이너 쿼리 안에서 컨테이너 자체의 크기를 바꾸는 건 피해야 해. 내부 자식 요소들의 스타일만 변경하는 게 안전해.
| 브라우저 | Size Queries | Style Queries |
|---|---|---|
| Chrome 105+ | 지원 | 실험적 |
| Edge 105+ | 지원 | 실험적 |
| Safari 16+ | 지원 | 일부 |
| Firefox 110+ | 지원 | 지원 예정 |
전 세계 브라우저 지원율 약 90%+. 지금 바로 프로덕션에서 써도 된다.
폴백이 필요하다면:
/* 지원 안 하는 브라우저용 기본 스타일 */
.card {
display: flex;
flex-direction: column;
}
/* Container Queries 지원하면 덮어씀 */
@supports (container-type: inline-size) {
.card-wrapper {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
flex-direction: row;
}
}
}
기존 미디어 쿼리 기반 컴포넌트를 Container Queries로 마이그레이션하는 체크리스트:
container-type: inline-size 설정@media (min-width: Xpx) → @container (min-width: Ypx) (Y는 컨테이너 기준으로 조정)cqw 단위 적용 검토/* Before: 미디어 쿼리 */
@media (min-width: 768px) {
.card { display: flex; }
}
@media (min-width: 1024px) {
.card { gap: 2rem; }
}
/* After: Container Queries */
.card-wrapper {
container: card / inline-size;
}
@container card (min-width: 300px) {
.card { display: flex; }
}
@container card (min-width: 500px) {
.card { gap: 2rem; }
}
Container Queries는 CSS 역사상 가장 오래 기다려온 기능 중 하나야. 미디어 쿼리가 "페이지 레이아웃"의 도구라면, Container Queries는 "컴포넌트 레이아웃"의 도구.
핵심 포인트:container-type: inline-size로 컨테이너 선언@container (min-width: Xpx) 문법으로 쿼리cqw, cqi 등 컨테이너 상대 단위 활용진정한 컴포넌트 기반 개발은 컴포넌트가 어디에 위치하든 그 공간에 맞게 스스로 적응하는 것부터 시작해. Container Queries가 그 꿈을 이루게 해준다.