
웹 애플리케이션을 만들다 보면 반응형 디자인은 거의 기본값처럼 따라온다. 화면 크기와 디바이스가 워낙 다양하니, UI가 좁은 화면과 넓은 화면에서 다르게 보이는 일 자체는 특별하지 않다. 오래 동안 이 역할은 CSS 미디어 쿼리가 맡아왔다. 뷰포트 너비를 기준으로 레이아웃을 바꾸는 방식은 단순하고, 충분히 강력하다.
하지만 미디어 쿼리는 어디까지나 브라우저 화면을 본다. 문제는 요즘 UI가 화면 하나만 보고 판단하기에는 너무 잘게 쪼개져 있다는 데 있다. 같은 카드 컴포넌트라도 메인 그리드 안에 있을 때와 사이드바 안에 있을 때, 혹은 모달 안에 들어갔을 때 사용할 수 있는 공간은 완전히 달라진다. 뷰포트는 그대로인데 컴포넌트가 놓인 영역만 달라지는 상황이다. 미디어 쿼리만으로는 이 차이를 알 수 없다.
이럴 때 흔히 떠올리는 방법은 ResizeObserver나 useEffect로 요소의 크기를 직접 관찰하는 것이다. 동작은 한다. 컴포넌트의 실제 너비를 읽고, 상태를 바꾸고, 조건부 클래스나 인라인 스타일을 붙이면 된다. 그런데 그 순간 스타일의 책임이 자바스크립트 쪽으로 조금씩 흘러간다. 단순히 “이 공간이 좁으면 이렇게 보여라”라고 말하고 싶었을 뿐인데, 관찰자와 상태와 렌더링 타이밍을 같이 생각해야 한다. 좀 구리다.
CSS Container Query는 이 지점을 정확히 겨냥한다. 컨테이너 쿼리는 뷰포트가 아니라 특정 요소의 크기를 기준으로 스타일을 분기한다. 다시 말해 컴포넌트가 브라우저 전체 화면이 아니라, 자신이 실제로 들어가 있는 공간을 보고 반응할 수 있게 해준다. 반응형 디자인의 기준이 페이지에서 컴포넌트로 내려오는 셈이다.
예를 들어 사용자가 사이드바를 접거나 펼칠 수 있는 서비스를 생각해보자. 유튜브 같은 화면이 대표적이다. 사이드바 상태에 따라 콘텐츠 영역의 너비는 꽤 크게 달라지지만, 브라우저 뷰포트 자체는 그대로일 수 있다. 미디어 쿼리만 쓰면 “사이드바가 열려 있을 때의 콘텐츠 너비” 같은 상태를 따로 고려해야 한다. 반면 컨테이너 쿼리를 쓰면 기준은 단순해진다. 콘텐츠가 들어 있는 영역의 너비만 보면 된다.
컨테이너가 될 요소에는 container-type을 지정한다. 일반적인 반응형 UI에서는 가로 방향 크기만 보면 되는 경우가 많으므로 inline-size를 가장 자주 쓴다. size는 인라인 축과 블록 축을 모두 포함하지만, 높이 계산까지 격리되기 때문에 오히려 의도하지 않은 레이아웃 문제를 만들 수 있다.
.profile {
container-type: inline-size;
}
.description {
font-family: Pretendard, sans-serif;
font-size: 3.6rem;
}
@container (max-width: 700px) {
.description {
font-size: 2.4rem;
}
}return (
<section className={styles.profile}>
<p className={styles.description}>{/* ... */}</p>
</section>
);이 코드에서 .description은 뷰포트가 아니라 가장 가까운 조상 컨테이너인 .profile의 너비를 기준으로 폰트 크기를 바꾼다. 같은 화면 크기라도 .profile이 넓은 그리드 안에 있느냐, 좁은 사이드바 안에 있느냐에 따라 결과가 달라질 수 있다. 이게 컨테이너 쿼리의 핵심이다.
기본적으로 @container는 가장 가까운 조상 컨테이너를 기준으로 동작한다. 중첩된 구조에서 특정 컨테이너를 명시하고 싶다면 container-name을 함께 사용하면 된다.
.profile {
container-name: profile-card;
container-type: inline-size;
}
.inner {
container-type: inline-size;
}
.description {
font-size: 3.6rem;
}
@container profile-card (max-width: 700px) {
.description {
font-size: 2.4rem;
}
}return (
<section className={styles.profile}>
<div className={styles.inner}>
<p className={styles.description}>{/* ... */}</p>
</div>
</section>
);여기서 .description은 가장 가까운 컨테이너인 .inner가 아니라 profile-card라는 이름을 가진 .profile을 기준으로 스타일을 계산한다. DOM 구조가 조금만 복잡해져도 “무엇을 기준으로 반응하는가”가 흐려지기 쉬운데, 이름 붙은 컨테이너는 그 기준을 명시적으로 남겨준다.
CSS Module에서는 위 문법을 그대로 쓰면 된다. Vanilla Extract 같은 CSS-in-JS 계열에서도 결국 CSS의 @container 규칙을 생성하는 방식이므로, 각 도구가 제공하는 문법에 맞춰 작성하면 된다.
// Vanilla Extract
export const container = style({
containerType: 'inline-size',
containerName: 'profile-card',
width: '100%',
padding: '20px',
});
export const responsiveText = style({
fontSize: '16px',
'@container profile-card': {
'(min-width: 500px)': {
fontSize: '20px',
},
'(min-width: 800px)': {
fontSize: '24px',
},
},
});Tailwind CSS를 쓴다면 문법이 조금 다르다. Tailwind CSS v4에서는 컨테이너 쿼리가 코어 기능으로 들어와서 별도 플러그인 없이 사용할 수 있다. 부모 요소에 @container 유틸리티를 붙이면 컨테이너가 되고, 자식 요소에서는 @sm:, @md:, @lg: 같은 컨테이너 기준 변형을 붙인다.
export function Card() {
return (
<article className="@container rounded-xl border p-4">
<div className="grid gap-3 @md:grid-cols-[160px_1fr]">
<img className="aspect-video w-full rounded-lg object-cover" src="/thumbnail.png" alt="" />
<div>
<h2 className="text-lg font-semibold @md:text-xl">Container Query</h2>
<p className="mt-2 text-sm text-gray-600 @md:text-base">
이 문장은 뷰포트가 아니라 카드가 들어 있는 공간을 기준으로 바뀐다.
</p>
</div>
</div>
</article>
);
}이 예제에서 @md:grid-cols-[160px_1fr]는 화면이 md 이상일 때가 아니라, article 컨테이너가 Tailwind의 컨테이너 쿼리 기준에서 md 이상일 때 적용된다. 이름이 필요한 경우에는 @container/card로 컨테이너를 만들고 @md/card:처럼 특정 이름의 컨테이너를 기준으로 삼을 수도 있다.
<div className="@container/card">
<div className="grid gap-4 @md/card:grid-cols-2 @max-lg/card:bg-gray-50">
{/* ... */}
</div>
</div>@max-*, @min-* 계열도 함께 쓰면 “이 범위에서만” 같은 조건을 더 직접적으로 표현할 수 있다. Tailwind v3 계열이라면 @tailwindcss/container-queries 플러그인을 붙여서 비슷한 방식으로 사용할 수 있다. 다만 새 프로젝트라면 v4의 코어 지원을 기준으로 생각하는 편이 자연스럽다.
물론 컨테이너 쿼리가 미디어 쿼리를 대체하는 것은 아니다. 페이지 전체의 큰 레이아웃, 예를 들어 모바일에서는 내비게이션을 숨기고 데스크톱에서는 사이드바를 노출하는 판단은 여전히 뷰포트 기준이 더 자연스럽다. 반대로 카드, 리스트 아이템, 프로필 박스처럼 여러 위치에 재사용되는 컴포넌트는 자신이 차지한 공간을 기준으로 반응하는 편이 낫다.
정리하면 컨테이너 쿼리는 “반응형 디자인을 어디의 책임으로 둘 것인가”에 대한 문법이다. 화면 전체의 문제는 미디어 쿼리가 보고, 컴포넌트가 놓인 공간의 문제는 컨테이너 쿼리가 본다. 이 구분만 잡아도 스타일 로직이 자바스크립트로 새는 일을 꽤 줄일 수 있고, 재사용 가능한 UI를 만들 때 머릿속에서 계산해야 하는 경우의 수도 줄어든다. 결국 좋은 반응형 설계란 더 많은 분기를 만드는 일이 아니라, 올바른 기준을 고르는 일에 가깝다.
댓글
댓글을 불러오는 중...