미디어 쿼리로는 안 되던 것 — @container 로 부모가 정한 값에 반응하기

  • 8th September 2026
  • 10 min read

같은 카드 부품이 본문에도 들어가고, 좁은 사이드바에도 들어가고, 어두운 배너 안에도 들어간다. 자리마다 조금씩 달라야 한다. 사이드바에서는 설명을 감추고, 배너 안에서는 글자를 밝게.

화면 크기로 갈라 볼까 싶지만 그건 답이 아니다. 미디어 쿼리는 화면만 본다. 넓은 데스크톱 화면에서도 사이드바는 여전히 좁고, 같은 화면 안에서 본문에 있는 카드와 사이드바에 있는 카드는 구분되지 않는다. 부품이 알아야 하는 것은 "창이 몇 픽셀인가"가 아니라 "나는 지금 어디에 담겨 있는가"인데, 미디어 쿼리는 그 질문에 답할 수 없다.

그래서 이걸 처리하는 방법은 오랫동안 하나였다. 변형 클래스를 부모가 붙여준다.

<aside class="sidebar">
  <div class="card card--compact">…</div>
</aside>

<section class="banner">
  <div class="card card--on-dark">…</div>
</section>

동작은 한다. 대신 부모가 자식의 사정을 알아야 한다. 사이드바 템플릿이 "카드에는 card--compact가 있다"는 것을 알고 있어야 하고, 카드 안에 또 다른 부품이 들어가면 그 부품의 변형 클래스도 같이 뿌려야 한다. 자리가 세 종류, 부품이 네 종류면 조합을 손으로 관리하게 된다.

이제 부모는 값 하나만 세워두고 빠질 수 있다.

.sidebar { --density: compact; }

@container style(--density: compact) {
  .card__desc { display: none; }
  .card__title { font-size: 1rem; }
}

사이드바는 --density: compact라고만 말한다. 카드 클래스도, 카드 안에 무엇이 있는지도 모른다. 반응은 각 부품이 자기 CSS에서 스스로 한다.

style() 쿼리가 2026년 5월 19일 파이어폭스 151로 마지막 브라우저를 채우면서 Baseline에 들어왔다. 다만 지금 쓸 수 있는 것은 문법의 절반쯤이고, 그 경계가 문서만 봐서는 잘 보이지 않는다. 이 글은 그 경계에 대한 것이다.

@container는 이름 하나에 갈래가 둘이다

먼저 정리해야 할 혼동이 있다. @container로 시작하는 규칙은 두 종류인데, 서로 다른 시기에 들어왔고 성질도 다르다.

@container (width > 500px) { … }        /* 크기 쿼리 */
@container style(--density: compact) { … }  /* 스타일 쿼리 */
크기 쿼리 스타일 쿼리
무엇에 반응컨테이너의 치수컨테이너의 커스텀 프로퍼티 값
Baseline2023-02-14 (newly)
2025-08-14부터 widely
2026-05-19 (newly)
마지막으로 합류한 브라우저파이어폭스 110파이어폭스 151
container-type반드시 선언필요 없음

크기 쿼리는 이미 3년 넘은 기능이고 널리 자리 잡았다. 새로 들어온 것은 스타일 쿼리 쪽이다. 크롬은 2023년 3월(111), 사파리는 2024년 9월(18)에 이미 넣었고, 파이어폭스가 2026년 5월에 합류하면서 셋이 채워졌다.

기능 소개 글이 둘을 "컨테이너 쿼리"로 묶어 부르는 일이 잦은데, 지원 상황이 3년 차이가 난다. "컨테이너 쿼리는 이제 안전하다"는 말은 크기 쿼리에 대해서는 오래전부터 맞는 말이었고, 스타일 쿼리에 대해서는 이제 막 맞는 말이 되었다.

스타일 쿼리는 컨테이너를 선언하지 않는다

크기 쿼리를 써 본 사람은 container-type을 붙이는 데 익숙하다. 그래서 스타일 쿼리에도 붙여야 하는 줄 알기 쉬운데, 필요 없다. 명세가 이렇게 적어 두었다.

기본적으로 모든 요소는 컨테이너 스타일 쿼리의 목적에서 쿼리 컨테이너다. (By default, all elements are query containers for the purpose of container style queries…)

container-type: normal—즉 기본값—도 마찬가지다. 명세는 "크기 쿼리나 스크롤 상태 쿼리의 쿼리 컨테이너는 아니지만, 컨테이너 스타일 쿼리의 쿼리 컨테이너로는 남는다"고 못 박는다. 아무것도 선언하지 않은 <div>가 이미 스타일 컨테이너다.

이건 편의의 문제가 아니라 부작용을 피하는 문제다. 크기 쿼리에서 container-type을 선언하면 그 요소에 containment가 걸린다. MDN의 설명은 이렇다.

크기 쿼리 컨테이너를 선언하면 containment가 추가된다. 이것은 성능상 필요한 조치다 — DOM의 모든 요소 크기를 항상 질의하는 것은 성능과 사용자 경험에 나쁘다. 또한 후손의 스타일이 컨테이너 요소의 크기를 바꾼다면 무한 루프가 생길 수 있다.

필요한 조치지만 대가가 있다. container-type: size를 준 요소는 자식의 크기를 더 이상 자기 크기에 반영하지 않는다.

.box { container-type: size; }
.box > p { height: 100px; }

크롬 152에서 재어 보면 .box의 높이는 0px이다. 안에 100px짜리 문단이 들어 있는데도 그렇다. 크기 쿼리를 처음 쓸 때 레이아웃이 무너지는 대부분의 사연이 여기서 나온다(그래서 실무에서는 세로는 포기하고 inline-size만 쓰는 쪽이 관행이 됐다).

스타일 쿼리에는 이 대가가 없다. 커스텀 프로퍼티 값은 후손이 바꿔도 조상의 레이아웃에 되먹임되지 않으니 containment를 걸 이유가 없다. 선언도 필요 없고 레이아웃도 건드리지 않는다. 도입 비용이 사실상 0인 셈이다.

함정 1 — 컨테이너 자신은 바뀌지 않는다

가장 먼저, 그리고 가장 자주 걸리는 자리다. 이 코드는 동작하지 않는다.

.card { --variant: hero; }

@container style(--variant: hero) {
  .card { padding: 32px; }   /* ← 걸리지 않는다 */
}

스타일 쿼리는 컨테이너의 후손에게 적용되는 규칙을 조건부로 만든다. 컨테이너 자신은 대상이 아니다. 명세의 표현으로는 "쿼리 컨테이너의 flat tree 후손에 적용되는 스타일 규칙"이다. 크롬 152와 사파리 26.5에서 똑같이 확인된다 — 자신에게는 안 걸리고, 바로 아래 자식부터 걸린다.

변수를 선언하는 자리와 반응하는 자리가 같은 요소일 수 없다. 그래서 부품 스스로 모습을 바꾸게 하려면 껍데기가 하나 더 필요하다.

✗ 변수와 반응이 같은 요소 .card --variant: hero 자기 자신은 대상이 아니다 .card__title 여기는 걸린다 카드의 여백·배경은 못 바꾸고 안쪽 글자만 바뀌어 어중간해진다. ✓ 껍데기가 변수를 든다 .card-slot --variant: hero .card 여기부터 걸린다 껍데기는 자리를 뜻하는 요소라 대개 레이아웃에 이미 있다. 변수를 선언하는 요소와 그 값에 반응하는 요소는 언제나 조상과 후손 관계여야 한다.
스타일 쿼리가 미치는 범위 — 컨테이너 자신은 빠진다

다행히 실제 레이아웃에는 그 껍데기가 이미 있는 경우가 많다. 그리드 셀, 슬롯, 리스트 항목 같은 "자리"를 뜻하는 요소가 변수를 들면 된다. 맨 앞의 예시에서 .sidebar가 그 역할을 한 것이다. 부품이 자기 자신을 스스로 바꾸는 그림보다, 자리가 성격을 선언하고 부품이 읽는 그림이 더 자연스럽다.

함정 2 — 커스텀 프로퍼티만 된다

명세와 MDN 문서는 style() 안에 일반 CSS 속성도 쓸 수 있는 것처럼 적혀 있다. 실제로 MDN 예시에는 이런 것이 실려 있다.

@container style(color: green) and style(background-color: transparent) { … }

문법으로는 맞다. 그런데 일반 속성을 조건으로 쓰는 스타일 쿼리는 아직 어느 브라우저에도 구현되어 있지 않다. 크롬 152, 사파리 26.5 양쪽에서 style(font-weight: bold)는 조건이 참인 상황에서도 걸리지 않는다.

Baseline "newly available" 판정이 가리키는 것은 커스텀 프로퍼티에 대한 스타일 쿼리뿐이다. 문서를 읽고 style(display: flex) 같은 것을 기대하고 들어갔다가 아무 일도 일어나지 않는 것을 보게 되는 자리다.

실용적으로는 큰 제약이 아니다. 어차피 조건으로 삼고 싶은 것은 "이 자리는 조밀한가", "어두운 배경 위인가" 같은 의도이지 font-weight 값이 아니다. 의도에 이름을 붙여 변수로 세우는 편이 읽기도 낫다.

함정 3 — 값은 계산된 뒤 비교되지만, 등록하지 않으면 토큰 비교다

등록되지 않은 커스텀 프로퍼티의 값은 브라우저에게 그냥 토큰 뭉치다. 색인지 숫자인지 모른다. 그래서 이런 일이 생긴다.

.box { --c: blue; }

@container style(--c: #0000ff) { … }   /* 거짓 */

blue#0000ff는 같은 색이지만 같은 토큰이 아니다. MDN도 이 점을 명시한다 — "커스텀 프로퍼티가 blue 값을 가질 때, @property로 색이라고 정의해 두지 않는 한 같은 값인 16진수 #0000ff는 일치하지 않는다."

등록하면 달라진다.

@property --c {
  syntax: "<color>";
  inherits: true;
  initial-value: black;
}

.box { --c: blue; }

@container style(--c: #0000ff) { … }   /* 참 */

크롬 152와 사파리 26.5 양쪽에서 그대로 확인된다. 등록 전에는 불일치, 등록 후에는 일치다.

같은 이유로 계산식도 걸린다. --n: calc(6/2)일 때 style(--n: 3)거짓이다. 평문 형태는 계산된 값을 문자 그대로 비교하는데, 등록되지 않은 프로퍼티의 계산된 값은 여전히 calc(6/2)라는 토큰이기 때문이다.

여기서 나오는 실무 규칙은 단순하다. 스타일 쿼리로 주고받는 값은 계산하지 말고 이름표처럼 쓴다. --density: compact, --surface: dark처럼 정해진 낱말 몇 개를 쓰면 표기가 흔들릴 일이 없다. 숫자나 색을 조건으로 삼아야 한다면 @property 등록을 함께 가져가야 한다.

함정 4 — 범위 문법은 아직 크롬에만 있다

명세에는 부등호로 비교하는 형태도 있다.

@container style(--columns >= 3) { … }
@container style(--n = 3) { … }

범위 형태는 평문 형태와 달리 양쪽을 숫자로 계산한 뒤 비교한다. 그래서 앞에서 거짓이던 --n: calc(6/2)style(--n = 3)으로 쓰면 참이 된다. 등록 없이도 숫자 비교가 되는 셈이라 편리해 보인다.

다만 이건 Baseline 판정에 포함되지 않은 나중 추가분이다. 두 브라우저에서 같은 문서를 열어 재 보면 갈린다.

조건 크롬 152 사파리 26.5
style(--variant: hero) 평문 형태걸림걸림
style(--flag) 불리언 형태걸림걸림
style(--n = 3) 범위 형태걸림안 걸림
style(--cols > 2) 범위 형태걸림안 걸림

사파리에서 조건이 통째로 거짓이 되므로, 안쪽 규칙이 조용히 사라진다. 오류도 경고도 없다. "Baseline newly available"이 문법 전체를 보장하지 않는다는 것을 보여주는 사례다. 기능 단위의 판정은 대표 문법을 기준으로 매겨지고, 나중에 붙은 문법은 자기 시계로 따로 간다.

값이 없을 때 참이 되는 불리언 형태는 양쪽 다 된다. 이건 유용하다.

@container style(--compact) { … }   /* --compact 에 값이 있으면 참 */

정확히는 "계산된 값이 초깃값과 다르면 참"이다. 등록하지 않은 프로퍼티의 초깃값은 보장된 무효값이므로, 아무것도 선언하지 않았으면 거짓, 무엇이든 선언했으면 참이 된다. 켜고 끄는 스위치로 쓰기 좋다.

상속이 함께 딸려 온다

미디어 쿼리와 감각이 가장 다른 부분이다. 커스텀 프로퍼티는 상속되므로, 변수를 세운 지점부터 아래로 전부가 조건 안에 들어간다. 자식만이 아니라 손자, 증손자까지 간다.

대개는 이게 바라는 동작이다. 사이드바가 --density: compact라고 말하면 그 안의 모든 깊이의 부품이 조밀해져야 맞다. 문제는 중간에 예외를 두고 싶을 때인데, 방법은 변수를 다시 세우는 것이다.

.sidebar { --density: compact; }

/* 이 안쪽만 조밀함에서 빠진다 */
.sidebar .feature { --density: initial; }

중간에서 값을 바꾸면 그 아래부터는 가까운 쪽이 이긴다. 바깥이 loud, 중간이 quiet일 때 안쪽 요소에는 quiet 규칙이 걸린다. 조건에 맞는 조상 전부가 아니라, 가장 가까운 값 하나가 판정 기준이다.

바깥쪽 값을 기준으로 삼아야 한다면 컨테이너에 이름을 붙인다.

.layout { container-name: shell; --tone: loud; }
.panel  { --tone: quiet; }

@container shell style(--tone: loud) { … }   /* 중간의 quiet 를 건너뛴다 */

이름을 지정하면 그 이름을 가진 조상까지 올라가서 값을 읽는다. 중간에 같은 변수를 덮어쓴 요소가 있어도 무시된다. 레이아웃 전역의 성격과 지역의 성격을 같은 변수 이름으로 다루고 싶을 때 쓸 만하다.

폴백 — 감지할 방법이 마땅치 않다

새 CSS 기능을 쓸 때 흔히 @supports로 갈라 쓰는데, 스타일 쿼리에는 이 방법이 잘 듣지 않는다.

CSS.supports('at-rule(@container)')                  // true — @container 규칙의 존재만 확인
CSS.supports('at-rule(@container, style(--x: 1))')   // false — 크롬 152 에서도 false
CSS.supports('@container style(--x: 1)')             // false

첫 줄은 크기 쿼리 시절부터 참이라 아무것도 구분해 주지 못한다. 아래 둘은 스타일 쿼리를 지원하는 크롬 152에서도 거짓이라 지원 여부를 판별하는 데 쓸 수 없다. 범위 문법처럼 브라우저마다 갈리는 문법은 더 말할 것도 없다.

그래서 폴백은 감지가 아니라 순서로 만든다. 기본 선언을 "쿼리가 안 걸렸을 때의 모습"으로 두고, 쿼리 안에서 덮어쓴다.

/* 어디서든 성립하는 기본값 */
.card__desc { display: block; }

/* 지원하는 곳에서만 덮인다 */
@container style(--density: compact) {
  .card__desc { display: none; }
}

지원하지 않는 브라우저는 @container 블록을 통째로 무시하므로 기본값이 남는다. 조밀해지지는 않지만 깨지지도 않는다. 반대로 기본값 쪽에 "조밀한 모습"을 두고 쿼리로 되돌리려 하면, 지원하지 않는 곳에서 전부 조밀하게 보인다. 어느 쪽을 기본으로 둘지가 폴백 설계의 전부다.

바꿔 말하면 스타일 쿼리는 있으면 좋은 층에만 쓴다. 없으면 읽을 수 없어지는 것—본문 색과 배경의 대비 같은 것—을 쿼리 안쪽에만 넣으면 안 된다. 어두운 배경 위에서 글자를 밝게 만드는 규칙이 쿼리 안에만 있으면, 지원하지 않는 브라우저에서 어두운 글자가 어두운 배경 위에 남는다. KWCAG 2.2의 5.4.3 텍스트 콘텐츠의 명도 대비는 4.5:1을 요구하는데, 이런 구조에서는 그 검사가 브라우저에 따라 통과와 실패로 갈린다. 배경을 어둡게 하는 선언과 글자를 밝게 하는 선언은 같은 조건 안에 함께 두어야 한다.

정리

  • 미디어 쿼리는 화면을 보고, 컨테이너 쿼리는 담긴 곳을 본다. 그중 스타일 쿼리가 보는 것은 담긴 곳의 크기가 아니라 담긴 곳이 세운 값이다.
  • @container에는 갈래가 둘이다. 크기 쿼리는 2023년부터 널리 쓸 수 있었고, 스타일 쿼리가 2026년 5월 19일에 Baseline에 들어왔다.
  • 스타일 쿼리는 container-type이 필요 없다. 모든 요소가 이미 스타일 컨테이너이고, 크기 쿼리가 부르는 containment 부작용도 없다.
  • 컨테이너 자신에게는 걸리지 않는다. 변수를 세우는 요소와 반응하는 요소는 조상·후손 관계여야 한다. "자리"를 뜻하는 요소가 변수를 들게 한다.
  • 조건으로 쓸 수 있는 것은 커스텀 프로퍼티뿐이다. 일반 속성을 쓴 style()은 문법상 유효해도 어디서도 동작하지 않는다.
  • 값은 이름표처럼 쓴다. 숫자나 색으로 비교하려면 @property 등록이 필요하다.
  • 범위 문법은 아직 크롬만 지원한다. 사파리에서는 조건이 조용히 거짓이 된다.
  • 지원 여부를 감지할 수단이 없으므로, 기본값을 폴백으로 두고 쿼리 안에서 덮어쓴다.

변형 클래스를 부모가 뿌리는 구조는 부품이 몇 개 안 될 때는 문제가 없다. 그러다 자리가 늘고 부품이 중첩되기 시작하면 조합이 손을 벗어난다. 그때 필요한 것은 부모가 자식을 지목하지 않고 성격만 선언하는 방법이고, 스타일 쿼리가 그걸 CSS 안에서 해준다. 다만 위의 네 가지 경계는 문서만 읽어서는 잘 보이지 않으니, 쓰기 전에 한 번씩 짚어 두는 편이 낫다.

참고