본문 바로가기
Frontend2026년 9월 6일19분 읽기

CSS 컨테이너 쿼리와 :has() — 미디어쿼리로는 못 풀던 반응형을 컴포넌트 단위로

YS
김영삼
조회 8
CSS 컨테이너 쿼리와 :has() — 미디어쿼리로는 못 풀던 반응형을 컴포넌트 단위로

컨테이너 쿼리는 \"뷰포트가 얼마나 넓은가\"가 아니라 \"내 부모 요소가 얼마나 넓은가\"로 스타일을 바꾼다. :has()는 CSS에 드디어 부모 선택자를 준다. 이 둘이 합쳐지면서, 같은 컴포넌트를 사이드바에 넣든 본문에 넣든 알아서 레이아웃이 바뀌는 진짜 컴포넌트 반응형이 가능해졌다.

2023년 후반부터 모든 주요 브라우저가 둘 다 지원한다. 나도 처음엔 반신반의했는데, 디자인 시스템 하나를 컨테이너 쿼리 기반으로 갈아엎고 나서 미디어쿼리 관리 부담이 확 줄었다. 아래는 그 과정에서 정리한 실전 패턴들이다.

반응형 CSS를 10년 넘게 짜면서 가장 답답했던 건, 카드 컴포넌트 하나가 놓이는 위치마다 다르게 보여야 하는데 CSS는 항상 화면 전체 너비만 알고 있다는 점이었다. 넓은 본문에 들어간 카드는 가로 배치, 좁은 사이드바에 들어간 같은 카드는 세로 배치 — 이걸 미디어쿼리로 하려면 결국 부모마다 클래스를 다르게 주거나 JS로 너비를 재는 수밖에 없었다. 컨테이너 쿼리는 정확히 이 문제를 겨냥해서 나왔다.

기능기준표준화주요 브라우저 지원
@media뷰포트(화면) 크기CSS2 (2001)전부
@container (size)부모 컨테이너 크기2022~Chrome 105+, Safari 16+, Firefox 110+
@container (style)부모의 커스텀 속성 값2023~Chrome 111+, Safari 18+ (부분)
:has()자식/후속 요소 조건으로 부모 선택2023~Chrome 105+, Safari 15.4+, Firefox 121+
cqw / cqi 단위컨테이너 상대 길이2022~컨테이너 쿼리와 동일

위 지원 버전은 대략적 기준이다. 대부분의 실제 사용자 환경에서는 2024년 이후로 두 기능 모두 90% 후반대 커버리지에 도달했다. 정확한 수치는 caniuse.com에서 배포 직전에 다시 확인하는 습관을 권한다.

미디어쿼리는 왜 반응형의 완전한 답이 아니었나

미디어쿼리는 뷰포트만 본다. 컴포넌트가 실제로 차지하는 공간이 아니라 브라우저 창 전체 너비를 기준으로 판단하기 때문에, 재사용 가능한 컴포넌트의 반응형을 근본적으로 표현하지 못한다. 이게 핵심 한계다.

예를 들어 화면이 1400px로 넓어도, 그 안의 카드가 240px짜리 사이드바에 들어가 있으면 카드는 좁다. 그런데 미디어쿼리는 \"화면이 넓으니까 가로 배치\"라고 판단해버린다. 결과가 깨진다. 이걸 피하려고 우리는 그동안 이런 것들을 해왔다.

  • 부모 위치에 따라 .card--sidebar, .card--main 같은 변형 클래스를 수동으로 붙이기
  • ResizeObserver로 JS에서 너비를 재고 클래스를 토글하기 (리렌더·레이아웃 스래싱 위험)
  • 그냥 포기하고 한 가지 레이아웃으로 타협하기

셋 다 겪어봤는데, 특히 두 번째 JS 방식은 컴포넌트가 많아지면 성능도 성능이지만 유지보수가 지옥이었다. 컨테이너 쿼리는 이걸 브라우저 렌더링 엔진 레벨에서 공짜로 해준다. 그게 진짜 차이다.

@container의 기본 문법: 두 줄이면 시작한다

컨테이너 쿼리는 두 단계다. 먼저 어떤 요소를 \"컨테이너\"로 선언하고(container-type), 그다음 자식에서 @container로 그 컨테이너 크기를 질의한다. 미디어쿼리처럼 전역이 아니라, 명시적으로 기준점을 잡아준다는 게 다르다.

/* 1단계: 부모를 컨테이너로 등록 */
.card-wrapper {
  container-type: inline-size;   /* 인라인(가로) 크기를 질의 대상으로 */
  container-name: card;          /* 이름은 선택 사항이지만 붙이는 걸 권장 */
}

/* 축약형 — 위 두 줄을 한 줄로 */
.card-wrapper {
  container: card / inline-size;
}

/* 2단계: 컨테이너 너비에 따라 자식 스타일 변경 */
.card {
  display: grid;
  gap: 12px;
}

@container card (min-width: 400px) {
  .card {
    grid-template-columns: 160px 1fr;  /* 넓으면 좌우 2단 */
  }
}

여기서 inline-size가 거의 항상 정답이다. size는 가로·세로 둘 다 질의할 수 있지만, 컨테이너의 높이를 고정해야 해서(콘텐츠 높이에 따라 늘어나면 순환 참조가 되니까) 실무에서 쓸 일이 드물다. 세로 스크롤 웹에서 우리가 궁금한 건 결국 \"가로로 얼마나 넓냐\"다.

참고 container-type: inline-size를 지정한 요소는 자동으로 contain: layout style inline-size 성격을 갖는다. 즉 그 요소의 안쪽 크기가 바깥에 영향을 주지 않게 격리된다. 그래서 컨테이너 자신에게 @container를 걸 수 없다 — 반드시 컨테이너의 자식에서 질의해야 한다. 이거 모르고 컨테이너 요소 자체에 규칙 걸었다가 왜 안 먹지 하고 30분 날린 적 있다.

실전: 같은 카드, 놓이는 곳에 따라 다른 레이아웃

컨테이너 쿼리의 진가는 같은 마크업, 같은 클래스가 부모에 따라 다르게 보일 때 드러난다. 아래 카드는 컨테이너가 320px 미만이면 세로, 그 이상이면 가로 2단으로 스스로 바뀐다. 어느 부모에 넣든 코드는 그대로다.

.grid { display: grid; gap: 20px; }
.slot { container-type: inline-size; }

/* 카드 컴포넌트 — 어디에 놓여도 동일한 마크업 */
.product {
  display: grid;
  gap: 12px;
  border: 1px solid var(--line);
  border-radius: 12px;
  padding: 16px;
}
.product img { width: 100%; border-radius: 8px; }

/* 컨테이너가 넓어지면 썸네일을 왼쪽으로 */
@container (min-width: 320px) {
  .product {
    grid-template-columns: 120px 1fr;
    align-items: center;
  }
}

/* 더 넓으면 여백과 폰트까지 키운다 */
@container (min-width: 520px) {
  .product { grid-template-columns: 200px 1fr; padding: 24px; }
  .product h3 { font-size: 20px; }
}

HTML 쪽은 이렇게 .slot으로만 감싸주면 끝이다. 하나는 3단 그리드 안에, 하나는 좁은 사이드바 안에 넣어도 각 카드가 알아서 판단한다.

<main class="grid" style="grid-template-columns: 2fr 1fr">
  <div class="grid" style="grid-template-columns: repeat(2, 1fr)">
    <div class="slot"><article class="product">...</article></div>
    <div class="slot"><article class="product">...</article></div>
  </div>
  <aside class="slot">
    <article class="product">...</article>  <!-- 좁아서 자동 세로 -->
  </aside>
</main>

이게 되는 순간 디자인 시스템 관점에서 사고방식이 바뀐다. 컴포넌트가 \"화면 어디쯤\"이 아니라 \"내가 받은 공간\"만 신경 쓰면 되니까, 진짜로 이식 가능한 부품이 된다.

cqw / cqi: 컨테이너 상대 단위로 유동 타이포까지

컨테이너 쿼리는 새 길이 단위도 딸려 온다. cqw는 컨테이너 너비의 1%, cqi는 인라인 크기의 1%다. 뷰포트 기준 vw의 컨테이너 버전이라고 보면 된다. 이걸로 컨테이너 크기에 비례하는 유동 폰트를 만들 수 있다.

.hero { container-type: inline-size; }

.hero__title {
  /* 컨테이너 폭에 비례하되 최소 20px, 최대 44px로 클램프 */
  font-size: clamp(20px, 5cqi, 44px);
  line-height: 1.15;
}

개인적으로는 히어로 배너나 카드 제목처럼 \"박스 크기에 딱 맞아떨어져야\" 예쁜 곳에서 cqi + clamp() 조합을 즐겨 쓴다. vw로 하면 사이드바에 들어갔을 때 폰트가 이상하게 커지거나 작아지는데, cqi는 그럴 일이 없다. 단위 하나 바꿨을 뿐인데 체감이 크다.

주의 cqi/cqw는 가장 가까운 등록된 컨테이너를 기준으로 계산된다. 조상 중에 container-type이 지정된 요소가 하나도 없으면 뷰포트(작은 뷰포트, small viewport)를 기준으로 폴백한다. 값이 예상과 다르게 나오면 \"내 조상 중에 컨테이너가 있긴 한가\"부터 의심하자.

:has() — CSS가 드디어 얻은 부모 선택자

:has()는 \"이런 자식/후속 요소를 가진 부모\"를 선택한다. CSS 역사상 20년 넘게 요청됐던 부모 선택자가 이걸로 실현됐다. 그리고 결정적으로 순수 CSS로 상태에 반응하는 문이 열렸다 — 예전엔 JS로 클래스를 토글해야만 했던 것들이다.

/* 이미지가 있는 카드만 패딩을 다르게 */
.card:has(img) { padding-top: 0; }

/* 체크된 체크박스를 품은 라벨 강조 (JS 없이!) */
.option:has(input:checked) {
  border-color: var(--primary);
  background: color-mix(in srgb, var(--primary) 8%, transparent);
}

/* 유효성 실패한 입력을 가진 폼 그룹 */
.field:has(input:invalid:not(:placeholder-shown)) .error-msg {
  display: block;
}

세 번째 예시가 실무에서 특히 유용하다. 예전엔 입력 검증 UI를 위해 input에 이벤트 리스너 달고, 부모를 타고 올라가서 에러 메시지 요소를 찾아 display를 토글했다. 이제 CSS 한 줄이다. :placeholder-shown을 끼운 건 \"아직 입력 안 한 빈 칸\"까지 빨갛게 만들지 않으려는 현장 팁이다.

:has()의 진짜 힘: 형제·전역 상태 반응

:has()는 부모뿐 아니라 결합자와 조합해서 형제 관계까지 표현한다. 이 덕분에 \"어떤 상태면 페이지 전체를 바꾼다\" 같은 것도 순수 CSS로 가능하다. 예를 들어 모달이 열리면 body 스크롤을 잠그는 클래식한 패턴.

/* 열린 dialog가 문서에 존재하면 배경 스크롤 잠금 */
body:has(dialog[open]) {
  overflow: hidden;
}

/* 사이드바 토글 체크박스 상태로 그리드 레이아웃 전환 */
.layout:has(#nav-toggle:checked) {
  grid-template-columns: 260px 1fr;
}
.layout:has(#nav-toggle:not(:checked)) {
  grid-template-columns: 0 1fr;
}

/* 앞에 특정 요소가 오면 다음 형제 여백 조정 */
h2:has(+ .callout) { margin-bottom: 8px; }

주의할 건 :has() 안의 선택자는 기준 요소의 특이도(specificity)에 영향을 준다는 점. :is()처럼 인자 중 가장 높은 특이도를 취한다. 그리고 :has()는 자체적으로 특이도를 갖지 않지만 인자는 계산에 포함되니, 스타일 우선순위 디버깅할 때 이 부분을 놓치기 쉽다.

참고 :has()는 강력한 만큼 성능 얘기가 늘 따라붙는다. 결론부터 말하면 현대 브라우저에선 대부분 걱정 안 해도 된다. 다만 * :has(...)처럼 전체 셀렉터에 광범위하게 걸거나, 자주 바뀌는 동적 상태에 :has()를 남발하면 스타일 무효화 비용이 생길 수 있다. 무거워지면 범위를 좁은 조상으로 한정하는 게 정석이다.

컨테이너 쿼리 + :has() 합체 패턴

두 기능은 따로도 좋지만 같이 쓰면 표현력이 한 단계 올라간다. :has()로 \"콘텐츠 구성\"에 반응하고, @container로 \"가용 공간\"에 반응한다 — 축이 다르기 때문이다. 아래는 이미지가 있으면서 컨테이너가 넓을 때만 매거진 스타일 레이아웃으로 바꾸는 예다.

.article-card:has(.cover) {
  container-type: inline-size;
}

/* 커버 이미지가 있고 + 컨테이너가 충분히 넓을 때만 2단 매거진 */
@container (min-width: 480px) {
  .article-card:has(.cover) .body {
    display: grid;
    grid-template-columns: 1fr 1.4fr;
    gap: 24px;
  }
}

/* 태그가 3개 이상이면 태그 줄을 접기 */
.article-card:has(.tags > :nth-child(4)) .tags {
  max-height: 28px;
  overflow: hidden;
}

여기서 재밌는 건 이 모든 판단이 선언적이라는 거다. \"이미지 있음 + 넓음 → 이 레이아웃\"이라는 규칙을 CSS에 적어두면, 데이터가 어떻게 들어오든 브라우저가 매칭해준다. JS 조건 분기가 CSS로 내려온 셈이다.

폴백과 점진적 향상: 안 되는 브라우저는?

둘 다 @supports로 감지할 수 있다. 기본을 미디어쿼리나 세로 레이아웃으로 깔아두고, 지원되면 컨테이너 쿼리로 덧칠하는 점진적 향상이 가장 안전하다. 구형 브라우저 사용자에게도 최소한 깨지지 않는 화면을 준다.

/* 기본값: 컨테이너 쿼리 없어도 동작하는 세로 레이아웃 */
.product { display: grid; gap: 12px; }

@supports (container-type: inline-size) {
  .slot { container-type: inline-size; }
  @container (min-width: 320px) {
    .product { grid-template-columns: 120px 1fr; }
  }
}

@supports selector(:has(*)) {
  .card:has(img) { padding-top: 0; }
}

selector(:has(*)) 문법이 낯설 수 있는데, @supports에서 \"이 선택자를 파싱할 수 있냐\"를 묻는 방식이다. 함수형 슈도클래스 지원 감지엔 이걸 쓴다. 실무에선 이미 지원율이 높아서, 신규 프로젝트라면 폴백을 아예 생략하고 가는 팀도 많다. 레거시 지원 요구사항을 먼저 확인하고 결정하면 된다.

언제 미디어쿼리를 계속 써야 하나

컨테이너 쿼리가 미디어쿼리를 완전히 대체하는 건 아니다. 둘은 겨냥하는 문제가 다르다. 정리하면 이렇다.

상황추천이유
페이지 전체 골격(1단↔2단)@media화면 전체가 기준이라 뷰포트가 맞다
재사용 카드·위젯 내부 배치@container놓이는 위치마다 공간이 달라서
다크모드·모션 축소 등 환경 선호@mediaprefers-* 는 미디어쿼리 전용
콘텐츠 유무·상태 기반 스타일:has()DOM 구조·상태를 CSS로 조건 분기
인쇄 스타일@media print미디어 타입은 컨테이너 개념 밖

내 경험칙은 이렇다. 페이지는 미디어쿼리, 컴포넌트는 컨테이너 쿼리. 그리고 다크모드·prefers-reduced-motion 같은 사용자 환경 선호는 애초에 컨테이너 쿼리로 표현할 수 없으니 미디어쿼리 몫으로 남는다. 이 경계만 잡으면 헷갈릴 일이 거의 없다.

마이그레이션할 때 걸리는 함정들

기존 미디어쿼리 코드를 컨테이너 쿼리로 옮길 때 실제로 부딪힌 것들을 정리한다. 문서에 잘 안 나오는 것들 위주다.

  • container-type: inline-size를 준 요소는 가로 크기 격리 때문에, 자식이 부모보다 넓어져도 부모가 안 늘어난다. 가로 스크롤 컨테이너에 무심코 걸면 레이아웃이 이상해진다.
  • 컨테이너 자신에겐 @container가 안 먹는다. 항상 한 겹 감싸는 래퍼가 필요하다 — 위 예시의 .slot이 그 역할이다.
  • Flexbox/Grid 자식은 container-type을 걸면 min-width: auto 기본 동작과 얽혀 예상 밖 축소가 날 수 있다. 넘치면 min-width: 0을 같이 확인.
  • :has() 안에는 아직 또 다른 :has()를 중첩할 수 없다. 그리고 의사요소(::before 등)를 인자로 넣는 것도 지원되지 않는다.

자주 묻는 질문

컨테이너 쿼리를 쓰면 미디어쿼리는 이제 안 써도 되나요?

아니요. 둘은 대체 관계가 아니라 보완 관계입니다. 페이지 전체 골격과 다크모드·모션 축소 같은 사용자 환경 선호는 여전히 미디어쿼리로 처리하고, 재사용 컴포넌트 내부 레이아웃만 컨테이너 쿼리로 옮기는 조합이 실무 정답입니다.

container-type의 inline-size와 size는 뭐가 다른가요?

inline-size는 가로(인라인) 크기만 질의 대상으로 잡고, size는 가로와 세로 모두를 잡습니다. size는 컨테이너 높이를 콘텐츠와 무관하게 결정해야 순환 참조를 피할 수 있어서 실무 사용이 드뭅니다. 세로 스크롤 웹에서는 거의 항상 inline-size가 정답입니다.

:has()는 성능이 나쁘다는데 써도 되나요?

일반적인 사용에서는 문제없습니다. 현대 브라우저 엔진은 :has() 매칭을 최적화하고 있습니다. 다만 전역 선택자(*)에 광범위하게 걸거나 자주 바뀌는 동적 상태에 남발하면 스타일 무효화 비용이 생길 수 있으니, 가능한 한 좁은 조상 범위로 한정해서 쓰는 것이 좋습니다.

컨테이너 쿼리와 :has()는 지금 프로덕션에 써도 안전한가요?

2025년 기준 대부분의 프로젝트에서 안전합니다. Chrome, Safari, Firefox 모두 2023년 후반부터 두 기능을 안정적으로 지원하며 실사용 커버리지도 90% 후반대입니다. 레거시 브라우저 지원이 필요하다면 @supports로 폴백을 깔아 점진적 향상 방식으로 적용하세요.

cqi 단위가 값이 이상하게 나옵니다. 왜 그런가요?

cqi는 가장 가까운 등록된 컨테이너를 기준으로 계산됩니다. 조상 중에 container-type이 지정된 요소가 하나도 없으면 뷰포트를 기준으로 폴백하기 때문에 예상과 다른 값이 나옵니다. 부모 어딘가에 container-type: inline-size가 실제로 있는지부터 확인하세요.

왜 컨테이너 요소 자신에는 @container가 안 먹나요?

컨테이너로 등록된 요소는 자기 자신을 질의 대상으로 삼을 수 없고, 반드시 그 자식 요소에서 질의해야 하기 때문입니다. 순환 참조(자신의 스타일이 자신의 크기를 바꾸는)를 막기 위한 설계입니다. 그래서 컴포넌트를 감싸는 얇은 래퍼 요소를 하나 두고, 래퍼를 컨테이너로 등록하는 패턴을 씁니다.

:has()로 순수 CSS 모달·탭을 만들어도 될까요?

간단한 토글이나 상태 반영에는 훌륭합니다. 다만 포커스 트랩, 키보드 내비게이션, 스크린리더 알림 같은 접근성 요구가 있는 인터랙션은 CSS만으로 완결되지 않습니다. 시각적 상태 전환은 :has()에 맡기되, 접근성 관련 동작은 JS와 ARIA로 보강하는 것이 안전합니다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록