선언형 CSS가 JavaScript를 대체하기 시작했다
프론트엔드 개발자라면 한 번쯤 이런 경험이 있을 것이다. 폼 필드 상태를 바꾸려고 classList.toggle을 붙이고, 사이드바 너비를 조정하려고 ResizeObserver를 걸고, 모바일 풀스크린을 맞추려고 window.innerHeight를 계산하는—그 번거로운 루틴. 그런데 2024~2025년을 거치며 브라우저에 안착한 세 가지 CSS 기능이 이 루틴을 조용히 무너뜨리고 있다. :has(), Container Queries, 그리고 dvh/lvh/svh. 단순한 신규 스펙이 아니라, 컴포넌트 UI 로직을 선언적으로 표현하는 방식 자체를 바꾸는 패러다임 전환이다.
1. :has() — 부모가 자식을 보고 스스로 바뀐다
dev.to의 분석에 따르면, :has()는 지난 10년 간 CSS 아키텍처에서 가장 의미 있는 변화로 꼽힌다. 흔히 '부모 선택자'라고 불리지만, 본질은 더 넓다. 자식의 상태를 기반으로 부모(혹은 앞선 형제)의 스타일을 바꿀 수 있다는 것—이 한 줄이 기존 CSS의 단방향 계층 구조를 깬다.
실무에서 가장 체감되는 지점은 폼 검증이다. 기존에는 인풋이 :invalid 상태일 때 부모 fieldset에 빨간 테두리를 주려면 JavaScript로 클래스를 토글해야 했다. 이제는 fieldset:has(input:invalid:not(:focus))만으로 충분하다. CMS나 백엔드에서 card--with-image 같은 수동 클래스를 관리하던 카드 레이아웃도 마찬가지다. img 태그의 존재 여부를 CSS가 직접 감지해 grid-template-areas를 바꾼다.
더 흥미로운 건 :not(), :checked 같은 다른 의사 클래스와 조합할 때다. .container:has(input[type="checkbox"]:not(:checked))처럼 '체크되지 않은 항목이 하나라도 있는 컨테이너'를 타깃하는 논리 게이트를 CSS 안에서 구성할 수 있다. 메가 메뉴 호버 시 배경 딤 처리, 푸터 없는 위젯의 스타일 분기—is-active, is-open 같은 상태 클래스를 수동으로 붙이던 패턴이 설 자리를 잃어간다. 2024~2025년 기준 모든 메이저 에버그린 브라우저에서 지원되므로, 프로덕션 적용을 주저할 이유가 없다.
2. Container Queries — 컴포넌트가 자기 공간을 스스로 안다
10년 넘게 반응형 디자인은 뷰포트 너비에 묶여 있었다. 미디어 쿼리는 '브라우저 창이 768px보다 넓으면'이라는 전역 조건이었고, 같은 카드 컴포넌트가 사이드바에 들어가느냐 메인 콘텐츠 영역에 들어가느냐에 따라 전혀 다른 CSS 오버라이드를 관리해야 했다. Container Queries는 이 구조를 뒤집는다. 뷰포트가 아니라 컨테이너의 크기를 기준으로 컴포넌트 자신이 스타일을 결정한다.
설정 방식은 간단하다. 부모 요소에 container-type: inline-size를 선언해 컨테이너로 지정하고, @container (min-width: 400px) { ... } 규칙으로 쿼리를 작성하면 된다. 컴포넌트는 이제 어떤 레이아웃에 배치되든 자기 공간에 맞게 스스로 변형된다.
디자인 시스템 관점에서 이 변화의 의미는 크다. 기존에는 컴포넌트의 반응형 동작이 '페이지 템플릿'에 종속됐다. 대시보드용 카드와 랜딩용 카드가 사실상 다른 컴포넌트였다. Container Queries 이후에는 하나의 컴포넌트가 컨텍스트를 스스로 읽는다. 재사용성과 캡슐화가 동시에 올라가고, ResizeObserver 기반의 JavaScript 로직이 제거되면서 메인 스레드 부하도 낮아진다. 중첩 컨테이너가 복잡해질 경우 container-name으로 명시적 이름을 부여해 쿼리 충돌을 막는 것도 실무 베스트 프랙티스로 자리잡고 있다. 뷰포트 고정 브레이크포인트(768px, 1024px)가 아니라 컴포넌트 디자인이 실제로 깨지는 시점을 기준으로 쿼리를 설계하는 '컨테이너 퍼스트' 접근법이 이제는 표준에 가깝다.
3. dvh/lvh/svh — 모바일 풀스크린의 오랜 숙제를 CSS가 풀다
100vh는 오랫동안 모바일 개발자의 적이었다. 주소창이 나타났다 사라지는 iOS Safari의 동적 뷰포트 때문에 height: 100vh로 만든 히어로 섹션이 잘리거나 빈 공간을 남기는 문제는 프론트엔드 밈이 될 만큼 익숙한 고통이었다. 해결책은 언제나 JavaScript—window.innerHeight를 읽어 CSS 변수에 주입하는 핵이었다.
W3C가 제안한 세 단위가 이 구조를 바꾼다. svh(Small Viewport Height)는 주소창이 완전히 펼쳐진 최소 뷰포트 기준, lvh(Large Viewport Height)는 주소창이 숨겨진 최대 뷰포트 기준, dvh(Dynamic Viewport Height)는 현재 뷰포트 상태에 동적으로 반응한다. 세 단위 중 90%의 케이스를 커버하는 것은 dvh다. 히어로 섹션에 height: 100dvh를 쓰면, 스크롤에 따라 뷰포트가 변할 때 컨텐츠가 매끄럽게 따라간다.
폴백 전략도 깔끔하다. 같은 속성에 100vh를 먼저 선언하고 100dvh를 뒤에 쓰면, 구형 브라우저는 vh를 유지하고 모던 브라우저는 dvh를 적용한다. Chrome 108+, Safari 15.4+, Firefox 101+ 이상에서 지원되므로 현시점에서 프로덕션 전환은 현실적이다. resize 이벤트 리스너와 window.innerHeight 계산 코드를 지울 수 있다—이것만으로도 전환할 이유는 충분하다.
왜 지금 이 세 가지인가
세 기능의 공통점은 JavaScript가 담당하던 '상태 감지와 반응' 로직을 CSS 선언 안으로 끌어들인다는 것이다. 이는 단순한 코드 절감이 아니다. 렌더링 관점에서 JavaScript DOM 조작보다 CSS 엔진이 처리하는 스타일 계산이 훨씬 저렴하다. 메인 스레드를 덜 쓰고, 레이아웃 스래싱이 줄어들고, Core Web Vitals—특히 CLS와 INP—에 긍정적인 영향을 미친다.
디자인 시스템 설계 관점에서도 전환점이다. 컴포넌트가 외부 클래스 토글 없이 자신의 상태와 컨텍스트를 스스로 읽고 반응한다는 것은, 컴포넌트의 진짜 캡슐화에 한 걸음 더 가까워졌다는 뜻이다. AI 코드 생성 도구가 컴포넌트를 쏟아내는 지금, 그 컴포넌트들이 얼마나 선언적으로 설계되어 있느냐가 유지보수 비용을 가른다.
지금 당장 적용할 수 있는 것들
세 기능 모두 지금 당장 프로덕션에 쓸 수 있는 상태다. 우선순위를 굳이 정하자면, dvh부터 시작하라. 변경 범위가 가장 좁고(특정 높이 단위만 교체), 효과는 즉각적이다. 다음은 Container Queries—신규 컴포넌트를 만들 때부터 container-type을 기본으로 붙이는 습관을 들이면 된다. :has()는 폼 검증, 상태 기반 레이아웃 등 JavaScript 클래스 토글이 반복되는 지점을 찾아 점진적으로 교체한다.
이 세 가지를 조합하면, '컨테이너 크기에 따라 레이아웃을 바꾸고(Container Queries), 자식 상태에 따라 부모 스타일을 제어하며(:has()), 모바일 뷰포트에 정직하게 반응하는(dvh) 컴포넌트'가 완성된다. JavaScript를 억지로 걷어내는 게 아니라, CSS가 원래 해야 했던 일을 이제야 제대로 하게 된 것이다.