이 글은 누구를 위한 것인가
- 미디어 쿼리 기반 반응형 CSS의 한계를 느끼는 프론트엔드 개발자
- Sass/SCSS 없이도 네이티브 CSS만으로 강력한 스타일링을 하고 싶은 분
- 서드파티 CSS 라이브러리와 충돌하는 스타일 문제를 겪고 있는 팀
들어가며
"CSS가 드디어 쓸만해졌다."
몇 년 전까지만 해도 CSS는 한계가 명확한 언어였다. 부모 선택이 안 되고, 컴포넌트 자체 크기 기반 스타일링이 안 되고, 스타일 우선순위 충돌은 !important와 선택자 특이성 전쟁으로 해결해야 했다.
2024~2025년 사이 이 상황이 극적으로 바뀌었다. Container Queries, :has() 선택자, Cascade Layers, CSS Nesting이 모든 주요 브라우저(Chrome, Firefox, Safari, Edge)에서 지원되기 시작했다. Sass가 해결해주던 문제들을 이제 네이티브 CSS로 해결할 수 있다.
1. Container Queries: 부모 크기에 반응하는 컴포넌트
미디어 쿼리(Media Query)는 뷰포트(viewport) 크기를 기준으로 한다. 문제는 현실에서 컴포넌트가 놓이는 위치와 크기가 고정적이지 않다는 것이다.
<!-- 메인 콘텐츠 영역의 카드: 넓다 -->
<main>
<ProductCard />
</main>
<!-- 사이드바의 카드: 좁다 -->
<aside>
<ProductCard />
</aside>
같은 ProductCard 컴포넌트인데, 놓인 위치에 따라 다른 스타일이 필요하다. 미디어 쿼리는 뷰포트 기준이라 이 상황을 처리하기 어렵다.
Container Query 사용:
/* 카드를 감싸는 컨테이너 정의 */
.card-container {
container-type: inline-size;
container-name: card; /* 선택사항 */
}
/* 컨테이너 크기가 500px 이상일 때 */
@container card (min-width: 500px) {
.product-card {
display: grid;
grid-template-columns: 200px 1fr;
}
.product-card img {
height: 100%;
}
}
/* 컨테이너 크기가 300px 미만일 때 */
@container card (max-width: 300px) {
.product-card {
flex-direction: column;
}
.product-card img {
width: 100%;
}
}
이제 ProductCard는 뷰포트가 아닌 자신이 담긴 컨테이너 크기에 반응한다. 메인 콘텐츠에서는 가로 배치, 사이드바에서는 세로 배치가 자동으로 된다. 하나의 컴포넌트 CSS 코드로.
Container Query Units
/* cqw: 컨테이너 너비의 1% */
.card-title {
font-size: clamp(14px, 3cqw, 24px); /* 컨테이너 크기에 비례하는 폰트 */
}
2. :has() 선택자: CSS에서 드디어 부모를 선택할 수 있다
CSS에서 "자식 요소를 기준으로 부모를 선택"하는 것은 오랫동안 불가능했다. JavaScript로 처리하거나, 부모에 별도 클래스를 추가해야 했다. :has()가 이것을 가능하게 한다.
/* 체크박스가 선택된 경우 부모 label 스타일 변경 */
label:has(input[type="checkbox"]:checked) {
background: #e8f5e9;
font-weight: bold;
}
/* img 태그를 포함하는 figure */
figure:has(img) {
border: 1px solid #ddd;
padding: 8px;
}
/* 에러가 있는 폼 필드 그룹 */
.form-group:has(.error-message) {
background: #fff0f0;
}
/* 자식이 없는 컨테이너 숨기기 */
.sidebar:has(:not(*)) {
display: none; /* 사이드바가 비어있으면 숨김 */
}
실용적인 활용 예: 드롭다운 메뉴
/* 하위 메뉴가 있는 nav 아이템에 화살표 표시 */
nav li:has(ul) > a::after {
content: ' ▾';
}
/* 하위 메뉴가 펼쳐진 경우 화살표 방향 변경 */
nav li:has(ul:hover) > a::after {
content: ' ▴';
}
JavaScript 없이 순수 CSS만으로 드롭다운 상태에 따른 스타일 변경이 가능해진다.
3. Cascade Layers: 스타일 충돌의 종말
CSS 우선순위 충돌은 모든 프론트엔드 개발자가 겪는 문제다. !important를 쓰거나, 선택자를 더 구체적으로 만들어 우선순위를 높이는 "특이성 전쟁"이 일어난다.
Cascade Layers는 스타일시트를 명확한 계층으로 나눠, 계층 간 우선순위를 명시적으로 정의한다.
/* 계층 순서 선언 (앞에 선언할수록 우선순위 낮음) */
@layer reset, base, components, utilities;
/* reset 계층: 브라우저 기본 스타일 초기화 */
@layer reset {
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
}
}
/* base 계층: 기본 타이포그래피, 색상 */
@layer base {
body {
font-family: 'Pretendard Variable', sans-serif;
color: #1a1a1a;
}
}
/* components 계층: 재사용 컴포넌트 */
@layer components {
.button {
padding: 8px 16px;
border-radius: 4px;
background: blue;
color: white;
}
}
/* utilities 계층: 단일 목적 유틸리티 (최고 우선순위) */
@layer utilities {
.mt-4 { margin-top: 16px; }
.text-red { color: red; }
}
계층이 선언된 순서가 우선순위를 결정한다. utilities가 마지막에 선언됐으므로, components의 스타일을 !important 없이 덮어쓸 수 있다.
서드파티 라이브러리 충돌 해결
/* 서드파티 CSS를 낮은 계층에 가두기 */
@layer vendors {
@import url("bootstrap.css");
}
/* 우리 스타일이 항상 bootstrap을 덮어씀 */
@layer components {
.button { /* bootstrap의 .button보다 이 스타일이 항상 우선 */ }
}
4. CSS Nesting: Sass 없이 중첩 선택자
Sass/SCSS의 가장 편리한 기능 중 하나가 중첩 선택자다. 이제 네이티브 CSS에서도 가능하다.
/* 기존 CSS */
.card { background: white; }
.card:hover { background: #f5f5f5; }
.card .title { font-size: 20px; }
.card .title a { color: blue; }
.card .title a:hover { color: darkblue; }
/* CSS Nesting */
.card {
background: white;
&:hover {
background: #f5f5f5;
}
.title {
font-size: 20px;
a {
color: blue;
&:hover {
color: darkblue;
}
}
}
}
컴포넌트 단위로 스타일을 한 블록에 모을 수 있어 가독성이 크게 향상된다.
미디어 쿼리도 중첩 가능
.container {
width: 100%;
@media (min-width: 768px) {
width: 750px;
}
@media (min-width: 1200px) {
width: 1170px;
}
}
5. 실전 예시: 카드 컴포넌트를 Container Query로 설계
이 모든 기능을 조합한 실전 예시다.
/* 계층 구조 선언 */
@layer reset, base, components;
@layer components {
/* 카드 컨테이너 */
.card-wrapper {
container-type: inline-size;
container-name: card;
}
/* 카드 기본 스타일 */
.card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border-radius: 8px;
background: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
/* 이미지가 있는 카드 스타일 */
&:has(img) {
padding: 0;
.card-content {
padding: 16px;
}
}
/* 선택된 카드 강조 */
&:has(input:checked) {
outline: 2px solid blue;
}
/* 넓은 컨테이너: 가로 배치 */
@container card (min-width: 500px) {
flex-direction: row;
align-items: center;
img {
width: 200px;
height: 150px;
object-fit: cover;
border-radius: 8px 0 0 8px;
}
}
}
}
6. 브라우저 지원 현황과 Progressive Enhancement
| 기능 | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Container Queries | 105+ | 110+ | 16+ | 105+ |
:has() | 105+ | 121+ | 15.4+ | 105+ |
| Cascade Layers | 99+ | 97+ | 15.4+ | 99+ |
| CSS Nesting | 120+ | 117+ | 17.2+ | 120+ |
2026년 기준으로 한국 사용자의 95%+ 이상이 지원 브라우저를 사용한다. IE 지원이 필요하지 않다면 모두 사용 가능하다.
구형 브라우저 대비 Progressive Enhancement:
/* 기본 스타일 (모든 브라우저) */
.card { flex-direction: column; }
/* Container Query 지원 브라우저에서만 */
@supports (container-type: inline-size) {
.card-wrapper { container-type: inline-size; }
@container (min-width: 500px) {
.card { flex-direction: row; }
}
}
맺으며
지난 10년간 CSS의 한계를 Sass, CSS-in-JS, 여러 도구들이 보완해왔다. 이제 많은 부분에서 도구 없이 네이티브 CSS만으로 해결할 수 있다.
당장 모든 프로젝트에서 Sass를 제거할 필요는 없다. 하지만 새로 시작하는 프로젝트라면 네이티브 CSS만으로 시작해보자. Container Query로 진정한 컴포넌트 기반 반응형 설계를 해보고, :has()로 JavaScript 없이 상태를 표현하고, Cascade Layers로 스타일 우선순위를 명확히 관리해보자.
CSS가 이렇게 강력해진 시대에, 이전 방식에만 머물 이유가 없다.