상태 기반 스타일링
CSS에서 요소의 상태에 따라 스타일을 다르게 적용하려면 pseudo-class를 사용한다. 버튼에 마우스를 올렸을 때 배경색을 바꾸려면 .btn:hover { background: blue; }처럼 작성한다.
Tailwind는 이 pseudo-class를 클래스명 앞에 붙이는 modifier로 표현한다. 같은 효과를 hover:bg-blue-500으로 작성한다. 별도의 CSS 파일 없이 HTML 안에서 상태별 스타일을 정의할 수 있다는 게 핵심이다.
자기 자신의 상태
가장 기본적인 형태다. 요소 자체의 상태가 바뀔 때 스타일을 변경한다.
인터랙티브 상태
사용자가 요소와 상호작용할 때 시각적 피드백을 주기 위해 사용한다. 버튼을 눌렀을 때 색이 진해지거나, 입력창에 포커스가 갔을 때 테두리가 강조되는 것이 대표적이다.
<button class="bg-violet-500 hover:bg-violet-600 active:bg-violet-700 focus:outline-none focus:ring focus:ring-violet-300">
Save changes
</button>
이 버튼은 세 가지 상태를 가진다:
- 기본:
bg-violet-500 - 마우스 올림:
hover:bg-violet-600(조금 진해짐) - 클릭 중:
active:bg-violet-700(더 진해짐) - 포커스:
focus:ring(보라색 링 표시)
| modifier | CSS | 언제 적용되는가 |
|---|---|---|
hover: | :hover | 마우스 커서가 요소 위에 있을 때 |
focus: | :focus | 키보드 탭이나 클릭으로 요소가 포커스를 받았을 때 |
active: | :active | 마우스 버튼을 누르고 있는 동안 |
visited: | :visited | 이미 방문한 링크일 때 |
focus-within: | :focus-within | 요소 자신 또는 자식 중 하나가 포커스를 받았을 때 |
focus-visible: | :focus-visible | 키보드로 포커스했을 때만 (마우스 클릭은 제외) |
focus와 focus-visible의 차이가 중요하다. focus는 마우스 클릭으로 포커스해도 적용되지만, focus-visible은 키보드 탭으로 이동했을 때만 적용된다. 접근성을 고려할 때 키보드 사용자에게만 포커스 표시를 보여주고 싶다면 focus-visible을 사용한다.
폼 상태
폼 요소는 입력 가능/불가능, 유효/무효 등 다양한 상태를 가진다. 이 상태들을 시각적으로 구분해주면 사용자가 현재 무엇을 할 수 있는지, 입력에 문제가 있는지 즉시 알 수 있다.
<input type="text" disabled class="
focus:border-sky-500 focus:ring-1 focus:ring-sky-500
disabled:bg-slate-50 disabled:text-slate-500
invalid:border-pink-500 invalid:text-pink-600
focus:invalid:border-pink-500 focus:invalid:ring-pink-500
"/>
이 입력창은:
- 포커스 시 파란 테두리 (
focus:border-sky-500) - 비활성화 시 회색 배경과 흐린 텍스트 (
disabled:bg-slate-50) - 유효성 검사 실패 시 분홍색 테두리 (
invalid:border-pink-500) - 포커스 상태에서 유효성 실패 시 분홍색 링 (
focus:invalid:ring-pink-500)
마지막 줄처럼 modifier를 조합할 수 있다. focus:invalid:는 "포커스 상태이면서 동시에 invalid일 때"를 의미한다.
| modifier | CSS | 언제 적용되는가 |
|---|---|---|
disabled: | :disabled | disabled 속성이 있을 때 |
enabled: | :enabled | disabled 속성이 없을 때 |
invalid: | :invalid | HTML5 유효성 검사 실패 시 (예: type="email"에 이메일 형식이 아닌 값) |
valid: | :valid | HTML5 유효성 검사 통과 시 |
required: | :required | required 속성이 있을 때 |
checked: | :checked | 체크박스나 라디오 버튼이 선택되었을 때 |
placeholder-shown: | :placeholder-shown | 입력값이 없어서 placeholder가 보일 때 |
위치 기반 상태
리스트 아이템을 렌더링할 때 첫 번째나 마지막 요소만 다르게 스타일링해야 할 때가 있다. 예를 들어 리스트 아이템 사이에 구분선을 넣되, 마지막 아이템 아래에는 구분선이 없어야 한다거나, 첫 번째 아이템만 상단 패딩이 없어야 하는 경우다.
일반적인 방법은 JavaScript로 isFirst, isLast 같은 조건을 체크해서 클래스를 다르게 적용하는 것이다. Tailwind에서는 first:, last: modifier로 이를 CSS만으로 해결한다.
<ul class="divide-y divide-slate-200">
{#each people as person}
<li class="py-4 first:pt-0 last:pb-0">
{person.name}
</li>
{/each}
</ul>
모든 <li>에 같은 클래스를 적용하지만, 첫 번째는 상단 패딩이 없고(first:pt-0), 마지막은 하단 패딩이 없다(last:pb-0).
| modifier | CSS | 언제 적용되는가 |
|---|---|---|
first: | :first-child | 부모의 첫 번째 자식일 때 |
last: | :last-child | 부모의 마지막 자식일 때 |
odd: | :nth-child(odd) | 홀수 번째 자식일 때 (1, 3, 5...) |
even: | :nth-child(even) | 짝수 번째 자식일 때 (2, 4, 6...) |
only: | :only-child | 부모의 유일한 자식일 때 |
테이블에서 줄무늬 패턴을 만들 때 odd:와 even:을 자주 사용한다:
<tr class="odd:bg-white even:bg-gray-100">
다른 요소의 상태
지금까지는 요소 자신의 상태에 따라 자신의 스타일을 변경했다. 하지만 때로는 다른 요소의 상태에 따라 스타일을 변경해야 할 때가 있다.
대표적인 예가 카드 컴포넌트다. 카드 전체 영역에 마우스를 올렸을 때 제목 색상만 바꾸고 싶다면? 일반 CSS에서는 .card:hover .title { color: blue; }처럼 작성한다. 부모의 상태(:hover)에 따라 자식(.title)의 스타일을 변경하는 것이다.
Tailwind는 유틸리티 클래스 기반이라 이런 부모-자식, 형제 관계의 스타일링이 기본적으로 불가능하다. 각 요소에 직접 클래스를 붙이기 때문이다. 이 문제를 해결하기 위해 group과 peer라는 특별한 클래스를 제공한다.
| modifier | 기준 요소 | CSS 선택자 원리 |
|---|---|---|
group-* | 부모 | .group:hover .child (자손 선택자) |
peer-* | 형제 | .peer:hover ~ .sibling (일반 형제 선택자) |
group
부모 요소의 상태에 따라 자식 요소의 스타일을 변경한다.
동작 원리는 단순하다. 부모에 group 클래스를 붙이면, Tailwind가 이 요소를 "그룹의 기준점"으로 인식한다. 그 안의 자식 요소에서 group-hover:, group-focus: 같은 modifier를 사용하면, 부모 group의 상태에 따라 스타일이 적용된다.
<div className="group">
<h3 className="group-hover:text-blue-500">
부모 어디든 hover하면 색상이 바뀐다
</h3>
</div>
이 코드가 생성하는 CSS는 대략 이렇다:
.group:hover .group-hover\:text-blue-500 {
color: rgb(59 130 246);
}
부모(.group)에 :hover가 적용됐을 때 자식(.group-hover:text-blue-500)의 스타일이 바뀌는 구조다.
왜 필요한가
링크 카드를 생각해보자. 카드 전체가 클릭 가능한 영역인데, 제목 텍스트 위에 마우스를 올렸을 때만 색상이 바뀌면 어색하다. 날짜나 태그 위에 마우스를 올려도 "이 영역 전체가 클릭 가능하다"는 시각적 피드백을 줘야 한다.
<Link href="/post/1" className="group flex gap-4 p-4 hover:bg-gray-100">
<h3 className="group-hover:text-primary">
글 제목
</h3>
<time className="text-gray-500">
2026-01-24
</time>
</Link>
<Link> 전체에 group을 붙였다. 이제 링크 영역 어디에 마우스를 올려도 제목 색상이 바뀐다.
중첩 그룹
그룹이 중첩되면 문제가 생긴다. 안쪽 자식이 group-hover:를 사용할 때, 어떤 group을 기준으로 할지 모호해진다.
group/{name} 문법으로 그룹에 이름을 붙여 해결한다:
<div className="group/card">
<div className="group/title">
<h3 className="group-hover/title:underline group-hover/card:text-blue-500">
제목
</h3>
</div>
</div>
group-hover/title:underline→title그룹(안쪽 div)에 hover하면 밑줄group-hover/card:text-blue-500→card그룹(바깥쪽 div)에 hover하면 파란색
group-has-*
반대로 자식의 상태에 따라 스타일을 변경할 수도 있다. CSS의 :has() 선택자를 활용한다.
<div class="group">
<img src="..." />
<h4>Spencer Sharp</h4>
<svg class="hidden group-has-[a]:block">
<!-- 외부 링크 아이콘 -->
</svg>
<p>Product Designer at <a href="...">planeteria.tech</a></p>
</div>
group-has-[a]:block은 "그룹 안에 <a> 태그가 있으면 표시"를 의미한다. 링크가 있는 프로필만 외부 링크 아이콘을 보여주는 패턴이다.
임의 선택자
대괄호 안에 커스텀 선택자를 넣어 일회성 modifier를 만들 수 있다.
<div class="group is-published">
<div class="hidden group-[.is-published]:block">
Published
</div>
</div>
group-[.is-published]:block은 "그룹이 .is-published 클래스를 가지고 있으면 표시"를 의미한다. JavaScript로 상태 클래스를 토글할 때 유용하다.
peer
형제 요소의 상태에 따라 스타일을 변경한다. group이 부모→자식 관계라면, peer는 형제→형제 관계다.
CSS의 일반 형제 선택자(~)를 사용하기 때문에 중요한 제약이 있다. peer 클래스가 붙은 요소는 스타일을 받을 요소보다 앞에 있어야 한다. CSS는 "이전 형제"를 선택하는 방법이 없기 때문이다.
<input type="email" class="peer" placeholder="Email" />
<p class="invisible peer-invalid:visible text-red-500">
유효한 이메일을 입력하세요
</p>
이 코드가 생성하는 CSS는 대략 이렇다:
.peer:invalid ~ .peer-invalid\:visible {
visibility: visible;
}
<input>이 :invalid 상태가 되면(이메일 형식이 아닌 값을 입력하면), 뒤에 오는 <p>가 보인다.
왜 필요한가
폼 유효성 메시지가 대표적이다. 입력창의 상태에 따라 에러 메시지를 보여주거나 숨겨야 하는데, 이 둘은 부모-자식이 아니라 형제 관계다.
JavaScript 없이 CSS만으로 이런 상호작용을 구현할 수 있다는 게 peer의 가치다.
중첩 peer
여러 peer를 구분해야 할 때 peer/{name}으로 이름을 붙인다:
<input id="draft" class="peer/draft" type="radio" name="status" checked />
<label for="draft" class="peer-checked/draft:text-sky-500">Draft</label>
<input id="published" class="peer/published" type="radio" name="status" />
<label for="published" class="peer-checked/published:text-sky-500">Published</label>
<div class="hidden peer-checked/draft:block">
Drafts are only visible to administrators.
</div>
<div class="hidden peer-checked/published:block">
Your post will be publicly visible on your site.
</div>
두 개의 라디오 버튼이 있고, 어떤 것이 선택되었는지에 따라 다른 설명이 표시된다.
peer-has-*
형제 요소의 자식 상태에 따라 스타일을 변경한다:
<label class="peer">
<input type="checkbox" name="todo" checked />
Create a to do list
</label>
<svg class="peer-has-[:checked]:hidden">
<!-- 체크 전 아이콘 -->
</svg>
<label> 안의 체크박스가 체크되면 <svg>가 숨겨진다.
modifier 조합
modifier는 여러 개를 조합할 수 있다. 순서가 중요한데, 왼쪽에서 오른쪽으로 중첩된다고 생각하면 된다.
dark:group-hover:opacity-100
이건 dark(groupHover('opacity-100'))처럼 해석된다. 생성되는 CSS:
.dark .group:hover .dark\:group-hover\:opacity-100 {
opacity: 1;
}
"다크모드(.dark)에서, 그룹에 hover했을 때" 적용된다.
순서를 바꾸면 의미가 달라진다:
group-hover:dark:opacity-100
.group:hover .dark .group-hover\:dark\:opacity-100 {
opacity: 1;
}
"그룹에 hover했을 때, 그 안의 다크모드 영역에서" 적용된다. DOM 구조가 달라야 한다.