Tailwind CSS

Tailwind CSS group peer has 차이: 상태 기준으로 고르는 방법

2026.04.24·수정 2026.05.11·약 22분

Tailwind CSS group, peer, has-*를 고르는 기준

Tailwind CSS에서 상태 스타일링이 헷갈리는 순간은 대부분 클래스 이름 때문이 아니라, 상태가 발생하는 요소와 실제로 바꾸고 싶은 요소가 다르기 때문에 생깁니다.는 각각 부모, 형제, 자식 관계를 기준으로 상태를 전달하는 방법입니다.

상태가 생기는 위치와 스타일이 바뀌는 위치

Tailwind CSS group peer has 차이: 상태 기준으로 고르는 방법 핵심 개념을 설명하는 첫 번째 본문 이미지

Tailwind CSS에서 hover:bg-gray-100, focus:ring-2, checked:border-blue-500 같은 상태 variant는 비교적 빨리 익숙해집니다. 요소 자신에게 hover가 걸리고, 그 요소 자신의 배경이 바뀌는 구조라면 크게 막히지 않습니다.

실제 UI에서는 상태가 생기는 위치와 바꾸고 싶은 위치가 자주 엇갈립니다. 카드 전체에 마우스를 올렸는데 안쪽 제목 색상만 바꾸고 싶을 수 있습니다. input에 focus가 들어갔을 때 아래 안내 문구를 강조하고 싶을 수도 있습니다. radio input이 선택되었을 때는 input 자체가 아니라 label 카드 전체의 테두리를 바꾸고 싶어집니다.

이때 먼저 봐야 하는 것은 클래스 이름이 아니라 관계입니다. 상태가 어디에서 발생하는지, 스타일을 바꿀 대상이 어디에 있는지 분리해서 보면 group, peer, has-* 중 무엇을 써야 할지 빠르게 좁혀집니다.

상황 상태 기준 스타일 대상 먼저 볼 variant
카드 hover 시 내부 제목 변경 부모 카드 자식 제목 group-*
input invalid 시 에러 문구 표시 앞쪽 input 뒤쪽 안내 문구 peer-*
radio 선택 시 카드 전체 강조 자식 input 현재 label 카드 has-*

표에서 중요한 부분은 마지막 열보다 가운데 두 열입니다. 어떤 variant를 쓰는지는 상태 기준과 스타일 대상의 관계에서 결정됩니다.

부모 상태를 자식에게 전달하는 group

group은 부모 요소의 상태를 자식 요소 스타일에 반영할 때 사용합니다. 가장 흔한 예시는 카드 UI입니다. 카드 전체에 hover했을 때 카드 안의 제목, 설명, 아이콘이 함께 반응해야 하는 경우가 많습니다.

이 구조에서 hover 상태는 카드에 생깁니다. 그런데 실제로 색상이 바뀌는 대상은 카드 안쪽의 제목이나 아이콘입니다. 자식 요소 입장에서는 자기 자신이 hover된 것이 아니기 때문에 hover:text-*만으로는 원하는 흐름을 만들기 어렵습니다.

카드 hover에서 내부 요소 바꾸기

<article class="group rounded-2xl border border-gray-200 p-5 transition hover:border-blue-300 hover:bg-blue-50"> <div class="flex items-center justify-between gap-4"> <div> <p class="text-sm text-gray-500">Tailwind 상태 variant</p> <h3 class="mt-1 text-lg font-semibold text-gray-900 transition group-hover:text-blue-700"> 부모 hover에 반응하는 카드 제목 </h3> </div> <span class="translate-x-0 text-gray-400 transition group-hover:translate-x-1 group-hover:text-blue-600"> → </span> </div>
</article>

부모인 articlegroup을 붙이고, 자식 요소에서는 group-hover:*를 사용합니다. CSS 선택자 관점으로 보면 “부모가 hover 상태일 때 그 안의 특정 자식을 바꾼다”는 구조입니다.

카드형 링크, 상품 카드, 관리자 목록 행처럼 큰 영역에 hover를 걸고 내부 일부만 바꾸고 싶을 때 group이 잘 맞습니다. 반대로 바뀌어야 하는 대상이 부모 자신이라면 group을 꺼낼 필요가 없습니다. 그때는 부모 요소에 바로 hover:*를 쓰는 편이 단순합니다.

중첩된 group에서는 이름을 붙여 구분하기

카드 안에 또 다른 hover 영역이 들어가면 group-hover가 어느 부모를 기준으로 하는지 헷갈릴 수 있습니다. 이런 경우에는 group/card, group/button처럼 이름을 붙여 구분할 수 있습니다.

<article class="group/card rounded-2xl border p-5 hover:bg-gray-50"> <h3 class="text-lg font-semibold group-hover/card:text-blue-700"> 카드 제목 </h3> <button class="group/button mt-4 inline-flex items-center gap-2 rounded-lg border px-3 py-2"> 자세히 보기 <span class="transition group-hover/button:translate-x-1">→</span> </button>
</article>

바깥 카드의 hover에 반응할 요소는 group-hover/card:*를 사용하고, 버튼 hover에만 반응해야 하는 아이콘은 group-hover/button:*를 사용합니다. 중첩 UI에서 이름 없는 group만 계속 쓰면 나중에 수정할 때 기준을 찾기 어려워집니다.

group-has-*가 필요한 경우

부모에 group을 붙인 뒤, 그 부모 안에 특정 요소가 있을 때만 다른 자식을 바꾸고 싶다면 group-has-*도 사용할 수 있습니다. 예를 들어 카드 내부에 링크가 있을 때만 오른쪽 아이콘을 보여주는 식입니다. 이 방식은 “부모의 후손 조건을 보고, 다른 자식의 스타일을 바꾸는” 흐름입니다.

<article class="group rounded-2xl border p-4"> <div class="flex items-center justify-between gap-4"> <div> <h3 class="font-semibold text-gray-900">문서 카드</h3> <a href="/docs/tailwind-state" class="mt-1 inline-block text-sm text-blue-600"> 자세히 보기 </a> </div> <span class="hidden text-gray-400 group-has-[a]:block">→</span> </div>
</article>

group-has-[a]:block은 group 내부에 링크가 있을 때 아이콘을 표시합니다. 모든 카드가 같은 구조라면 필요 없지만, 링크가 있는 카드와 없는 카드가 섞여 있는 목록에서는 조건을 마크업 안에서 읽기 좋게 남길 수 있습니다.

형제 요소의 상태를 따라가는 peer

peer는 형제 요소의 상태를 기준으로 다른 형제 요소를 스타일링할 때 사용합니다. 폼 UI에서 자주 나옵니다. input의 focus, invalid, checked 상태를 기준으로 label, 안내 문구, 버튼 상태를 바꾸는 식입니다.

peergroup보다 까다롭게 느껴지는 이유는 DOM 순서 때문입니다. peer는 기준이 되는 요소에 붙고, 그 뒤에 오는 형제 요소가 peer-*로 반응하는 구조입니다. 뒤에 있는 요소가 앞에 있는 peer의 상태를 바라본다고 이해하면 됩니다.

input 상태에 따라 안내 문구 바꾸기

<label class="block max-w-sm"> <span class="mb-2 block text-sm font-medium text-gray-700">이메일</span> <input type="email" required class="peer w-full rounded-lg border border-gray-300 px-3 py-2 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-100 invalid:border-red-400" placeholder="name@example.com" > <p class="mt-2 hidden text-sm text-red-500 peer-[&:not(:placeholder-shown):not(:focus):invalid]:block"> 이메일 형식을 확인해 주세요. </p>
</label>

여기서 peer는 input에 붙습니다. 에러 문구는 input 뒤에 있는 형제 요소이고, input이 비어 있지 않고 focus 상태가 아니며 invalid일 때만 표시됩니다. 단순히 peer-invalid:block만 쓰면 required input이 비어 있는 첫 화면에서도 에러가 바로 보일 수 있어, 실제 폼에서는 노출 조건을 조금 더 좁히는 경우가 많습니다.

이 예시에서 에러 문구가 input보다 앞에 있으면 같은 방식으로는 동작하지 않습니다. Tailwind 클래스 문제가 아니라 CSS 선택자 방향의 문제입니다. peer가 먹지 않을 때는 클래스 오타보다 먼저 HTML 순서를 확인해야 합니다.

여러 peer가 있을 때는 이름을 붙인다

라디오 버튼이 여러 개 있고 각각 다른 설명 문구를 보여줘야 한다면 peer에도 이름을 붙일 수 있습니다. 이름을 붙이지 않으면 어떤 input의 checked 상태를 기준으로 하는지 읽기 어려워집니다.

<fieldset class="space-y-3"> <legend class="text-sm font-semibold text-gray-900">공개 상태</legend> <input id="draft" type="radio" name="status" class="peer/draft sr-only" checked> <label for="draft" class="block cursor-pointer rounded-lg border p-3 peer-checked/draft:border-blue-500"> 임시 저장 </label> <input id="publish" type="radio" name="status" class="peer/publish sr-only"> <label for="publish" class="block cursor-pointer rounded-lg border p-3 peer-checked/publish:border-blue-500"> 공개 </label> <p class="hidden text-sm text-gray-500 peer-checked/draft:block"> 임시 저장 상태에서는 관리자만 볼 수 있습니다. </p> <p class="hidden text-sm text-gray-500 peer-checked/publish:block"> 공개 후에는 방문자가 페이지를 볼 수 있습니다. </p>
</fieldset>

이 예시는 peer/draftpeer/publish를 분리합니다. 라디오가 여러 개 있는 폼에서는 named peer를 쓰면 조건이 어디에 연결되는지 코드만 보고도 따라가기 쉽습니다.

peer가 어울리는 화면

peer는 input과 설명 문구가 나란히 있거나, checkbox와 label 텍스트가 형제 관계로 배치된 구조에서 잘 맞습니다. 로그인 폼, 검색 필터, 약관 동의, 토글 옵션처럼 작은 입력 요소의 상태를 주변 UI가 따라가야 하는 경우입니다.

다만 카드 전체를 선택 상태로 바꾸기 위해 input과 label을 억지로 형제 관계로 만들다 보면 마크업이 어색해질 때가 있습니다. 이때는 peer를 고집하기보다 has-*가 더 자연스러운 구조인지 확인하는 편이 낫습니다.

자식 상태로 현재 요소를 바꾸는 has-*

has-*는 현재 요소 안에 있는 자식이나 후손의 상태를 기준으로 현재 요소를 바꿀 때 사용합니다. CSS의 :has() 감각과 연결해서 보면 이해하기 쉽습니다.

선택 카드 UI를 만들 때 label 안에 radio input과 텍스트를 함께 넣는 구조가 자주 쓰입니다. 이때 radio가 checked 상태가 되면 label 카드 전체의 border나 background를 바꾸고 싶습니다. 상태는 자식 input에 있고, 스타일은 부모 역할을 하는 label에 적용됩니다. 이 상황에서는 has-checked:*가 자연스럽습니다.

선택된 radio를 가진 카드 강조하기

<div class="grid gap-3 sm:grid-cols-2"> <label class="has-checked:border-blue-500 has-checked:bg-blue-50 has-checked:ring-2 has-checked:ring-blue-100 cursor-pointer rounded-2xl border border-gray-200 p-4 transition"> <input type="radio" name="plan" class="sr-only" checked> <span class="block text-sm font-semibold text-gray-900">기본 플랜</span> <span class="mt-1 block text-sm text-gray-500">작은 프로젝트에 맞는 구성</span> </label> <label class="has-checked:border-blue-500 has-checked:bg-blue-50 has-checked:ring-2 has-checked:ring-blue-100 cursor-pointer rounded-2xl border border-gray-200 p-4 transition"> <input type="radio" name="plan" class="sr-only"> <span class="block text-sm font-semibold text-gray-900">프로 플랜</span> <span class="mt-1 block text-sm text-gray-500">여러 화면을 관리하는 구성</span> </label>
</div>

이 코드는 label 카드 자체에 has-checked:*를 붙입니다. label 내부의 radio가 checked 상태가 되면 label 카드가 선택된 스타일로 바뀝니다. input을 숨기더라도 label 안에 포함되어 있으면 클릭 영역을 카드 전체로 잡을 수 있어 선택 UI를 만들기 좋습니다.

예전에는 이런 UI를 peer-checked:*로 만들기 위해 input과 label을 형제 관계로 배치하는 경우가 많았습니다. 물론 그 방식도 가능합니다. 하지만 label이 input을 감싸는 구조가 더 읽기 좋고 접근성 흐름도 자연스럽다면 has-*가 코드 의도를 더 잘 드러냅니다.

has-*는 상태뿐 아니라 존재 여부도 볼 수 있다

has-*는 checked나 focus 같은 상태뿐 아니라 특정 자식 요소가 있는지도 기준으로 삼을 수 있습니다. 예를 들어 카드 안에 이미지가 있을 때만 레이아웃 간격을 다르게 주거나, 링크가 포함된 카드에만 아이콘을 노출하는 식입니다.

<article class="has-[img]:grid has-[img]:grid-cols-[120px_1fr] has-[img]:gap-4 rounded-2xl border p-4"> <img src="/images/product.jpg" alt="" class="aspect-square rounded-xl object-cover"> <div> <h3 class="font-semibold text-gray-900">이미지가 있는 카드</h3> <p class="mt-1 text-sm text-gray-500">이미지가 있을 때만 카드 레이아웃이 바뀝니다.</p> </div>
</article>

has-[img]:grid는 현재 카드 안에 이미지가 있을 때만 grid 레이아웃을 적용합니다. 카드 데이터에 이미지가 있을 수도 있고 없을 수도 있는 목록이라면 조건부 클래스를 JavaScript로 나누지 않고도 간단한 차이를 만들 수 있습니다.

다만 has-*를 너무 많이 쓰면 HTML 구조와 스타일 조건이 강하게 묶입니다. 단순 hover라면 hover:*, 부모 hover가 자식에게 전달되는 정도라면 group-*, 형제 입력 상태를 따라가는 정도라면 peer-*가 더 읽기 쉬운 경우도 많습니다.

group, peer, has-* 선택 기준

Tailwind CSS group peer has 차이: 상태 기준으로 고르는 방법 적용 흐름을 설명하는 두 번째 본문 이미지

세 문법을 고를 때는 “무엇이 더 최신인가”보다 “현재 마크업 관계를 가장 덜 비틀고 표현할 수 있는가”를 기준으로 보는 편이 실무에 맞습니다.

상태가 부모에서 생기고 자식이 바뀐다면 group입니다. 카드, 리스트 행, 메뉴 항목처럼 큰 영역에 hover나 focus-within이 걸리고 내부 텍스트, 아이콘, 배지가 따라 바뀌는 구조에 적합합니다.

상태가 앞쪽 형제 요소에서 생기고 뒤쪽 형제 요소가 바뀐다면 peer입니다. input 아래 에러 문구, checkbox 옆 설명, radio 옆 보조 텍스트처럼 입력 요소와 안내 요소가 같은 레벨에 놓이는 구조에서 쓰기 좋습니다.

상태가 자식이나 후손에서 생기고 현재 요소가 바뀐다면 has-*입니다. 특히 label 카드 안에 input을 넣고 카드 전체를 선택 상태로 바꾸는 UI에서 의도가 선명합니다.

variant 관계 방향 대표 사용처 먼저 확인할 점
group-* 부모 상태 → 자식 스타일 카드 hover, 메뉴 hover, 리스트 행 hover 부모에 group이 붙어 있는가
peer-* 형제 상태 → 뒤쪽 형제 스타일 폼 안내 문구, checkbox 설명, input 상태 메시지 peer 요소가 대상보다 앞에 있는가
has-* 자식 상태 → 현재 요소 스타일 선택 카드, 내부 요소 유무에 따른 카드 변경 현재 요소 안에 기준 요소가 들어 있는가

이 기준을 잡아두면 Tailwind 클래스가 길어져도 방향을 잃지 않습니다. group-hover:, peer-invalid:, has-checked:는 모양만 다른 문법이 아니라 각각 바라보는 위치가 다릅니다.

다음 작업에서 다시 볼 체크포인트

상태 variant가 작동하지 않을 때는 색상 클래스나 spacing 클래스부터 바꾸기보다 HTML 구조를 먼저 확인하는 것이 빠릅니다. Tailwind는 결국 CSS 선택자를 생성하기 때문에, 선택자가 닿을 수 없는 위치에 요소가 있으면 클래스 이름을 바꿔도 해결되지 않습니다.

  • 상태가 발생하는 요소와 스타일이 바뀌는 요소가 같은지 먼저 확인합니다.
  • 부모 상태를 자식이 따라가야 한다면 부모에 group을 붙입니다.
  • 형제 요소 상태를 따라가야 한다면 기준 요소에 peer를 붙이고 대상이 그 뒤에 있는지 확인합니다.
  • 자식 상태로 부모 역할의 요소를 바꿔야 한다면 has-*를 검토합니다.
  • 중첩된 hover 영역이 있으면 group/card, group/button처럼 이름을 붙여 충돌을 줄입니다.
  • 여러 input의 상태를 각각 따라가야 한다면 peer/draft, peer/publish처럼 named peer를 사용합니다.
  • 선택 카드 UI에서는 peer-checked:*has-checked:* 중 마크업이 더 자연스러운 쪽을 고릅니다.

group, peer, has-*는 많이 외울수록 좋은 문법이라기보다, UI 상태가 어디에서 시작해서 어디에 영향을 주는지 표시하는 도구입니다. 카드, 폼, 선택 리스트를 만들 때 이 관계를 먼저 나누면 Tailwind 코드가 길어져도 수정 기준이 남습니다.

선택자 문법을 더 세밀하게 다뤄야 한다면 Tailwind Arbitrary Value와 Arbitrary Variant 차이를, 반복되는 스타일을 재사용하는 기준은 Tailwind CSS @apply와 @utility 사용 기준에서 이어서 확인할 수 있습니다.

참고 자료

Tailwind CSS 공식 문서의 hover, focus, group, peer, has 관련 상태 variant 설명을 기준으로 문법과 사용 방향을 확인했습니다.

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

“Tailwind CSS group peer has 차이: 상태 기준으로 고르는 방법”에 대한 5개의 생각

댓글 남기기