04_ 컴포넌트와 UI 요소
Tailwind CSS v4로 재사용 가능한 컴포넌트, 폼, 인터랙티브 요소 구현
코드 블록의 Try it Yourself 버튼으로 직접 실행할 수 있다.
구문
4장: 컴포넌트와 UI 요소
4-1. 재사용 가능한 컴포넌트 패턴
@utility — v4 커스텀 컴포넌트 클래스
v3 → v4 변경사항 —
@layer components { .btn { @apply ... } }방식 대신@utility를 사용한다. v4에서도@apply는 동작하지만@utility가 권장된다.
@utility란? — Tailwind의 유틸리티 클래스처럼 동작하는 나만의 클래스를 정의하는 문법이다. bg-blue-600 같은 기본 유틸리티와 동일한 우선순위로 적용되므로, 다른 유틸리티 클래스와 자연스럽게 조합할 수 있다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>4-1. @utility 컴포넌트</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
/* ── @utility로 커스텀 컴포넌트 정의 ── */
/* 버튼 공통 스타일 */
@utility btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
transition: all 0.15s ease;
cursor: pointer;
}
/*
theme() 함수로 Tailwind 기본 팔레트 색상을 참조한다.
예: theme(--color-blue-600) → Tailwind의 blue-600 색상값
*/
@utility btn-primary {
background-color: theme(--color-blue-600);
color: white;
&:hover { background-color: theme(--color-blue-700); }
}
@utility btn-secondary {
background-color: theme(--color-gray-100);
color: theme(--color-gray-900);
&:hover { background-color: theme(--color-gray-200); }
}
@utility btn-danger {
background-color: theme(--color-red-600);
color: white;
&:hover { background-color: theme(--color-red-700); }
}
/* 카드 컴포넌트 */
@utility card {
background-color: white;
border-radius: 1rem;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.1);
overflow: hidden;
}
</style>
</head>
<body class="bg-gray-50 p-8">
<h2 class="text-lg font-bold mb-4">@utility 버튼</h2>
<div class="flex gap-3 mb-8">
<button class="btn btn-primary">저장</button>
<button class="btn btn-secondary">취소</button>
<button class="btn btn-danger">삭제</button>
</div>
<h2 class="text-lg font-bold mb-4">@utility 카드</h2>
<div class="card p-6 max-w-sm">
<h3 class="font-bold text-gray-900">카드 제목</h3>
<p class="mt-2 text-sm text-gray-600">카드 내용이 여기에 들어간다.</p>
</div>
</body>
</html>
@apply — 기존 방식 (v4에서도 동작)
@apply는 @layer components 안에서 기존 유틸리티 클래스를 그대로 조합해 새 클래스를 만드는 방식이다. v4에서도 동작하지만 @utility를 권장한다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>4-1. @apply 방식</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
@layer components {
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-blue { @apply bg-blue-100 text-blue-800; }
.badge-green { @apply bg-green-100 text-green-800; }
.badge-red { @apply bg-red-100 text-red-800; }
.badge-gray { @apply bg-gray-100 text-gray-800; }
}
</style>
</head>
<body class="bg-gray-50 p-8">
<div class="flex gap-3">
<span class="badge badge-blue">신규</span>
<span class="badge badge-green">완료</span>
<span class="badge badge-red">오류</span>
<span class="badge badge-gray">대기</span>
</div>
</body>
</html>
@utility vs @apply 차이 정리
항목 @utility@apply위치 최상위에 바로 작성 @layer components { }안에 작성문법 일반 CSS 속성 사용 Tailwind 유틸리티 클래스 나열 우선순위 유틸리티와 동일 레이어 components레이어 (유틸리티보다 낮음)권장 여부 v4 권장 v4에서도 동작하지만 레거시
4-2. 버튼 컴포넌트
기본 버튼 스타일
버튼은 시각적 피드백이 중요하다. hover: → active: → focus: → disabled: 순서로 상태별 스타일을 지정한다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>4-2. 버튼</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
</head>
<body class="bg-gray-50 p-8 space-y-6">
<div>
<p class="text-sm font-medium text-gray-500 mb-2">기본 버튼</p>
<button class="bg-blue-600 hover:bg-blue-700 active:bg-blue-800
text-white font-semibold
px-4 py-2 rounded-lg
transition-colors duration-150
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed">
기본 버튼
</button>
<button disabled class="bg-blue-600 text-white font-semibold
px-4 py-2 rounded-lg ml-2
disabled:opacity-50 disabled:cursor-not-allowed">
비활성 버튼
</button>
</div>
<div>
<p class="text-sm font-medium text-gray-500 mb-2">외곽선(Outline) 버튼</p>
<button class="border-2 border-blue-600 text-blue-600
hover:bg-blue-50 active:bg-blue-100
font-semibold px-4 py-2 rounded-lg
transition-colors duration-150
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
외곽선 버튼
</button>
</div>
<div>
<p class="text-sm font-medium text-gray-500 mb-2">고스트(Ghost) 버튼 — 배경 없이 텍스트만</p>
<button class="text-gray-700 hover:bg-gray-100 active:bg-gray-200
font-medium px-4 py-2 rounded-lg
transition-colors duration-150
focus:outline-none focus:ring-2 focus:ring-gray-400">
고스트 버튼
</button>
</div>
<div>
<p class="text-sm font-medium text-gray-500 mb-2">아이콘 + 텍스트 버튼 — gap-2로 간격 조절</p>
<button class="inline-flex items-center gap-2
bg-green-600 hover:bg-green-700
text-white font-semibold px-4 py-2 rounded-lg
transition-colors duration-150">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
추가
</button>
</div>
</body>
</html>
버튼 크기 변형
크기는 px(좌우 패딩), py(상하 패딩), text-*(글자 크기), rounded-*(둥글기)의 조합으로 결정된다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>4-2. 버튼 크기</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
</head>
<body class="bg-gray-50 p-8">
<div class="flex items-end gap-4 flex-wrap">
<button class="px-2.5 py-1.5 text-xs font-medium bg-blue-600 text-white rounded-md">
소형
</button>
<button class="px-4 py-2 text-sm font-semibold bg-blue-600 text-white rounded-lg">
중형
</button>
<button class="px-6 py-3 text-base font-semibold bg-blue-600 text-white rounded-xl">
대형
</button>
</div>
<div class="mt-6 max-w-sm">
<button class="w-full px-4 py-3 text-sm font-semibold bg-blue-600 text-white rounded-lg">
전체 너비 버튼
</button>
</div>
</body>
</html>
4-3. 카드 컴포넌트
기본 카드
실습 팁 — 이미지 자리에 picsum.photos를 사용하면 실제 이미지를 확인할 수 있다.
object-cover는 이미지가 영역을 꽉 채우면서 비율을 유지하게 해 준다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>4-3. 카드</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
</head>
<body class="bg-gray-100 p-8">
<div class="max-w-sm">
<div class="bg-white rounded-2xl shadow-md overflow-hidden">
<img src="https://picsum.photos/seed/card1/400/300" alt="상품 이미지"
class="w-full h-48 object-cover" />
<div class="p-5">
<span class="text-xs font-medium text-blue-600 bg-blue-50 px-2 py-1 rounded-full">
카테고리
</span>
<h3 class="mt-2 text-lg font-bold text-gray-900 line-clamp-2">
상품 또는 콘텐츠 제목이 여기에 들어간다
</h3>
<p class="mt-1 text-sm text-gray-500 line-clamp-3">
설명 텍스트가 여기에 들어간다. 3줄이 넘으면 말줄임 처리된다.
긴 텍스트를 넣어서 직접 확인해 보자.
이렇게 세 줄을 넘기면 자동으로 잘린다.
이 줄은 보이지 않을 것이다.
</p>
<div class="mt-4 flex items-center justify-between">
<span class="text-xl font-bold text-gray-900">₩29,000</span>
<button class="bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium
px-3 py-1.5 rounded-lg transition-colors">
구매
</button>
</div>
</div>
</div>
</div>
</body>
</html>
호버 효과 카드
group 클래스를 부모에 붙이면, 부모에 마우스를 올렸을 때 자식 요소들을 group-hover:로 제어할 수 있다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>4-3. 호버 카드</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
</head>
<body class="bg-gray-100 p-8">
<div class="max-w-sm">
<div class="group bg-white rounded-2xl shadow hover:shadow-xl
transition-all duration-300 overflow-hidden cursor-pointer">
<div class="overflow-hidden h-48">
<img src="https://picsum.photos/seed/hover1/400/300" alt=""
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
</div>
<div class="p-5">
<h3 class="font-bold text-gray-900 group-hover:text-blue-600 transition-colors">
호버해 보세요
</h3>
<p class="mt-1 text-sm text-gray-500">이미지 확대 + 제목 색상 변경</p>
</div>
</div>
</div>
</body>
</html>
가로형 카드
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>4-3. 가로형 카드</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
</head>
<body class="bg-gray-100 p-8">
<div class="max-w-lg">
<div class="flex bg-white rounded-2xl shadow-md overflow-hidden">
<img src="https://picsum.photos/seed/hcard/200/200" alt=""
class="w-32 sm:w-48 flex-shrink-0 object-cover" />
<div class="flex flex-col justify-between p-4">
<div>
<h3 class="font-bold text-gray-900">가로형 카드 제목</h3>
<p class="mt-1 text-sm text-gray-500 line-clamp-2">
가로형 카드는 목록형 UI에 적합하다. 썸네일을 왼쪽에 고정하고 텍스트를 오른쪽에 배치한다.
</p>
</div>
<div class="flex items-center gap-2 mt-3">
<img src="https://picsum.photos/seed/avatar/100/100" alt=""
class="size-6 rounded-full" />
<span class="text-xs text-gray-500">작성자 · 2일 전</span>
</div>
</div>
</div>
</div>
</body>
</html>
4-4. 폼(Form) 컴포넌트
v4 신규 —
field-sizing-content로 textarea가 입력 내용에 맞게 자동으로 높이가 늘어난다. (2024년 기준 Chrome 123+, Edge 123+ 지원. Safari·Firefox는 미지원이므로min-h-24로 최소 높이를 함께 지정한다.)
기본 입력 스타일
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>4-4. 폼</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
</head>
<body class="bg-gray-50 p-8">
<form class="space-y-4 max-w-md" onsubmit="event.preventDefault(); alert('제출됨!')">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">이름</label>
<input type="text" id="name" placeholder="홍길동"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm
placeholder:text-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed
transition-shadow duration-150" />
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">이메일</label>
<input type="email" id="email" placeholder="example@email.com"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
</div>
<div>
<label for="pw" class="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
<input type="password" id="pw"
class="w-full rounded-lg border border-red-400 bg-red-50 px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent" />
<p class="mt-1 text-xs text-red-600">비밀번호를 입력해 주세요.</p>
</div>
<div>
<label for="msg" class="block text-sm font-medium text-gray-700 mb-1">메시지</label>
<textarea id="msg" rows="4"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm resize-none
field-sizing-content min-h-24
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="내용을 입력하세요 — 줄이 늘어나면 높이가 자동으로 커진다"></textarea>
</div>
<div>
<label for="role" class="block text-sm font-medium text-gray-700 mb-1">역할</label>
<select id="role"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500
appearance-none bg-no-repeat bg-right"
style="background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); background-position: right 0.75rem center; padding-right: 2rem;">
<option value="">선택하세요</option>
<option value="admin">관리자</option>
<option value="user">일반 사용자</option>
<option value="viewer">뷰어</option>
</select>
</div>
<button type="submit"
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold
py-2.5 rounded-lg transition-colors duration-150
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
제출
</button>
</form>
</body>
</html>
체크박스 · 라디오 · 토글
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>4-4. 체크박스·라디오·토글</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
</head>
<body class="bg-gray-50 p-8 space-y-8">
<div>
<p class="text-sm font-bold text-gray-700 mb-3">체크박스</p>
<label class="flex items-center gap-2 cursor-pointer group">
<input type="checkbox"
class="size-4 rounded border-gray-300 text-blue-600
focus:ring-2 focus:ring-blue-500 cursor-pointer" />
<span class="text-sm text-gray-700 group-hover:text-gray-900">이용약관 동의</span>
</label>
</div>
<fieldset class="space-y-2">
<legend class="text-sm font-bold text-gray-700 mb-2">배송 방법 (라디오)</legend>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="delivery" value="regular"
class="size-4 border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer" />
<span class="text-sm text-gray-700">일반 배송 (3~5일)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="delivery" value="express"
class="size-4 border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer" />
<span class="text-sm text-gray-700">빠른 배송 (1~2일) +₩3,000</span>
</label>
</fieldset>
<div>
<p class="text-sm font-bold text-gray-700 mb-3">토글 스위치</p>
<label class="inline-flex items-center cursor-pointer gap-3">
<span class="text-sm font-medium text-gray-700">알림 수신</span>
<div class="relative">
<input type="checkbox" class="sr-only peer" />
<div class="w-11 h-6 bg-gray-200 peer-checked:bg-blue-600 rounded-full
transition-colors duration-200
peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-500 peer-focus:ring-offset-2">
</div>
<div class="absolute top-0.5 left-0.5 size-5 bg-white rounded-full shadow
transition-transform duration-200 peer-checked:translate-x-5">
</div>
</div>
</label>
</div>
</body>
</html>
4-5. 내비게이션
상단 네비게이션 바 (모바일 메뉴 포함)
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>4-5. 네비게이션</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
</head>
<body class="bg-gray-50">
<nav class="bg-white border-b border-gray-200 sticky top-0 z-50">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="flex items-center justify-between h-16">
<a href="#" class="flex items-center gap-2 font-bold text-lg text-gray-900">
<div class="size-8 bg-blue-600 rounded-lg"></div>
MyApp
</a>
<ul class="hidden md:flex items-center gap-1">
<li><a href="#" class="px-3 py-2 rounded-lg text-sm font-medium text-gray-900 hover:bg-gray-100 transition-colors">홈</a></li>
<li><a href="#" class="px-3 py-2 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors">소개</a></li>
<li><a href="#" class="px-3 py-2 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors">서비스</a></li>
<li><a href="#" class="px-3 py-2 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors">문의</a></li>
</ul>
<div class="hidden md:flex items-center gap-2">
<a href="#" class="text-sm font-medium text-gray-600 hover:text-gray-900 px-3 py-2">로그인</a>
<a href="#" class="bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold
px-4 py-2 rounded-lg transition-colors">시작하기</a>
</div>
<button id="menu-btn" class="md:hidden p-2 rounded-lg text-gray-600 hover:bg-gray-100">
<svg class="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</div>
</div>
<div id="mobile-menu" class="hidden md:hidden border-t border-gray-200 px-4 py-3 space-y-1">
<a href="#" class="block px-3 py-2 rounded-lg text-sm font-medium text-gray-900 hover:bg-gray-100">홈</a>
<a href="#" class="block px-3 py-2 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-100">소개</a>
<a href="#" class="block px-3 py-2 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-100">서비스</a>
<a href="#" class="block px-3 py-2 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-100">문의</a>
</div>
</nav>
<div class="max-w-6xl mx-auto p-8 space-y-4">
<p class="text-gray-500">↓ 스크롤해서 sticky 네비게이션을 확인해 보자.</p>
<div class="h-[200vh]"></div>
</div>
<script>
// 모바일 메뉴 토글
const menuBtn = document.getElementById('menu-btn')
const mobileMenu = document.getElementById('mobile-menu')
menuBtn.addEventListener('click', () => {
mobileMenu.classList.toggle('hidden')
})
</script>
</body>
</html>
4-6. 모달 (Modal)
모달은 3개 레이어로 구성된다: 배경 오버레이 → 모달 패널 → 내부 콘텐츠(헤더/본문/푸터).
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>4-6. 모달</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
</head>
<body class="bg-gray-50 p-8">
<button id="open-modal"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold
px-4 py-2 rounded-lg transition-colors">
모달 열기
</button>
<div id="modal"
class="hidden fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div class="absolute inset-0 bg-black/60" id="modal-backdrop"></div>
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 id="modal-title" class="text-lg font-bold text-gray-900">모달 제목</h2>
<button id="close-modal"
class="p-1 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100
transition-colors focus:outline-none focus:ring-2 focus:ring-gray-400">
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span class="sr-only">닫기</span>
</button>
</div>
<div class="px-6 py-4">
<p class="text-sm text-gray-600">
모달 내용이 여기에 들어간다. 사용자에게 중요한 정보를 표시하거나 확인을 요청할 때 사용한다.
</p>
</div>
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
<button id="cancel-modal"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100
rounded-lg transition-colors">
취소
</button>
<button class="px-4 py-2 text-sm font-semibold bg-blue-600 hover:bg-blue-700
text-white rounded-lg transition-colors">
확인
</button>
</div>
</div>
</div>
<script>
const modal = document.getElementById('modal')
const openBtn = document.getElementById('open-modal')
const closeBtn = document.getElementById('close-modal')
const cancelBtn = document.getElementById('cancel-modal')
const backdrop = document.getElementById('modal-backdrop')
const openModal = () => modal.classList.remove('hidden')
const closeModal = () => modal.classList.add('hidden')
openBtn.addEventListener('click', openModal)
closeBtn.addEventListener('click', closeModal)
cancelBtn.addEventListener('click', closeModal)
backdrop.addEventListener('click', closeModal)
// ESC 키로도 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal()
})
</script>
</body>
</html>
4-7. 드롭다운 메뉴
relative(부모) + absolute(패널) 조합으로 위치를 잡는다. 문서 아무 곳이나 클릭하면 닫히도록 처리한다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>4-7. 드롭다운</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
</head>
<body class="bg-gray-50 p-8">
<div class="relative inline-block" id="dropdown-wrapper">
<button id="dropdown-btn"
class="inline-flex items-center gap-2 px-4 py-2
bg-white border border-gray-300 rounded-lg text-sm font-medium
text-gray-700 hover:bg-gray-50 transition-colors
focus:outline-none focus:ring-2 focus:ring-blue-500">
메뉴
<svg class="size-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="dropdown-menu"
class="hidden absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg
border border-gray-100 py-1 z-10"
role="menu">
<a href="#" class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50" role="menuitem">
<svg class="size-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
프로필
</a>
<a href="#" class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50" role="menuitem">
<svg class="size-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
설정
</a>
<hr class="my-1 border-gray-100" />
<a href="#" class="flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50" role="menuitem">
로그아웃
</a>
</div>
</div>
<script>
const btn = document.getElementById('dropdown-btn')
const menu = document.getElementById('dropdown-menu')
// 버튼 클릭 → 토글 (e.stopPropagation으로 document 클릭과 분리)
btn.addEventListener('click', (e) => {
e.stopPropagation()
menu.classList.toggle('hidden')
})
// 문서 아무 곳 클릭 → 닫기
document.addEventListener('click', () => menu.classList.add('hidden'))
</script>
</body>
</html>
4-8. 알림(Toast / Alert)
색상 계열만 바꿔서 정보(blue), 성공(green), 경고(yellow), 오류(red) 4가지 상태를 표현한다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>4-8. 알림</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
</head>
<body class="bg-gray-50 p-8">
<div class="max-w-lg space-y-4">
<div class="flex items-start gap-3 bg-blue-50 border border-blue-200 text-blue-800
rounded-xl px-4 py-3 text-sm" role="alert">
<svg class="size-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p>새로운 업데이트가 있다. 확인해 보자.</p>
</div>
<div class="flex items-start gap-3 bg-green-50 border border-green-200 text-green-800
rounded-xl px-4 py-3 text-sm" role="alert">
<svg class="size-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<p>저장이 완료됐다.</p>
</div>
<div class="flex items-start gap-3 bg-yellow-50 border border-yellow-200 text-yellow-800
rounded-xl px-4 py-3 text-sm" role="alert">
<svg class="size-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<p>저장공간이 80% 사용됐다.</p>
</div>
<div class="flex items-start gap-3 bg-red-50 border border-red-200 text-red-800
rounded-xl px-4 py-3 text-sm" role="alert">
<svg class="size-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p>오류가 발생했다. 다시 시도해 보자.</p>
</div>
</div>
</body>
</html>
4-9. 상태 변형 (State Variants)
hover / focus / active / disabled
각 의사 클래스의 발동 시점:
| 변형 | 발동 시점 |
|---|---|
hover: | 마우스 커서를 올렸을 때 |
focus: | Tab 키 또는 클릭으로 포커스를 받았을 때 |
active: | 클릭(마우스 누르는 중)하고 있을 때 |
disabled: | disabled 속성이 있을 때 |
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>4-9. 상태 변형</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
</head>
<body class="bg-gray-50 p-8 space-y-12">
<section>
<h2 class="text-lg font-bold mb-4">hover / focus / active / disabled</h2>
<div class="flex gap-4">
<button class="
bg-purple-600
hover:bg-purple-700
active:bg-purple-800
focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2
disabled:opacity-40 disabled:cursor-not-allowed
text-white font-semibold px-4 py-2 rounded-lg
transition-all duration-150">
활성 버튼
</button>
<button disabled class="
bg-purple-600
disabled:opacity-40 disabled:cursor-not-allowed
text-white font-semibold px-4 py-2 rounded-lg">
비활성 버튼
</button>
</div>
</section>
<section>
<h2 class="text-lg font-bold mb-4">group-hover — 부모 hover 시 자식 변경</h2>
<div class="group flex items-center gap-4 p-4 rounded-xl hover:bg-gray-100 cursor-pointer transition-colors max-w-sm border border-gray-200">
<img src="https://picsum.photos/seed/avatar2/100/100" alt=""
class="size-12 rounded-full" />
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900 group-hover:text-blue-600 transition-colors">사용자 이름</p>
<p class="text-sm text-gray-500 truncate">user@example.com</p>
</div>
<svg class="size-5 text-gray-300 group-hover:text-blue-500
opacity-0 group-hover:opacity-100
transition-all duration-200
-translate-x-1 group-hover:translate-x-0"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</div>
</section>
<section>
<h2 class="text-lg font-bold mb-4">peer — 형제 요소 상태 연동 (플로팅 라벨)</h2>
<div class="relative max-w-xs">
<input id="floating-input" type="text" placeholder=" "
class="peer w-full border border-gray-300 rounded-lg px-3 pt-5 pb-2 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label for="floating-input"
class="absolute left-3 top-1.5 text-xs text-gray-400
peer-placeholder-shown:top-3.5 peer-placeholder-shown:text-sm
peer-focus:top-1.5 peer-focus:text-xs peer-focus:text-blue-600
transition-all duration-150 pointer-events-none">
플로팅 라벨
</label>
</div>
</section>
</body>
</html>