
UI를 만들다 보면 내용은 없는데 wrapper만 남는 경우가 있다. 카드의 보조 설명 영역, 필터 결과 안내 영역, 혹은 children을 받아서 그리는 작은 레이아웃 컴포넌트가 그렇다. 실제로 보여줄 내용은 없는데 padding이나 border가 적용된 부모 요소만 화면에 남으면, 사용자는 빈 박스를 보게 된다.
빈 박스는 대체로 사소해 보인다. 하지만 이런 작은 흔적이 쌓이면 화면은 이유 없이 느슨해지고, 컴포넌트는 실제 콘텐츠보다 레이아웃을 위한 껍데기를 더 많이 드러낸다. 특히 공통 wrapper를 여러 곳에서 재사용하는 경우라면, 매번 별도의 상태값을 만들거나 className을 분기하는 방식도 금방 귀찮아진다.
이런 상황에서 CSS의 :empty 의사 클래스를 사용할 수 있다. :empty는 이름 그대로 비어 있는 요소를 선택한다. 다만 여기서 비어 있다는 말은 화면에 보이는 내용이 없다는 뜻이 아니라, DOM 기준으로 자식 노드가 없다는 뜻이다.
.wrapper:empty {
display: none;
}이 코드는 .wrapper 안에 element node나 text node가 없을 때만 display: none을 적용한다. 예를 들어 아래 요소는 자식 노드가 없으므로 :empty에 걸린다.
<div class="wrapper"></div>반대로 아래처럼 공백만 들어 있어도 상황은 달라질 수 있다.
<div class="wrapper"> </div>공백이나 개행은 브라우저가 text node로 취급할 수 있다. 그러면 사람 눈에는 비어 보이더라도 CSS 입장에서는 비어 있지 않은 요소가 된다. 주석이나 CSS의 content 속성은 :empty 판단에 영향을 주지 않지만, 텍스트와 다른 요소는 영향을 준다.
그래서 :empty는 “보이는 내용이 있는가?”보다 “DOM 안에 자식 노드가 있는가?”를 기준으로 이해하는 편이 안전하다. 이 차이를 알고 있으면, 왜 어떤 빈 wrapper는 잘 숨겨지고 어떤 wrapper는 그대로 남는지 덜 억울해진다. CSS가 갑자기 변덕을 부린 게 아니라, 우리가 모르는 사이에 공백 하나가 취업을 한 셈이다.
Tailwind에서는 empty:hidden
Tailwind CSS를 사용하고 있다면 같은 처리를 더 짧게 작성할 수 있다.
<div className="empty:hidden">
{children}
</div>Tailwind의 empty: variant는 CSS의 :empty에 대응한다. 여기에 hidden을 붙이면 요소가 비어 있을 때만 display: none이 적용된다. 결국 아래 CSS를 Tailwind 방식으로 적은 것과 같다.
.wrapper:empty {
display: none;
}실제 코드에서는 보통 여백이나 테두리가 있는 wrapper에 함께 붙인다.
function DescriptionBox({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-md border border-gray-200 p-4 empty:hidden">
{children}
</div>
);
}이 컴포넌트는 내부에 렌더링된 자식 노드가 없으면 wrapper까지 같이 사라진다. 반대로 자식 노드가 하나라도 있으면 rounded-md, border, p-4 같은 기존 스타일이 그대로 적용된다. “내용이 있을 때만 박스처럼 보인다”는 규칙을 별도의 상태값 없이 className 안에 남길 수 있다.
숨기지 않고 비어 있음을 다루기
그렇다고 empty:가 항상 hidden과만 붙어야 하는 것은 아니다. 빈 요소를 없애고 싶은 경우도 있지만, 어떤 경우에는 비어 있다는 상태 자체를 레이아웃 안에서 표현해야 한다. 예를 들어 드래그 앤 드롭 영역, 빈 목록, 아직 선택된 항목이 없는 패널은 사라지는 것보다 “여기에 무언가 들어올 수 있다”는 흔적을 남기는 편이 더 자연스럽다.
이럴 때는 empty:min-h-*, empty:border-*, empty:bg-* 같은 utility를 조합할 수 있다.
function DropZone({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-md border border-gray-200 p-4 empty:min-h-24 empty:border-dashed empty:bg-gray-50">
{children}
</div>
);
}이 코드는 내용이 있을 때는 일반적인 박스처럼 보인다. 하지만 비어 있을 때는 최소 높이를 확보하고, 점선 테두리와 옅은 배경을 적용한다. empty:hidden이 빈 wrapper를 화면에서 제거하는 선택이라면, 이 방식은 빈 wrapper를 “비어 있는 영역”으로 바꾸는 선택이다.
공간은 유지하되 보이지만 않게 만들고 싶을 때는 empty:invisible도 쓸 수 있다.
<div className="h-6 empty:invisible">
{badge}
</div>hidden은 요소를 레이아웃에서 제거하지만, invisible은 공간을 남긴 채 보이지만 않게 만든다. 배지나 보조 문구처럼 높이가 바뀌면 주변 레이아웃이 덜컥 움직이는 영역에서는 이 차이가 의외로 중요하다. 빈 상태를 없앨 것인지, 빈 자리를 유지할 것인지에 따라 hidden과 invisible의 선택이 달라진다.
빈 상태 메시지를 보여주고 싶다면
한 단계 더 나아가면, wrapper가 비어 있을 때 별도의 안내 문구를 보여주고 싶을 수 있다. 이때 :empty만으로는 요소 안에 실제 텍스트를 만들어내기 어렵다. CSS의 ::before와 content를 이용할 수도 있지만, 사용자가 읽어야 하는 안내 문구라면 실제 DOM에 두는 편이 더 명확하다.
Tailwind에서는 peer-empty:*를 사용하면 이런 구조를 만들 수 있다.
function ResultPanel({ children }: { children: React.ReactNode }) {
return (
<section>
<div className="peer empty:hidden">
{children}
</div>
<p className="hidden rounded-md border border-dashed border-gray-300 p-4 text-sm text-gray-500 peer-empty:block">
아직 표시할 내용이 없습니다.
</p>
</section>
);
}여기서 첫 번째 div는 비어 있으면 empty:hidden으로 사라진다. 동시에 그 div가 :empty 상태이므로, 뒤에 있는 문단은 peer-empty:block에 의해 화면에 나타난다. 빈 상태 문구를 CSS content로 억지로 만들지 않고 실제 텍스트로 남길 수 있다는 점에서 더 읽기 좋고, 접근성 측면에서도 다루기 쉽다.
물론 이 패턴도 전제는 같다. peer가 붙은 요소 안에 공백 text node나 숨겨진 자식 요소가 있으면 :empty가 매칭되지 않을 수 있다. empty:를 사용할 때는 항상 “화면에 비어 보이는가?”가 아니라 “DOM 기준으로 정말 비어 있는가?”를 먼저 생각해야 한다.
언제 쓰면 좋을까
:empty와 Tailwind의 empty:는 거대한 상태 관리 도구가 아니다. 오히려 작은 wrapper를 정리하거나, 빈 상태에서만 적용되는 시각적 보정을 가까운 className 안에 남겨두는 데 어울린다. 그래서 복잡한 비즈니스 조건을 표현하기보다는, DOM 구조가 이미 빈 상태를 말해주고 있을 때 그 상태를 CSS가 받아서 처리하는 정도가 적당하다.
정리하면 empty:hidden은 빈 wrapper를 제거할 때 좋다. empty:invisible은 공간을 유지하고 싶을 때 좋다. empty:min-h-*, empty:border-*, empty:bg-*는 빈 영역을 placeholder처럼 보이게 만들 때 쓸 수 있다. 그리고 빈 상태 메시지가 필요하다면 peer-empty:*로 실제 DOM 텍스트를 보여주는 방식도 고려할 만하다.
작은 선택자 하나지만, 기준을 정확히 알고 쓰면 꽤 유용하다. 내용이 없으면 숨기고, 필요하면 빈 상태를 보여주고, 그래도 안 맞으면 다시 DOM 구조를 확인한다. CSS가 해결할 수 있는 문제를 더 복잡한 로직으로 끌고 가지 않아도 되는, 작지만 적당한 선택지다.
더 읽어보기
2026.02.22
View Transition API
웹 애플리케이션에서 전환 품질은 기능 완성도와 동등한 수준으로 중요하다. 사용자가 목록에서 항목을 선택해 상세 화면으로 이동할 때, 화면이 자연스럽게 이어지면 서비스는 빠르고 안정적으로 느껴진다. 반대로 동일한 기능이라도 전환이 끊기면 체감 성능과 신뢰도는 동시에 하락한다. 전환은 부가…
2026.01.03
CSS Color Functions
이 포스트는 Sunkanmi Fafowora가 css-tricks에 올린 CSS Color Functions 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는 사견이 포함되어있기도 하다. 몇 달 전 누군가 저에게 “웹사이트가 돋보이려면 무엇이 필요할…
2025.11.02
@function
CSS는 오랫동안 스타일 값을 선언하는 정적인 언어로 인식되어 왔다. 그 안에서 반복되는 패턴이나 디자인 시스템의 일관성을 유지하기 위해 Sass나 CSS-in-JS 같은 추가 도구들을 활용해 왔으며, 최근에는 CSS Custom Properties를 활용하여 값의 재사용을 가능하게 만…
2025.05.31
모달과 팝오버, 그리고 앵커 포지셔닝
모달과 팝오버를 칼로 베듯 명확히 구분해본 적은 없었다. 그저 화면 한가운데에 떠서 다른 작업을 막으면 모달, 특정 버튼을 눌렀을 때 앵커를 기준으로 나타나면 팝오버라는 정도로 느슨하게 생각해왔다. 하지만 두 개념은 단순한 위치나 동작의 차이를 넘어, 그 목적과 사용 방식에서 뚜렷한 차…
2026.06.07
AI 에이전트의 비밀값을 macOS Keychain에 맡기기
AI 에이전트나 스킬을 만들다보면 비밀값을 어떻게 관리하면 좋을지 하는 생각을 자주 하게 된다. API를 호출하려면 API Key가 필요하고, 특정 기능을 자동화하는 과정에서 아이디와 비밀번호가 필요할 수도 있다. 그런데 그 값을 프롬프트에 박아버리면 대화 기록에 남고, 명령어 인자로…
2026.06.01
React Server Components를 위한 컴포넌트 아키텍처
이 포스트는 Vercel의 Next.js 팀 소속 개발자 Aurora Scharff가 자신의 블로그에 올린 Component Architecture for React Server Components 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는…
2026.05.26
차트는 멈췄는데 윈도우가 움직인다
상황 어느날 서비스를 살펴보시던 팀장님께서 이런 말씀을 slack에 남기셨다. 진호님, 예측 차트에서 zoom을 계속하면 어느 순간 라인 차트가 아니라 단일 스캐터 차트처럼 보이는 데, 이거 수정하면 좋을 거 같아요. 어느정도 zoom을 하면 그 이후로는 zoom이 안 되도록 할 수 없…
2026.05.21
피자가게로 이해하는 디자인 패턴
에이든 피자는 처음부터 복잡한 시스템을 만들 생각이 없었다. 처음에는 메뉴 몇 개만 만들면 됐다. 그런데 손님은 커스텀 주문을 넣기 시작했고, 주방은 상태를 나눠야 했고, 결제와 배달앱과 알림이 하나씩 붙었다. 코드도 가게를 닮는다. 장사가 잘될수록 이상하게 더 쉽게 망가진다. 디자인…
댓글
댓글을 불러오는 중...