05 반응형 디자인과 접근성
반응형 디자인과 접근성
1. Tailwind의 반응형 시스템
Tailwind는 모바일 우선(Mobile First) 방식을 사용한다. 접두사가 없는 클래스는 모든 화면에 적용되고, 접두사가 붙은 클래스는 해당 브레이크포인트 이상에서 적용된다.
1.1. 기본 브레이크포인트
| 접두사 | 최소 너비 | 대상 기기 |
|---|---|---|
| (없음) | 0px | 모든 화면 (모바일 기본) |
sm: | 40rem (640px) | 큰 모바일·소형 태블릿 |
md: | 48rem (768px) | 태블릿 |
lg: | 64rem (1024px) | 노트북·데스크톱 |
xl: | 80rem (1280px) | 와이드 데스크톱 |
2xl: | 96rem (1536px) | 초대형 화면 |
v4 변경사항 — v3에서
sm: 640px등 px 단위였던 것이 v4에서 rem 단위로 변경됐다. 브라우저 폰트 크기 설정을 존중하는 방식으로 개선됐다.
1.2. 모바일 우선 작성 원칙
1<!-- ✅ 올바른 방식: 모바일 먼저, 큰 화면을 sm:/md:/lg:로 확장 -->2<div class="text-sm md:text-base lg:text-lg">3 모바일: 14px → 태블릿: 16px → 데스크톱: 18px4</div>5
6<!-- ❌ 잘못된 방식: 데스크톱 먼저 작성 후 모바일에서 덮어쓰기 -->7<!-- Tailwind는 max-width 쿼리를 기본 제공하지 않으므로 이 방식은 어렵다 -->1<!-- 실전 예시: 반응형 레이아웃 -->2<div class="flex flex-col md:flex-row gap-6">3 <!-- 모바일: 세로 배열 → 태블릿 이상: 가로 배열 -->4 <aside class="w-full md:w-64 flex-shrink-0">사이드바</aside>5 <main class="flex-1">메인 콘텐츠</main>6</div>2. 반응형 유틸리티 패턴
2.1. 반응형 그리드
1<!-- 카드 목록: 모바일 1열 → 태블릿 2열 → 데스크톱 3열 → 와이드 4열 -->2<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">3 <div class="bg-white rounded-2xl shadow p-4">카드 1</div>4 <div class="bg-white rounded-2xl shadow p-4">카드 2</div>5 <div class="bg-white rounded-2xl shadow p-4">카드 3</div>6 <div class="bg-white rounded-2xl shadow p-4">카드 4</div>7</div>2.2. 반응형 요소 표시/숨김
1<!-- 모바일에서 숨기고 데스크톱에서 표시 -->2<div class="hidden lg:block">데스크톱 전용 콘텐츠</div>3
4<!-- 모바일에서 표시하고 데스크톱에서 숨김 -->5<div class="block lg:hidden">모바일 전용 콘텐츠 (햄버거 메뉴 등)</div>6
7<!-- 특정 구간에서만 표시 (sm ~ lg) -->8<div class="hidden sm:block lg:hidden">태블릿 전용</div>2.3. 반응형 타이포그래피
1<!-- 화면 크기에 따른 제목 크기 변화 -->2<h1 class="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-gray-900">3 반응형 제목4</h1>5
6<p class="text-sm sm:text-base md:text-lg leading-relaxed text-gray-600">7 화면 크기에 따라 본문 텍스트도 조정됩니다.8</p>2.4. 반응형 간격
1<section class="px-4 sm:px-6 md:px-8 lg:px-12 py-8 sm:py-12 lg:py-16">2 <div class="max-w-6xl mx-auto">3 <h2 class="text-2xl lg:text-3xl font-bold mb-4 sm:mb-6 lg:mb-8">4 섹션 제목5 </h2>6 <!-- 내용 -->7 </div>8</section>2.5. 반응형 이미지
1<!-- 고정 비율 이미지 컨테이너 -->2<div class="aspect-video sm:aspect-[4/3] lg:aspect-video overflow-hidden rounded-2xl">3 <img src="/hero.jpg" alt="히어로 이미지"4 class="w-full h-full object-cover" />5</div>6
7<!-- 히어로 섹션 -->8<div class="relative h-64 sm:h-80 md:h-96 lg:h-[500px] overflow-hidden">9 <img src="/hero-bg.jpg" alt=""10 class="absolute inset-0 w-full h-full object-cover" />11 <div class="absolute inset-0 bg-black/40 flex items-center justify-center">12 <h1 class="text-3xl md:text-5xl font-bold text-white text-center px-4">13 히어로 타이틀14 </h1>15 </div>16</div>3. v4 커스텀 브레이크포인트
v3에서는 tailwind.config.js의 theme.screens에서 설정했다.
v4에서는 CSS의 @theme에서 정의한다.
1@import "tailwindcss";2
3@theme {4 /* 기존 브레이크포인트 덮어쓰기 */5 --breakpoint-sm: 36rem; /* 576px */6 --breakpoint-md: 48rem; /* 768px */7 --breakpoint-lg: 62rem; /* 992px */8 --breakpoint-xl: 75rem; /* 1200px */9
10 /* 커스텀 브레이크포인트 추가 */11 --breakpoint-xs: 22.5rem; /* 360px — 소형 모바일 */12 --breakpoint-3xl: 112rem; /* 1792px — 초대형 */13}1<!-- 커스텀 브레이크포인트 사용 -->2<div class="text-xs xs:text-sm sm:text-base">작은 화면도 대응</div>3<div class="max-w-screen-xl 3xl:max-w-screen-3xl mx-auto">초대형 레이아웃</div>4. v4 컨테이너 쿼리 (Container Queries)
v4 주요 신기능 — 뷰포트가 아닌 부모 컨테이너 크기에 따라 스타일을 적용할 수 있다. v3에서는
@tailwindcss/container-queries플러그인이 필요했지만 v4에서는 기본 내장됐다.
4.1. 기본 사용법
1<!-- @container 로 컨테이너 지정 -->2<div class="@container">3 <!-- @sm:, @md: 등으로 컨테이너 크기 기반 스타일 적용 -->4 <div class="flex flex-col @md:flex-row gap-4">5 <img src="/product.jpg" alt="" class="w-full @md:w-48 rounded-xl object-cover" />6 <div>7 <h3 class="text-lg @md:text-xl font-bold">상품명</h3>8 <p class="text-sm text-gray-500">설명...</p>9 </div>10 </div>11</div>4.2. 컨테이너 쿼리 브레이크포인트
| 접두사 | 컨테이너 최소 너비 |
|---|---|
@xs: | 20rem (320px) |
@sm: | 24rem (384px) |
@md: | 28rem (448px) |
@lg: | 32rem (512px) |
@xl: | 36rem (576px) |
@2xl: | 42rem (672px) |
@3xl: | 48rem (768px) |
4.3. 컨테이너 쿼리 실전 예시
1<!-- 사이드바 너비에 따라 카드 레이아웃 변경 -->2<div class="@container" id="card-container">3 <div class="grid grid-cols-1 @sm:grid-cols-2 @lg:grid-cols-3 gap-4">4 <div class="bg-white rounded-xl shadow p-4">카드 A</div>5 <div class="bg-white rounded-xl shadow p-4">카드 B</div>6 <div class="bg-white rounded-xl shadow p-4">카드 C</div>7 </div>8</div>9
10<!-- 컨테이너 이름 지정 (중첩 시 구분) -->11<div class="@container/main">12 <div class="@container/sidebar">13 <p class="text-sm @sm/main:text-base @md/main:text-lg">14 main 컨테이너 크기 기준15 </p>16 </div>17</div>5. 접근성(Accessibility) 모범 사례
5.1. 색상 대비
WCAG 기준: 일반 텍스트 4.5:1 이상, 큰 텍스트(18px+) 3:1 이상.
1<!-- ✅ 충분한 대비 예시 -->2<p class="text-gray-900 bg-white">충분한 대비 (21:1)</p>3<p class="text-gray-700 bg-white">충분한 대비 (약 10:1)</p>4<p class="text-white bg-blue-700">충분한 대비 (약 5.9:1)</p>5
6<!-- ❌ 대비 부족 예시 -->7<!-- <p class="text-gray-400 bg-white">대비 부족 (약 2.5:1)</p> -->5.2. 포커스 표시 (focus-visible)
키보드 사용자를 위해 포커스 상태는 반드시 시각적으로 표시해야 한다.
focus-visible:은 키보드 탐색 시에만 링을 표시한다(마우스 클릭 시 미표시).
1<!-- ✅ 권장: focus-visible 사용 -->2<button class="focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-5003 focus-visible:ring-offset-2 bg-blue-600 text-white px-4 py-2 rounded-lg">4 접근성 버튼5</button>6
7<!-- 링크에도 적용 -->8<a href="#"9 class="text-blue-600 underline focus:outline-none10 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:rounded">11 접근성 링크12</a>5.3. 스크린 리더 전용 텍스트
시각적으로는 숨기되 스크린 리더에는 읽히는 텍스트.
1<!-- sr-only: 스크린 리더에만 노출 -->2<button class="bg-gray-200 p-2 rounded-lg hover:bg-gray-300">3 <svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">4 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>5 </svg>6 <span class="sr-only">닫기</span>7</button>8
9<!-- 아이콘만 있는 버튼 패턴 -->10<button aria-label="즐겨찾기 추가" class="p-2 rounded-full hover:bg-gray-100">11 <svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">12 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"13 d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>14 </svg>15</button>5.4. 의미론적 HTML + ARIA
1<!-- 내비게이션 -->2<nav aria-label="주 메뉴">3 <ul class="flex gap-4">4 <li><a href="/" class="font-medium" aria-current="page">홈</a></li>5 <li><a href="/about" class="text-gray-600 hover:text-gray-900">소개</a></li>6 </ul>7</nav>8
9<!-- 상태 변경 알림 -->10<div role="status" aria-live="polite" class="sr-only" id="live-region">11 <!-- JavaScript로 메시지 주입 시 스크린 리더가 읽음 -->12</div>13
14<!-- 드롭다운 메뉴 ARIA -->15<div class="relative">16 <button17 aria-haspopup="true"18 aria-expanded="false"19 id="menu-button"20 class="px-4 py-2 bg-white border rounded-lg"21 >22 메뉴 열기23 </button>24 <ul25 role="menu"26 aria-labelledby="menu-button"27 class="absolute mt-2 bg-white shadow-lg rounded-xl py-1"28 >29 <li role="menuitem"><a href="#" class="block px-4 py-2 hover:bg-gray-50">항목 1</a></li>30 <li role="menuitem"><a href="#" class="block px-4 py-2 hover:bg-gray-50">항목 2</a></li>31 </ul>32</div>33
34<!-- 로딩 스피너 -->35<div role="status" class="inline-flex items-center gap-2">36 <svg class="animate-spin size-5 text-blue-600" fill="none" viewBox="0 0 24 24" aria-hidden="true">37 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>38 <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>39 </svg>40 <span class="sr-only">로딩 중...</span>41</div>42
43<!-- 진행 바 -->44<div role="progressbar" aria-valuenow="60" aria-valuemin="0" aria-valuemax="100"45 aria-label="파일 업로드 진행도 60%"46 class="w-full bg-gray-200 rounded-full h-2">47 <div class="bg-blue-600 h-2 rounded-full transition-all duration-300 w-[60%]"></div>48</div>5.5. 폼 접근성
1<form class="space-y-4">2 <!-- ✅ label의 for와 input의 id 연결 필수 -->3 <div>4 <label for="search" class="block text-sm font-medium text-gray-700 mb-1">5 검색어6 <span class="text-red-500 ml-0.5" aria-hidden="true">*</span>7 <span class="sr-only">(필수)</span>8 </label>9 <input10 type="search"11 id="search"12 name="search"13 required14 aria-required="true"15 aria-describedby="search-hint search-error"16 class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm17 focus:outline-none focus:ring-2 focus:ring-blue-500"18 />19 <p id="search-hint" class="mt-1 text-xs text-gray-500">20 2글자 이상 입력하세요.21 </p>22 <!-- 에러 메시지 (조건부 표시) -->23 <p id="search-error" role="alert" class="mt-1 text-xs text-red-600 hidden">24 필수 입력 항목입니다.25 </p>26 </div>27
28 <!-- fieldset + legend: 관련 그룹 묶기 -->29 <fieldset class="border border-gray-200 rounded-xl p-4">30 <legend class="text-sm font-medium text-gray-700 px-1">배송 방법</legend>31 <div class="space-y-2 mt-2">32 <label class="flex items-center gap-2">33 <input type="radio" name="shipping" value="standard"34 class="text-blue-600 focus:ring-blue-500" />35 <span class="text-sm text-gray-700">일반 배송</span>36 </label>37 <label class="flex items-center gap-2">38 <input type="radio" name="shipping" value="express"39 class="text-blue-600 focus:ring-blue-500" />40 <span class="text-sm text-gray-700">빠른 배송</span>41 </label>42 </div>43 </fieldset>44</form>5.6. 키보드 접근성 — 트랩 포커스 (모달)
1// 모달 내 포커스 트랩2function trapFocus(modalElement) {3 const focusable = modalElement.querySelectorAll(4 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'5 )6 const first = focusable[0]7 const last = focusable[focusable.length - 1]8
9 modalElement.addEventListener('keydown', (e) => {10 if (e.key !== 'Tab') return11 if (e.shiftKey) {12 if (document.activeElement === first) { e.preventDefault(); last.focus() }13 } else {14 if (document.activeElement === last) { e.preventDefault(); first.focus() }15 }16 })17}1<!-- 스킵 내비게이션 (키보드 사용자가 메뉴를 건너뛰어 본문으로 이동) -->2<a href="#main-content"3 class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-44 focus:z-50 focus:bg-blue-600 focus:text-white5 focus:px-4 focus:py-2 focus:rounded-lg focus:font-medium">6 본문으로 건너뛰기7</a>8
9<nav>...</nav>10<main id="main-content" tabindex="-1">...</main>6. v4 not-* variant (신규)
특정 상태가 아닐 때 스타일을 적용한다.
1<!-- 마지막 자식이 아닌 항목에만 하단 테두리 적용 -->2<ul>3 <li class="not-last:border-b border-gray-200 py-3 px-4">항목 1</li>4 <li class="not-last:border-b border-gray-200 py-3 px-4">항목 2</li>5 <li class="not-last:border-b border-gray-200 py-3 px-4">항목 3</li>6</ul>7
8<!-- 비활성화 상태가 아닐 때 hover 적용 -->9<button class="not-disabled:hover:bg-blue-700 bg-blue-600 text-white px-4 py-2 rounded-lg10 disabled:opacity-50 disabled:cursor-not-allowed">11 버튼12</button>7. 성능 최적화
7.1. v4에서 자동 처리되는 것들
v3에서 수동으로 설정해야 했던 항목들이 v4에서 자동화됐다.
| v3 수동 작업 | v4 자동 처리 |
|---|---|
purge 옵션 설정 | 사용한 클래스만 자동 포함 |
mode: 'jit' 설정 | 항상 JIT 모드 |
@tailwind base/components/utilities | @import "tailwindcss" 한 줄 |
7.2. CSS 크기 최소화 전략
1@import "tailwindcss";2
3@theme {4 /* 필요한 색상만 정의 — 미정의 색상은 생성되지 않음 */5 --color-brand-500: #3b82f6;6 --color-brand-600: #2563eb;7
8 /* 기본 gray 계열 외에 불필요한 색상 팔레트 제거 */9 /* v4에서는 사용하지 않은 유틸리티는 자동으로 번들에서 제외됨 */10}7.3. 레이어 분리
1@import "tailwindcss";2
3/* base: HTML 요소 기본 스타일 초기화·설정 */4@layer base {5 *, *::before, *::after { box-sizing: border-box; }6 html { font-family: theme(--font-sans); }7 h1 { @apply text-3xl font-bold text-gray-900; }8 h2 { @apply text-2xl font-semibold text-gray-900; }9 a { @apply text-blue-600 hover:text-blue-700; }10}11
12/* components: 재사용 컴포넌트 클래스 */13@layer components {14 .container-main {15 @apply max-w-6xl mx-auto px-4 sm:px-6 lg:px-8;16 }17}18
19/* utilities: 커스텀 유틸리티 */20@layer utilities {21 .text-balance { text-wrap: balance; }22}7.4. 중요(Critical) CSS 인라인 처리
초기 렌더링을 빠르게 하려면 화면 상단(above-the-fold) 스타일을 인라인으로 삽입한다.
1<!DOCTYPE html>2<html>3<head>4 <!-- 중요 CSS 인라인 (빌드 도구로 자동화 권장) -->5 <style>6 /* 히어로 섹션, 네비게이션 등 최초 표시 요소 스타일만 */7 .nav { display: flex; align-items: center; padding: 1rem; }8 .hero { min-height: 100svh; background: #1e3a8a; }9 </style>10
11 <!-- 나머지 CSS는 비동기 로드 -->12 <link rel="preload" href="/dist/style.css" as="style" onload="this.onload=null;this.rel='stylesheet'">13 <noscript><link rel="stylesheet" href="/dist/style.css"></noscript>14</head>7.5. Vite 프로덕션 빌드
1# 개발 서버2npm run dev3
4# 프로덕션 빌드 (자동 최소화·트리셰이킹)5npm run build6
7# 빌드 결과물 미리보기8npm run preview1// vite.config.js — CSS 최소화 설정2import { defineConfig } from 'vite'3import tailwindcss from '@tailwindcss/vite'4
5export default defineConfig({6 plugins: [tailwindcss()],7 build: {8 cssMinify: true, // CSS 압축9 rollupOptions: {10 output: {11 manualChunks: {12 vendor: ['react', 'react-dom'], // 벤더 청크 분리13 }14 }15 }16 }17})8. 반응형 + 접근성 통합 실전 예시
8.1. 접근성을 갖춘 반응형 카드 목록
1<section aria-labelledby="products-heading">2 <h2 id="products-heading" class="text-2xl font-bold text-gray-900 mb-6">3 상품 목록4 </h2>5
6 <!-- 결과 수 알림 (스크린 리더) -->7 <p role="status" class="sr-only">총 6개의 상품이 있습니다.</p>8
9 <ul class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"10 role="list">11 <li>12 <article class="bg-white rounded-2xl shadow hover:shadow-lg transition-shadow13 focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2">14 <img src="/product1.jpg" alt="스니커즈 A - 흰색 운동화" class="w-full h-48 object-cover rounded-t-2xl" />15 <div class="p-4">16 <h3 class="font-bold text-gray-900">스니커즈 A</h3>17 <p class="text-sm text-gray-500 mt-1">편안하고 가벼운 일상 스니커즈</p>18 <div class="flex items-center justify-between mt-3">19 <span class="text-lg font-bold">₩89,000</span>20 <a href="/products/1"21 class="text-sm font-medium text-blue-600 hover:text-blue-80022 focus:outline-none rounded23 after:absolute after:inset-0"24 aria-label="스니커즈 A 상세 보기">25 상세 보기26 </a>27 </div>28 </div>29 </article>30 </li>31 <!-- 나머지 카드... -->32 </ul>33</section>8.2. 반응형 테이블
1<!-- 모바일: 스크롤, 데스크톱: 전체 표시 -->2<div class="overflow-x-auto rounded-2xl border border-gray-200">3 <table class="min-w-full divide-y divide-gray-200">4 <thead class="bg-gray-50">5 <tr>6 <th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">7 이름8 </th>9 <th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider hidden sm:table-cell">10 이메일11 </th>12 <th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">13 상태14 </th>15 <th scope="col" class="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">16 작업17 </th>18 </tr>19 </thead>20 <tbody class="bg-white divide-y divide-gray-200">21 <tr class="hover:bg-gray-50 transition-colors">22 <td class="px-4 py-3">23 <div class="flex items-center gap-3">24 <div class="size-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-xs font-bold flex-shrink-0">25 홍26 </div>27 <span class="text-sm font-medium text-gray-900">홍길동</span>28 </div>29 </td>30 <td class="px-4 py-3 hidden sm:table-cell">31 <span class="text-sm text-gray-600">hong@example.com</span>32 </td>33 <td class="px-4 py-3">34 <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">35 활성36 </span>37 </td>38 <td class="px-4 py-3 text-right">39 <button class="text-sm text-blue-600 hover:text-blue-800 font-medium40 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded">41 편집42 </button>43 </td>44 </tr>45 </tbody>46 </table>47</div>9. 접근성 검사 체크리스트
| 항목 | 확인 방법 | Tailwind 관련 클래스 |
|---|---|---|
| 색상 대비 | WebAIM Contrast Checker | text-gray-900, bg-white 등 명도 조합 확인 |
| 포커스 표시 | Tab키로 탐색 | focus-visible:ring-2 focus-visible:ring-{color} |
| 이미지 대체 텍스트 | 개발자 도구 확인 | alt 속성 작성, 장식 이미지는 alt="" |
| 키보드 탐색 | Tab / Shift+Tab / Enter / Space | 모든 인터랙티브 요소 도달 가능 여부 |
| 스크린 리더 | NVDA(Windows), VoiceOver(Mac) | sr-only, aria-label, role 속성 |
| 의미론적 HTML | 개발자 도구 Accessibility Tree | <nav>, <main>, <section>, <h1>~<h6> 올바른 사용 |
| 반응형 | 기기별 실제 테스트 | sm:, md:, lg: 브레이크포인트 |
| 링크 맥락 | 스크린 리더로 링크 목록 탐색 | ”자세히 보기” 대신 “상품명 자세히 보기” |
핵심 정리
- 모바일 우선: 접두사 없음 = 모바일,
sm:md:lg:로 확장- v4 브레이크포인트: rem 단위로 변경 (
sm: 40rem= 640px)- 컨테이너 쿼리:
@container+@sm:@md:로 부모 크기 기반 스타일 (v4 기본 내장)- 커스텀 브레이크포인트:
tailwind.config.js→ CSS@theme--breakpoint-*- 접근성:
focus-visible:ring-2,sr-only,aria-*, 의미론적 HTMLnot-*variant (v4 신규): 특정 상태가 아닐 때 적용- 성능: v4에서 purge·JIT 자동화,
@layer base/components/utilities구조 활용