최신 CSS — has 선택자, Nesting, Subgrid, 그리고 새로운 가능성
CSS는 왜 부모 요소를 선택하지 못했을까? Sass 없이는 선택자 중첩도 못 하고, 카드 레이아웃에서 내부 요소의 높이를 맞추려면 항상 JavaScript가 필요했을까?
오랫동안 "CSS로는 안 된다"고 여겨졌던 것들이 하나둘 가능해지고 있습니다. :has(), Nesting, Subgrid 같은 기능들은 단순한 편의 기능이 아니라, CSS가 할 수 있는 것의 경계를 넓히는 변화입니다. 공부하면서 "이걸 진작 알았으면 코드가 훨씬 깔끔했을 텐데"라는 생각이 정말 많이 들었습니다.
1. :has() 선택자 — 드디어 부모를 선택할 수 있다
개념
:has()는 특정 자손(또는 형제)을 가진 요소를 선택 하는 관계형 의사 클래스입니다. CSS 역사상 처음으로 "자식의 상태에 따라 부모를 스타일링"할 수 있게 되었습니다.
/* .img를 자식으로 가진 .card만 선택 */
.card:has(.img) {
grid-template-rows: 200px 1fr;
}
기존에는 이런 조건부 스타일링을 하려면 JavaScript로 클래스를 토글하거나, HTML 구조 자체에 별도 클래스를 추가해야 했습니다. :has()는 그 수고를 CSS만으로 해결합니다.
실전 예제 1 — 빈 입력 필드 감지
/* 유효하지 않은 입력이 있는 폼 그룹에 경고 테두리 */
.form-group:has(input:invalid) {
border-left: 3px solid var(--color-error);
}
/* placeholder가 보이는 상태(= 빈 필드)인 입력의 라벨 스타일 변경 */
label:has(+ input:placeholder-shown) {
color: var(--color-muted);
}
+(인접 형제 결합자)와 함께 쓰면 형제 관계도 조건으로 활용할 수 있습니다.
실전 예제 2 — 이미지 유무에 따른 카드 레이아웃
/* 이미지가 있는 카드 — 가로 배치 */
.card:has(img) {
display: grid;
grid-template-columns: 200px 1fr;
gap: 1rem;
}
/* 이미지가 없는 카드 — 세로 배치 */
.card:not(:has(img)) {
display: flex;
flex-direction: column;
}
:not()과 조합하면 "포함하지 않는 경우"도 깔끔하게 처리됩니다.
실전 예제 3 — 전역 상태 기반 스타일링
/* 다크 모드 토글 체크박스가 체크되면 전체 페이지에 다크 테마 적용 */
html:has(#dark-mode-toggle:checked) {
color-scheme: dark;
--bg: #1a1a2e;
--text: #eaeaea;
}
JavaScript 없이 체크박스 상태만으로 테마를 전환하는 패턴입니다. 물론 실제로는 JS와 병행하는 경우가 많지만, CSS만으로도 가능하다는 점이 핵심이에요.
:has()는 성능에 주의가 필요합니다. 브라우저가 DOM 트리를 역방향으로 탐색해야 하기 때문에,*:has(...)같은 넓은 범위의 선택자는 피하는 게 좋습니다. 선택자를 최대한 구체적으로 작성하세요.
2. CSS Nesting — Sass 없이 네이티브 중첩
개념
CSS Nesting은 선택자를 중첩해서 작성할 수 있는 네이티브 CSS 문법 입니다. Sass에서 가장 많이 쓰던 기능이 드디어 CSS 표준이 되었습니다.
/* 기존 방식 — 선택자를 반복해서 작성 */
.card { background: white; }
.card .title { font-size: 1.2rem; }
.card .title:hover { color: blue; }
.card .body { padding: 1rem; }
/* CSS Nesting — 구조가 한눈에 보인다 */
.card {
background: white;
.title {
font-size: 1.2rem;
&:hover {
color: blue;
}
}
.body {
padding: 1rem;
}
}
& 기호의 역할
&는 바깥쪽 선택자를 참조합니다. 의사 클래스나 의사 요소를 연결할 때 필수적입니다.
.btn {
background: var(--color-primary);
transition: background 0.2s;
/* & = .btn → .btn:hover */
&:hover {
background: var(--color-primary-dark);
}
/* & = .btn → .btn::after */
&::after {
content: '→';
margin-left: 0.5rem;
}
/* & = .btn → .btn.active */
&.active {
font-weight: bold;
}
/* &를 뒤에 놓으면 — .dark-theme .btn */
.dark-theme & {
background: var(--color-dark-primary);
}
}
2024년 후반부터
&없이도 요소 선택자를 중첩할 수 있게 업데이트되었습니다. 예를 들어.card { h2 { ... } }가 가능합니다. 하지만 코드 의도를 명확하게 하려면&를 명시적으로 쓰는 습관이 좋습니다.
@media 중첩
미디어 쿼리도 선택자 안에 중첩할 수 있습니다. 관련 스타일이 한 곳에 모이니 가독성이 크게 좋아집니다.
.sidebar {
width: 100%;
padding: 1rem;
/* 이 선택자에 대한 반응형 스타일이 바로 아래에 */
@media (min-width: 768px) {
width: 300px;
padding: 2rem;
}
@media (min-width: 1200px) {
width: 400px;
}
}
기존에는 .sidebar 스타일과 미디어 쿼리 안의 .sidebar 스타일이 파일의 전혀 다른 위치에 흩어져 있었는데, 이제 한 블록 안에서 관리할 수 있습니다.
3. Subgrid — 부모 그리드 트랙을 자식이 상속
문제: 카드 내부 요소의 높이가 안 맞는다
카드 레이아웃에서 제목, 본문, 버튼 영역의 높이가 카드마다 다른 건 아주 흔한 문제입니다.
/* 일반 Grid — 카드 내부는 각자 독립적인 트랙 */
.card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
.card {
display: grid;
/* 각 카드가 자체적으로 행을 나눔 — 옆 카드와 높이가 안 맞음 */
grid-template-rows: auto 1fr auto;
}
이 방식에서는 한 카드의 제목이 2줄이고 다른 카드는 1줄이면, 본문 시작 위치가 달라집니다.
해결: subgrid
.card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
/* 부모에서 행 트랙도 명시적으로 정의 */
grid-template-rows: subgrid;
gap: 1.5rem;
}
.card {
display: grid;
/* 부모의 행 트랙을 그대로 사용 */
grid-template-rows: subgrid;
/* 이 카드가 차지할 행 범위 지정 */
grid-row: span 3;
}
subgrid를 사용하면 자식이 부모의 트랙 라인을 공유하기 때문에, 모든 카드에서 제목은 같은 높이, 본문은 같은 높이, 버튼은 같은 높이로 정렬됩니다.
실전 패턴
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 2rem;
}
.card {
display: grid;
grid-template-rows: subgrid;
grid-row: span 3; /* 헤더, 본문, 푸터 — 3개의 행 */
.card-header {
/* 첫 번째 행에 자동 배치 */
font-weight: bold;
}
.card-body {
/* 두 번째 행 — 남은 공간을 채움 */
align-self: start;
}
.card-footer {
/* 세 번째 행 — 항상 카드 하단에 고정 */
align-self: end;
}
}
Subgrid는 행 방향(
grid-template-rows: subgrid)과 열 방향(grid-template-columns: subgrid)을 독립적으로 설정할 수 있습니다. 둘 다 subgrid로 쓸 수도 있고, 한쪽만 쓸 수도 있어요.
4. @layer — 최신 기능과의 조합
@layer는 캐스케이드 레이어를 정의해서 스타일의 우선순위를 구조적으로 관리 하는 기능입니다. 이 블로그의 다른 글에서 기본 개념을 다뤘으니, 여기서는 최신 기능과의 조합에 집중하겠습니다.
@layer + CSS Nesting
/* 레이어 순서 선언 — 나중에 선언된 레이어가 우선 */
@layer base, components, utilities;
@layer base {
/* 리셋 + 기본 스타일 */
* {
margin: 0;
box-sizing: border-box;
}
}
@layer components {
.card {
border: 1px solid var(--color-border);
border-radius: 0.5rem;
/* Nesting으로 컴포넌트 스타일을 한 블록에 */
.title {
font-size: 1.25rem;
}
&:has(img) {
/* :has()와 조합하여 조건부 레이아웃 */
grid-template-columns: 200px 1fr;
}
}
}
@layer utilities {
/* 유틸리티가 항상 컴포넌트보다 우선 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
}
레이어 안에서 Nesting과 :has()를 함께 쓰면, 코드 구조와 우선순위 관리가 동시에 깔끔해집니다.
서드파티 CSS 격리
/* 외부 라이브러리 CSS를 낮은 우선순위 레이어에 배치 */
@layer vendor, app;
@import url('some-library.css') layer(vendor);
@layer app {
/* 내 스타일이 항상 라이브러리를 덮어쓴다 — !important 불필요 */
.datepicker {
border-radius: 0.75rem;
font-family: inherit;
}
}
!important 전쟁을 끝내는 구조적 해법이에요. 외부 CSS가 아무리 구체적인 선택자를 써도, 레이어 순서에 의해 내 스타일이 우선합니다.
5. color-mix() — 색상 혼합 함수
개념
color-mix()는 두 색상을 지정한 비율로 혼합 하는 함수입니다. CSS 변수와 결합하면 한 가지 기본색에서 여러 변형을 만들어낼 수 있습니다.
/* 기본 문법: color-mix(in 색공간, 색1 비율, 색2) */
.element {
/* oklch 색 공간에서 파랑 60% + 초록 40% 혼합 */
background: color-mix(in oklch, blue 60%, green);
}
실전 — CSS 변수 하나로 색상 팔레트 생성
:root {
--brand: #3b82f6;
/* 기본색에서 밝은/어두운 변형을 자동 생성 */
--brand-light: color-mix(in oklch, var(--brand) 40%, white);
--brand-dark: color-mix(in oklch, var(--brand) 70%, black);
/* 반투명 변형 */
--brand-subtle: color-mix(in srgb, var(--brand) 15%, transparent);
}
.badge {
background: var(--brand-subtle);
color: var(--brand-dark);
border: 1px solid var(--brand-light);
}
기존에는 각 변형 색상을 일일이 하드코딩하거나 Sass 함수(lighten(), darken())를 써야 했는데, 이제 네이티브 CSS만으로 가능합니다.
oklch를 쓰는 이유
/* sRGB에서의 혼합 — 중간 색이 탁하게 보일 수 있음 */
.srgb { background: color-mix(in srgb, blue 50%, yellow); }
/* oklch에서의 혼합 — 인간이 지각하는 밝기가 균일 */
.oklch { background: color-mix(in oklch, blue 50%, yellow); }
- sRGB: 전통적인 색 공간. 혼합 결과가 직관적이지 않을 때가 있음
- oklch: 지각적으로 균일한 색 공간. 밝기와 채도가 자연스럽게 보간됨
색상 팔레트를 만들 때는 oklch가 대부분의 경우 더 나은 결과를 줍니다.
6. @scope — 스코프 스타일링 (실험적)
개념
@scope는 스타일의 적용 범위를 특정 DOM 서브트리로 제한 하는 규칙입니다. BEM 네이밍이나 CSS Modules 없이도 스타일 충돌을 방지할 수 있습니다.
@scope (.card) {
/* .card 내부에서만 적용 — 외부의 .title에는 영향 없음 */
.title {
font-size: 1.2rem;
font-weight: bold;
}
.body {
line-height: 1.6;
}
}
하한 경계 (to)
@scope (.article) to (.comments) {
/* .article 내부이되, .comments 안은 제외 */
p {
font-size: 1.1rem;
color: var(--text-primary);
}
a {
color: var(--link-color);
text-decoration: underline;
}
}
to 키워드로 "여기까지만 적용" 이라는 하한 경계를 설정할 수 있습니다. 기사 본문에는 스타일을 적용하되 댓글 영역은 제외하는 식의 패턴이 가능해요.
스코프 근접성(Scope Proximity)
@scope (.light-theme) {
.btn { background: white; color: black; }
}
@scope (.dark-theme) {
.btn { background: black; color: white; }
}
같은 구체성을 가진 규칙이 충돌하면, DOM 트리에서 스코프 루트와 대상 요소 사이의 거리가 가까운 쪽 이 우선합니다. 테마 중첩 같은 시나리오에서 아주 유용합니다.
@scope는 2026년 기준 아직 실험적 기능입니다. Chrome은 지원하지만 Firefox와 Safari는 제한적입니다. 프로덕션에서는@supports로 감싸서 사용하세요.
7. 브라우저 지원 현황과 폴백 전략
2026년 3월 기준 지원 현황
| 기능 | Chrome | Firefox | Safari | 활용도 |
|---|---|---|---|---|
:has() | 105+ | 121+ | 15.4+ | 안정 |
| CSS Nesting | 120+ | 117+ | 17.2+ | 안정 |
| Subgrid | 117+ | 71+ | 16.0+ | 안정 |
@layer | 99+ | 97+ | 15.4+ | 안정 |
color-mix() | 111+ | 113+ | 16.2+ | 안정 |
@scope | 118+ | 제한적 | 제한적 | 실험적 |
:has(), Nesting, Subgrid, @layer, color-mix()는 모든 주요 브라우저에서 지원되므로 프로덕션에서 안심하고 쓸 수 있습니다.
@supports — CSS 기능 감지
/* 기본 폴백 스타일 */
.card {
display: flex;
flex-direction: column;
}
/* Subgrid를 지원하면 업그레이드 */
@supports (grid-template-rows: subgrid) {
.card {
display: grid;
grid-template-rows: subgrid;
grid-row: span 3;
}
}
@supports selector() — 선택자 지원 감지
/* :has() 지원 여부를 확인 */
@supports selector(:has(*)) {
.form-group:has(input:invalid) {
border-color: red;
}
}
/* :has()를 지원하지 않는 브라우저용 폴백 */
@supports not selector(:has(*)) {
.form-group.has-error {
border-color: red;
}
}
@supports selector()를 사용하면 선택자 단위로 기능을 감지할 수 있습니다.
점진적 향상 전략 정리
/* 1단계: 모든 브라우저에서 동작하는 기본 스타일 */
.layout {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
/* 2단계: Grid를 지원하면 Grid로 업그레이드 */
@supports (display: grid) {
.layout {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
}
/* 3단계: Subgrid를 지원하면 정렬 개선 */
@supports (grid-template-rows: subgrid) {
.layout .card {
display: grid;
grid-template-rows: subgrid;
grid-row: span 3;
}
}
/* 4단계: :has()를 지원하면 조건부 스타일 추가 */
@supports selector(:has(*)) {
.layout .card:has(img) {
grid-column: span 2;
}
}
핵심은 기본 스타일은 어디서든 동작하게 하고, 최신 기능은 지원하는 브라우저에서만 추가 하는 것입니다. 이렇게 하면 구형 브라우저에서도 콘텐츠 접근에 문제가 없고, 최신 브라우저에서는 더 나은 경험을 제공합니다.
마무리 — CSS가 변하고 있다
정리하면 이런 흐름입니다.
- :has() — JavaScript 없이 부모/형제 관계 기반 조건부 스타일링
- Nesting — Sass 없이 선택자 중첩, 미디어 쿼리 중첩
- Subgrid — 부모-자식 그리드 간 트랙 공유로 정렬 문제 해결
- @layer — 캐스케이드 우선순위를 구조적으로 관리
- color-mix() — 네이티브 색상 혼합과 팔레트 생성
- @scope — DOM 서브트리 단위의 스타일 격리
공부하다 보니 느끼는 건, 예전에 "CSS 전처리기 없으면 안 돼"라고 생각했던 기능들이 이제 브라우저에 내장되어 있다는 점입니다. 물론 Sass가 완전히 불필요해진 건 아니지만, 순수 CSS만으로 할 수 있는 영역이 확실히 넓어졌습니다.
새 프로젝트를 시작한다면 @supports로 점진적 향상을 적용하면서 이 기능들을 적극적으로 써보는 걸 추천합니다.