Tailwind CSS의 sm, md, lg 반응형 클래스는 어떤 폭에서 적용되나요?
Tailwind는 작은 화면의 기본 클래스 위에 sm·md·lg 조건을 더합니다. 기기 이름보다 해당 폭 이상에서 바뀌는 스타일로 읽고, 기본 한 열 카드가 넓은 화면에서 여러 열이 되도록 확장합니다.
적용 조건·범위: 첫 명령이 표시하는 로컬 주소를 브라우저에서 열고, 두 번째 명령은 별도 터미널에서 실행합니다. 기본 루트 16px일 때 375px은 1열, 800px은 2열, 1100px은 3열입니다. sm은 작은 화면 전용이 아닌 최소 40rem 조건입니다.
breakpoint는 최소 너비 기준입니다
Tailwind의 기본 breakpoint는 최소 너비 기준입니다.
대표 값은 다음과 같습니다.
sm
→ 40rem 이상
md
→ 48rem 이상
lg
→ 64rem 이상
따라서 sm:은 “모바일 스타일”이라는 뜻이 아닙니다.
다음 예제를 보겠습니다.
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
...
</div>
해석:
기본
→ 1열
40rem 이상
→ 2열
64rem 이상
→ 3열
작은 화면의 스타일에는 prefix를 붙이지 않습니다.
mobile-first가 중요한 이유
다음 코드는 작은 화면부터 읽을 수 있습니다.
<div class="px-4 md:px-6 lg:px-8">
기본 좌우 padding이 4이고, 화면이 커지면서 6, 8로 확장됩니다.
반대로 데스크톱 스타일을 기본값으로 만들고 모바일에서 계속 되돌리는 방식은 class가 복잡해질 수 있습니다.
반응형 Flex
<div class="flex flex-col gap-3 md:flex-row md:items-center">
...
</div>
작은 화면에서는 세로로 쌓고, md 이상에서 가로로 바꿉니다.
TaskForm처럼 입력창과 버튼의 공간이 부족할 수 있는 UI에 적용할 수 있습니다.
반응형 Grid
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
...
</div>
작은 화면에서는 요약 카드가 세로로 쌓이고, sm 이상에서 3열이 됩니다.
카드 내용이 짧다면 sm부터 3열로 바꿀 수 있고, 내용이 길다면 md까지 1열 또는 2열을 유지할 수 있습니다.
breakpoint는 디바이스 이름보다 콘텐츠가 실제로 깨지는 지점을 기준으로 선택합니다.
max-* variant
특정 범위 이하에서만 스타일을 적용하는 max variant도 사용할 수 있습니다.
<div class="md:max-lg:flex">
...
</div>
하지만 기본 과정에서는 mobile-first min-width variant로 충분한 경우가 많습니다.
조건이 복잡해질 때만 범위 variant를 검토합니다.
자주 발생하는 실수
sm:을 모바일 전용으로 생각합니다
sm은 40rem 이상입니다. 작은 화면 기본값에는 prefix를 붙이지 않습니다.
breakpoint마다 모든 class를 다시 작성합니다
바뀌는 속성만 breakpoint prefix로 덮어씁니다.
grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3
화면 너비만 보고 breakpoint를 고릅니다
실제 콘텐츠가 언제 좁아지는지 브라우저를 줄여가며 확인합니다.
학습 완료 체크리스트
- mobile-first 반응형을 설명할 수 있습니다.
- sm·md·lg가 최소 너비 variant라는 것을 이해했습니다.
- 기본 class와 responsive class를 함께 사용할 수 있습니다.
- Flex 방향과 Grid 열 수를 breakpoint에서 변경할 수 있습니다.
- 실제 콘텐츠가 깨지는 지점으로 breakpoint를 선택할 수 있습니다.
독립 실습: 프로젝트 없이 결과 확인
크게 보기의 폭을 바꾸어 반응형 배치 차이를 확인하세요.
예제 크게 보기 (새 탭)같은 카드 세 개를 화면 폭에 따라 재배치합니다.
준비와 실행 파일
HTML의 요소·속성과 CSS 속성을 알고, 이 JSX 예제는 React 함수 컴포넌트의 반환 구조를 읽을 수 있어야 합니다. 새 Vite React + TypeScript 앱에서 Tailwind v4 설치의 plugin·CSS 연결 절차를 먼저 완료합니다. 생성된 index.html의 root를 유지하고 아래 세 파일을 각 경로에 저장합니다. TaskFlow 상태·컴포넌트·서버는 사용하지 않습니다.
src/App.tsx 전체 파일
export default function CardGrid() {
return (
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{['A', 'B', 'C'].map((item) => (
<article
key={item}
className="border border-gray-300 bg-white p-4"
>
<h2 className="text-lg font-bold">
카드 {item}
</h2>
</article>
))}
</section>
);
}
src/index.css 전체 파일
@import "tailwindcss";
src/main.tsx 전체 파일
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(<StrictMode><App /></StrictMode>);
실행과 예상 결과
npm run dev
npm run build
첫 명령이 표시하는 로컬 주소를 브라우저에서 열고, 두 번째 명령은 별도 터미널에서 실행합니다. 기본 루트 16px일 때 375px은 1열, 800px은 2열, 1100px은 3열입니다. sm은 작은 화면 전용이 아닌 최소 40rem 조건입니다.
작은 변형으로 확인하기
sm:grid-cols-2를 md:grid-cols-2로 바꾸면 700px에서 1열로 남습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Tailwind CSS로 앱 화면을 다듬습니다.
작은 화면을 기본으로 TaskFlow를 정리하고 sm·md 이상에서 레이아웃을 확장합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 7단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)실행 가능한 시작본과 이번 단계
이번 단계 시작 ZIP을 새 폴더에 압축 해제합니다. 이 ZIP은 직전 단계의 완료 파일과 같으며 앞 글의 코드를 따로 모을 필요가 없습니다. 이번 단계 완료 ZIP은 다른 폴더에 풀어 비교합니다.
TaskSummary는 1열→sm 3열, TaskForm은 세로→sm 가로로 바꿉니다. 공통 페이지 여백은 AppLayout에서 조절합니다.
src/components/AppLayout.tsx: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.src/components/TaskForm.tsx: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.src/components/TaskSummary.tsx: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.
짧은 코드 예제는 변경 위치를 설명합니다. 파일 전체를 작성하거나 교체할 때는 완료 파일 전체를 사용하세요. 생략 표시가 있는 부분 코드를 파일 전체로 저장하지 않습니다.
실행: Node.js 22.12 이상에서 ZIP 루트의 package.json·package-lock.json을 유지하고 npm ci 후 npm run dev를 실행합니다. 별도 터미널에서 npm run build를 실행합니다. React·라우터·기존 상태 구조를 유지하고 Tailwind 스타일만 단계적으로 적용합니다.
직접 확인할 결과: 320px에서 요약은 1열, 768px에서는 3열이며 입력과 버튼이 읽힙니다.
이 시작본은 React step-28의 실제 전체 소스입니다. 기존 글의 일반화된 App.tsx/props 조각과 달리 실제 라우터의 AppLayout·Context·RHF API를 유지한 위치에 반영했습니다. README와 CHANGES.md에 각 파일의 변경 diff가 포함되어 있습니다.
전체 파일·코드 보기
완료 파일 전체
index.html 전체 파일
<!doctype html>
<html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>TaskFlow</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
package.json 전체 파일
{
"name": "taskflow-step-28-library-choice",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0",
"react-hook-form": "7.88.0",
"react-router": "7.18.4"
},
"devDependencies": {
"typescript": "7.0.2",
"vite": "8.3.0",
"@vitejs/plugin-react": "6.1.1",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/node": "26.6.2",
"@tailwindcss/vite": "4.3.3",
"tailwindcss": "4.3.3"
}
}
src/App.tsx 전체 파일
import {
Navigate,
Route,
Routes,
} from 'react-router';
import AppLayout from './components/AppLayout';
import SettingsPage from './pages/SettingsPage';
import TaskDetailPage from './pages/TaskDetailPage';
import TaskListPage from './pages/TaskListPage';
export default function App() {
return (
<Routes>
<Route element={<AppLayout />}>
<Route
index
element={<Navigate to="/tasks" replace />}
/>
<Route
path="/tasks"
element={<TaskListPage />}
/>
<Route
path="/tasks/:taskId"
element={<TaskDetailPage />}
/>
<Route
path="/settings"
element={<SettingsPage />}
/>
</Route>
</Routes>
);
}
src/actions/saveProfile.ts 전체 파일
export type SaveProfileState = {
message: string;
};
export async function saveProfile(
_previousState: SaveProfileState,
formData: FormData,
): Promise<SaveProfileState> {
const displayName = String(
formData.get('displayName') ?? '',
).trim();
await new Promise((resolve) =>
setTimeout(resolve, 500),
);
if (!displayName) {
return {
message: '이름을 입력해 주세요.',
};
}
return {
message: '프로필을 저장했습니다.',
};
}
src/actions/updateTask.ts 전체 파일
export async function updateTaskOnServer(
taskId: string,
): Promise<void> {
await new Promise((resolve) =>
setTimeout(resolve, 600),
);
if (!taskId || taskId === 'fail') {
throw new Error('작업 ID가 없습니다.');
}
}
src/components/AppLayout.tsx 전체 파일
import {
NavLink,
Outlet,
} from 'react-router';
import Header from './Header';
export default function AppLayout() {
return (
<main className="mx-auto w-full max-w-3xl px-4 py-8 sm:px-6 sm:py-10 lg:py-12 text-gray-950">
<Header />
<nav aria-label="주요 메뉴">
<NavLink to="/tasks">
작업
</NavLink>
{' · '}
<NavLink to="/settings">
설정
</NavLink>
</nav>
<Outlet />
</main>
);
}
src/components/AsyncDemo.tsx 전체 파일
import { useState } from 'react';
import OptimisticTaskList from './OptimisticTaskList';
import type { Task } from '../types/task';
export default function AsyncDemo() {
const [tasks, setTasks] = useState<Task[]>([{id:'success',title:'저장 성공 실습',completed:false},{id:'fail',title:'저장 실패·복구 실습',completed:false}]);
return <details><summary>낙관적 UI 실습</summary><p>실제 서버가 아닌 지연 모의 요청입니다. 아래 목록은 기본 작업 목록과 분리됩니다.</p>
<OptimisticTaskList tasks={tasks} onCommitToggle={id => setTasks(previous => previous.map(task => task.id === id ? {...task,completed:!task.completed} : task))} /></details>;
}
src/components/Header.tsx 전체 파일
export default function Header() {
return (
<header>
<p className="mb-2 text-sm font-medium text-gray-500">
React Learning Project
</p>
<h1 className="text-3xl font-bold text-black">
TaskFlow
</h1>
<p className="mt-3 text-base text-gray-600">
오늘 할 일을 하나씩 정리해 보세요.
</p>
</header>
);
}
src/components/NetworkStatus.tsx 전체 파일
import { useState } from 'react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
export default function NetworkStatus() {
const [showDebugMessage, setShowDebugMessage] = useState(false);
const isOnline = useNetworkStatus(showDebugMessage);
return <section aria-label="네트워크 상태"><p>네트워크: {isOnline ? '온라인' : '오프라인'}</p>
<label><input type="checkbox" checked={showDebugMessage} onChange={e => setShowDebugMessage(e.target.checked)} />네트워크 로그 표시</label></section>;
}
src/components/OptimisticTaskList.tsx 전체 파일
import {
useTransition,
useOptimistic,
useState,
} from 'react';
import { updateTaskOnServer } from '../actions/updateTask';
import type { Task } from '../types/task';
type OptimisticTaskListProps = {
tasks: Task[];
onCommitToggle: (taskId: string) => void;
};
export default function OptimisticTaskList({
tasks,
onCommitToggle,
}: OptimisticTaskListProps) {
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] =
useState('');
const [optimisticTasks, toggleOptimistic] =
useOptimistic(
tasks,
(currentTasks, taskId: string) =>
currentTasks.map((task) =>
task.id === taskId
? {
...task,
completed: !task.completed,
}
: task,
),
);
const handleToggle = (taskId: string) => {
setErrorMessage('');
startTransition(async () => {
toggleOptimistic(taskId);
try {
await updateTaskOnServer(taskId);
startTransition(() => onCommitToggle(taskId));
} catch {
setErrorMessage(
'저장에 실패했습니다. 기존 상태로 돌아갑니다.',
);
}
});
};
return (
<section>
{errorMessage ? (
<p role="alert">{errorMessage}</p>
) : null}
<ul>
{optimisticTasks.map((task) => (
<li key={task.id}>
<button
type="button"
disabled={isPending}
onClick={() => handleToggle(task.id)}
>
{task.completed ? '완료 취소' : '완료'}
</button>
<span>{task.title}</span>
</li>
))}
</ul>
</section>
);
}
src/components/ProfileSaveForm.tsx 전체 파일
import { useActionState } from 'react';
import {
saveProfile,
type SaveProfileState,
} from '../actions/saveProfile';
const initialState: SaveProfileState = {
message: '',
};
export default function ProfileSaveForm() {
const [state, formAction, isPending] =
useActionState(saveProfile, initialState);
return (
<form action={formAction}>
<label>
표시 이름
<input name="displayName" />
</label>
<button disabled={isPending}>
{isPending ? '저장 중...' : '저장'}
</button>
<p>{state.message}</p>
</form>
);
}
src/components/StateResetDemo.tsx 전체 파일
import { useState } from 'react';
import TaskEditor from './TaskEditor';
import type { Task } from '../types/task';
const examples: Task[] = [
{ id: 'example-1', title: '첫 번째 작업', completed: false },
{ id: 'example-2', title: '두 번째 작업', completed: false },
];
export default function StateResetDemo() {
const [selectedId, setSelectedId] = useState(examples[0].id);
const selectedTask = examples.find(task => task.id === selectedId)!;
return <details><summary>state 초기화 실습</summary>
<p>입력값은 저장되지 않습니다. 작업을 바꾸면 key가 달라져 입력값이 초기화됩니다.</p>
{examples.map(task => <button type="button" key={task.id} onClick={() => setSelectedId(task.id)}>{task.title} 선택</button>)}
<TaskEditor key={selectedTask.id} task={selectedTask} />
</details>;
}
src/components/StatsSkeleton.tsx 전체 파일
export default function StatsSkeleton() {
return (
<section aria-busy="true">
<h2>작업 통계</h2>
<p>통계를 불러오는 중...</p>
</section>
);
}
src/components/TaskCard.tsx 전체 파일
import type { ReactNode } from 'react';
type TaskCardProps = {
children: ReactNode;
actions?: ReactNode;
};
export default function TaskCard({
children,
actions,
}: TaskCardProps) {
return (
<li className="border border-gray-300 bg-white p-4">
<div className="flex flex-wrap items-center gap-3">
{children}
</div>
{actions ? (
<div className="task-card__actions">
{actions}
</div>
) : null}
</li>
);
}
src/components/TaskEditor.tsx 전체 파일
import { useState } from 'react';
import type { Task } from '../types/task';
type TaskEditorProps = {
task: Task;
};
export default function TaskEditor({
task,
}: TaskEditorProps) {
const [title, setTitle] = useState(task.title);
return (
<section>
<h2>작업 편집</h2>
<label htmlFor="editor-title">편집 입력 연습</label>
<input id="editor-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
<p>선택한 작업 ID: {task.id}</p>
</section>
);
}
src/components/TaskFilter.tsx 전체 파일
import type {
Dispatch,
SetStateAction,
} from 'react';
import type { TaskStatusFilter } from '../types/task';
type TaskFilterProps = {
keyword: string;
onKeywordChange: Dispatch<SetStateAction<string>>;
status: TaskStatusFilter;
onStatusChange: Dispatch<
SetStateAction<TaskStatusFilter>
>;
onReset: () => void;
};
export default function TaskFilter({
keyword,
onKeywordChange,
status,
onStatusChange,
onReset,
}: TaskFilterProps) {
return (
<section>
<label htmlFor="task-keyword">
작업 검색
</label>
<input
id="task-keyword"
value={keyword}
onChange={(event) =>
onKeywordChange(event.target.value)
}
/>
<label htmlFor="task-status">
상태
</label>
<select
id="task-status"
value={status}
onChange={(event) =>
onStatusChange(
event.target.value as TaskStatusFilter,
)
}
>
<option value="all">전체</option>
<option value="active">진행 중</option>
<option value="completed">완료</option>
</select>
<button type="button" onClick={onReset}>
필터 초기화
</button>
</section>
);
}
src/components/TaskForm.tsx 전체 파일
import { useForm } from 'react-hook-form';
import { useTaskContext } from '../context/TaskContext';
type TaskFormValues = {
title: string;
};
export default function TaskForm() {
const { dispatch } = useTaskContext();
const {
register,
handleSubmit,
setValue,
setFocus,
formState: { errors },
} = useForm<TaskFormValues>({
defaultValues: {
title: '',
},
});
const onSubmit = (values: TaskFormValues) => {
const title = values.title.trim();
dispatch({
type: 'task/added',
task: {
id: crypto.randomUUID(),
title,
completed: false,
},
});
setValue('title', '');
setFocus('title');
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="flex flex-col gap-2 sm:flex-row">
<label htmlFor="task-title">
새 작업
</label>
<input
className="min-h-11 min-w-0 grow border border-gray-300 bg-white px-3 py-2 text-gray-950"
id="task-title"
aria-invalid={Boolean(errors.title)}
aria-describedby={
errors.title ? 'task-title-error' : undefined
}
{...register('title', {
setValueAs: (value: string) => value.trim(),
required: '작업 제목을 입력해 주세요.',
minLength: {
value: 2,
message: '작업 제목은 2자 이상 입력해 주세요.',
},
validate: (value) =>
value.trim().length > 0 ||
'공백만 입력할 수 없습니다.',
})}
/>
{errors.title && (
<p id="task-title-error" role="alert">
{errors.title.message}
</p>
)}
<button type="submit" className="min-h-11 shrink-0 border border-black bg-black px-4 py-2 text-white">
추가
</button>
</form>
);
}
src/components/TaskItem.tsx 전체 파일
import { Link } from 'react-router';
import TaskCard from './TaskCard';
import { useTaskContext } from '../context/TaskContext';
import type { Task } from '../types/task';
type TaskItemProps = {
task: Task;
};
export default function TaskItem({
task,
}: TaskItemProps) {
const { dispatch } = useTaskContext();
return (
<TaskCard>
<button
className="min-h-11 shrink-0 border border-gray-400 px-3 py-2"
type="button"
onClick={() =>
dispatch({
type: 'task/toggled',
id: task.id,
})
}
>
{task.completed ? '완료 취소' : '완료'}
</button>
<span className="min-w-0 grow text-gray-900">{task.title}</span>
<button
className="min-h-11 shrink-0 border border-gray-400 px-3 py-2"
type="button"
onClick={() =>
dispatch({
type: 'task/deleted',
id: task.id,
})
}
>
삭제
</button>
<Link to={`/tasks/${task.id}`}>상세</Link>
</TaskCard>
);
}
src/components/TaskList.tsx 전체 파일
import { useTaskContext } from '../context/TaskContext';
import type { Task } from '../types/task';
import TaskItem from './TaskItem';
type TaskListProps = {
tasks: Task[];
};
export default function TaskList({
tasks,
}: TaskListProps) {
const { tasks: allTasks } = useTaskContext();
if (allTasks.length === 0) {
return <p>등록된 작업이 없습니다.</p>;
}
if (tasks.length === 0) {
return <p>조건에 맞는 작업이 없습니다.</p>;
}
return (
<ul className="flex flex-col gap-3">
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
);
}
src/components/TaskStats.tsx 전체 파일
import type { Task } from '../types/task';
type TaskStatsProps = {
tasks: Task[];
};
export default function TaskStats({
tasks,
}: TaskStatsProps) {
const completedCount = tasks.filter(
(task) => task.completed,
).length;
return (
<section>
<h2>작업 통계</h2>
<p>전체: {tasks.length}</p>
<p>완료: {completedCount}</p>
<p>진행 중: {tasks.length - completedCount}</p>
</section>
);
}
src/components/TaskSummary.tsx 전체 파일
type TaskSummaryProps = {
total: number;
completed: number;
remaining: number;
};
export default function TaskSummary({
total,
completed,
remaining,
}: TaskSummaryProps) {
const items = [
['전체', total],
['완료', completed],
['남음', remaining],
] as const;
return (
<section
aria-label="작업 요약"
className="grid grid-cols-1 gap-3 sm:grid-cols-3"
>
{items.map(([label, value]) => (
<div
key={label}
className="border border-gray-300 bg-white p-4"
>
<p className="text-sm text-gray-500">
{label}
</p>
<strong className="mt-2 block text-2xl font-bold text-gray-950">
{value}
</strong>
</div>
))}
</section>
);
}
src/context/TaskContext.tsx 전체 파일
import {
createContext,
useContext,
useReducer,
type Dispatch,
type ReactNode,
} from 'react';
import { taskReducer } from '../reducers/taskReducer';
import type {
Task,
TaskAction,
} from '../types/task';
type TaskContextValue = {
tasks: Task[];
dispatch: Dispatch<TaskAction>;
};
const TaskContext = createContext<
TaskContextValue | undefined
>(undefined);
const initialTasks: Task[] = [
{
id: 'task-1',
title: 'Reducer와 Context 연결하기',
completed: false,
},
];
export function TaskProvider({
children,
}: {
children: ReactNode;
}) {
const [tasks, dispatch] = useReducer(
taskReducer,
initialTasks,
);
return (
<TaskContext.Provider
value={{ tasks, dispatch }}
>
{children}
</TaskContext.Provider>
);
}
export function useTaskContext() {
const value = useContext(TaskContext);
if (!value) {
throw new Error(
'useTaskContext는 TaskProvider 안에서 사용해야 합니다.',
);
}
return value;
}
src/hooks/useNetworkStatus.ts 전체 파일
import {
useEffect,
useEffectEvent,
useState,
} from 'react';
export function useNetworkStatus(
showDebugMessage: boolean,
) {
const [isOnline, setIsOnline] = useState(
navigator.onLine,
);
const onOnline = useEffectEvent(() => {
if (showDebugMessage) {
console.log('TaskFlow가 온라인 상태입니다.');
}
setIsOnline(true);
});
const onOffline = useEffectEvent(() => {
if (showDebugMessage) {
console.log('TaskFlow가 오프라인 상태입니다.');
}
setIsOnline(false);
});
useEffect(() => {
const handleOnline = () => onOnline();
const handleOffline = () => onOffline();
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener(
'online',
handleOnline,
);
window.removeEventListener(
'offline',
handleOffline,
);
};
}, []);
return isOnline;
}
src/hooks/useTaskFilter.ts 전체 파일
import {
useDeferredValue,
useState,
useTransition,
type Dispatch,
type SetStateAction,
} from 'react';
import type {
Task,
TaskStatusFilter,
} from '../types/task';
export function useTaskFilter(tasks: Task[]) {
const [keyword, setKeyword] = useState('');
const [status, setStatusState] =
useState<TaskStatusFilter>('all');
const [isPending, startTransition] =
useTransition();
const deferredKeyword =
useDeferredValue(keyword);
const normalizedKeyword = deferredKeyword
.trim()
.toLowerCase();
const visibleTasks = tasks.filter((task) => {
const matchesKeyword = task.title
.toLowerCase()
.includes(normalizedKeyword);
const matchesStatus =
status === 'all' ||
(status === 'active' && !task.completed) ||
(status === 'completed' && task.completed);
return matchesKeyword && matchesStatus;
});
const setStatus: Dispatch<
SetStateAction<TaskStatusFilter>
> = (nextStatus) => {
startTransition(() => {
setStatusState(nextStatus);
});
};
const resetFilter = () => {
setKeyword('');
startTransition(() => {
setStatusState('all');
});
};
return {
keyword,
setKeyword,
status,
setStatus,
visibleTasks,
resetFilter,
isStale: keyword !== deferredKeyword,
isPending,
};
}
src/hooks/useTaskStats.ts 전체 파일
import { useTaskContext } from '../context/TaskContext';
export function useTaskStats() {
const { tasks } = useTaskContext();
const totalCount = tasks.length;
const completedCount = tasks.filter(
(task) => task.completed,
).length;
const remainingCount =
totalCount - completedCount;
return {
totalCount,
completedCount,
remainingCount,
};
}
src/index.css 전체 파일
@import "tailwindcss";
@layer base {
body { font-family: Arial, sans-serif; color: #111; background: #f5f5f5; }
h2 { font-size: 24px; font-weight: 700; }
h3 { font-size: 20px; font-weight: 500; }
button, input, select { min-height: 44px; border: 1px solid #aaa; padding: 8px 12px; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
input:not([type="checkbox"]), select { max-width: 100%; }
form, section { margin-block: 20px; }
details { margin-block: 24px; }
summary { cursor: pointer; min-height: 44px; }
:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
}
src/main.tsx 전체 파일
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import './index.css';
import App from './App';
import { TaskProvider } from './context/TaskContext';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<TaskProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</TaskProvider>
</StrictMode>,
);
src/pages/SettingsPage.tsx 전체 파일
import AsyncDemo from '../components/AsyncDemo';
import ProfileSaveForm from '../components/ProfileSaveForm';
export default function SettingsPage() {
return (
<section>
<h2>설정</h2>
<p>TaskFlow 설정 화면입니다.</p>
<ProfileSaveForm /><AsyncDemo />
</section>
);
}
src/pages/TaskDetailPage.tsx 전체 파일
import {
Link,
useParams,
} from 'react-router';
import { useTaskContext } from '../context/TaskContext';
export default function TaskDetailPage() {
const { taskId } = useParams();
const { tasks } = useTaskContext();
const task = tasks.find(
(item) => item.id === taskId,
);
if (!task) {
return (
<section>
<h2>작업을 찾을 수 없습니다.</h2>
<Link to="/tasks">
목록으로 돌아가기
</Link>
</section>
);
}
return (
<section>
<h2>{task.title}</h2>
<p>
상태: {task.completed ? '완료' : '진행 중'}
</p>
<Link to="/tasks">
목록으로 돌아가기
</Link>
</section>
);
}
src/pages/TaskListPage.tsx 전체 파일
import { useEffect, lazy, Suspense } from 'react';
import TaskForm from '../components/TaskForm';
import TaskList from '../components/TaskList';
import TaskFilter from '../components/TaskFilter';
import StateResetDemo from '../components/StateResetDemo';
import NetworkStatus from '../components/NetworkStatus';
import TaskSummary from '../components/TaskSummary';
import { useTaskContext } from '../context/TaskContext';
import { useTaskStats } from '../hooks/useTaskStats';
import { useTaskFilter } from '../hooks/useTaskFilter';
import StatsSkeleton from '../components/StatsSkeleton';
const TaskStats = lazy(() => import('../components/TaskStats'));
export default function TaskListPage() {
const { tasks } = useTaskContext();
const { totalCount, completedCount, remainingCount } = useTaskStats();
const { keyword, setKeyword, status, setStatus, visibleTasks, resetFilter, isStale, isPending } = useTaskFilter(tasks);
useEffect(() => { document.title = `TaskFlow (${remainingCount})`; }, [remainingCount]);
return <section className="mt-8">
<h2>작업</h2>
<NetworkStatus />
<TaskSummary total={totalCount} completed={completedCount} remaining={remainingCount} />
<TaskForm />
<TaskFilter keyword={keyword} onKeywordChange={setKeyword} status={status} onStatusChange={setStatus} onReset={resetFilter} />
<p role="status">{isStale ? "검색 결과 갱신 중..." : isPending ? "필터 갱신 중..." : ""}</p>
<TaskList tasks={visibleTasks} />
<Suspense fallback={<StatsSkeleton />}><TaskStats tasks={tasks} /></Suspense>
<StateResetDemo />
</section>;
}
src/reducers/taskReducer.ts 전체 파일
import type {
Task,
TaskAction,
} from '../types/task';
export function taskReducer(
tasks: Task[],
action: TaskAction,
): Task[] {
switch (action.type) {
case 'task/added':
return [...tasks, action.task];
case 'task/toggled':
return tasks.map((task) =>
task.id === action.id
? { ...task, completed: !task.completed }
: task,
);
case 'task/deleted':
return tasks.filter(
(task) => task.id !== action.id,
);
default:
return tasks;
}
}
src/types/task.ts 전체 파일
export type TaskId = string;
export type Task = {
id: TaskId;
title: string;
completed: boolean;
};
export type TaskAction =
| {
type: 'task/added';
task: Task;
}
| {
type: 'task/toggled';
id: TaskId;
}
| {
type: 'task/deleted';
id: TaskId;
};
export type TaskStatusFilter = 'all' | 'active' | 'completed';
tsconfig.json 전체 파일
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"types": [
"vite/client"
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src",
"vite.config.ts"
]
}
vite.config.ts 전체 파일
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
});
위 파일과 ZIP의 동명 파일은 동일합니다. package-lock.json과 회차별 README·CHANGES 문서는 ZIP에 포함합니다. 생성 산출물·node_modules·실제 환경 변수 파일은 포함하지 않습니다.
시작본과 이번 변경
Tailwind CSS Grid 실무 레이아웃 정리: 카드 목록부터 대시보드까지의 하단 완료 상태를 기준으로 이어갑니다. 기존 설명은 이전 글의 변경 내용을 연결하는 안내입니다. 위 시작 ZIP에 해당 상태의 전체 파일을 제공하므로 이제 별도로 조립하지 않아도 됩니다.
아래 변경 파일과 각 코드의 적용 위치를 따라 이번 속성만 바꾼 뒤, 기존 추가·완료·삭제 기능을 확인하고 이 글의 실행 확인 항목으로 화면을 비교합니다. 부분 코드의 생략 영역에는 이전 단계의 props·상태·handler를 유지합니다.
이전 체크포인트
tailwind-step-06-grid
현재 상태
TaskSummary가 3열 Grid로 고정되어 있어 작은 화면에서 카드가 지나치게 좁아질 수 있습니다.
TaskForm도 flex-wrap으로 동작하지만 화면별 의도가 명확하지 않습니다.
이번 목표
작은 화면을 기본으로 TaskFlow를 정리하고 sm·md 이상에서 레이아웃을 확장합니다.
변경 파일
src/App.tsx
src/components/TaskSummary.tsx
src/components/TaskForm.tsx
src/components/TaskItem.tsx
TaskSummary
<section
aria-label="작업 요약"
className="grid grid-cols-1 gap-3 sm:grid-cols-3"
>
...
</section>
기본은 1열, sm 이상에서 3열입니다.
TaskForm
<form
onSubmit={handleSubmit}
className="flex flex-col gap-2 sm:flex-row"
>
<input
className="min-h-11 min-w-0 grow border border-gray-300 bg-white px-3 py-2 text-gray-950"
/>
<button
type="submit"
className="min-h-11 shrink-0 border border-black bg-black px-4 py-2 text-white"
>
추가
</button>
</form>
작은 화면에서 input과 button이 각각 충분한 폭을 확보하고, sm 이상에서 가로로 연결됩니다.
App
<main className="mx-auto w-full max-w-3xl px-4 py-8 sm:px-6 sm:py-10 lg:py-12">
...
</main>
화면이 커질수록 여백을 조금씩 늘립니다.
TaskItem
작은 화면에서 버튼과 제목이 지나치게 한 줄에 압축되지 않도록 기본 wrap을 유지합니다.
<li className="flex flex-wrap items-center gap-3 border border-gray-300 bg-white p-4">
...
</li>
실제 문제가 생기는 경우 min-w-0와 overflow 기준은 22회차에서 다시 확인합니다.
Before / After
Before
TaskSummary 항상 3열
TaskForm 구조가 화면별 의도 없이 wrap
After
mobile: 세로 중심
sm 이상: 요약 3열, form 가로
큰 화면: 페이지 여백 확대
실행 확인
브라우저 폭을 단계적으로 줄여 확인합니다.
약 360px
약 640px
약 768px
약 1024px
정확한 기기 프리셋보다 실제 콘텐츠가 읽히는지 확인합니다.
npm run dev
npm run build
현재 확장 단계
✓ 1~6. 설치·기본 레이아웃
→ 7. responsive
○ 8. 상태 variant
○ 9. group / peer / has
○ 10. dark
○ 11~17. 설계·구조
완료 체크포인트
tailwind-step-07-responsive
직접 만드는 화면과 적용 전후 점검
독자 질문: Tailwind CSS 반응형 클래스 사용법 sm md lg 기준 잡기을 읽고 무엇을 직접 확인해야 할까요?
만들 화면: 모바일 한 열·넓은 화면 세 열의 TaskFlow 요약 화면을 만듭니다.
- 하단 TaskSummary의 요약 부모를 grid grid-cols-1 gap-3 sm:grid-cols-3로 바꾸고 375px·700px·900px에서 확인합니다.
- 기본 루트 16px 기준 375px에서는 한 열, 700px과 900px에서는 세 열인지 확인하고 가로 스크롤 여부를 봅니다.
- sm:을 작은 화면 전용으로 오해하지 말고 실제 viewport와 CSS 우선순위를 확인합니다.
다음 학습: Tailwind CSS 상태 variant 사용법: hover focus aria data 기준 잡기 — https://blogflow.kr/tailwind-css-state-variants-hover-focus-visible-disabled-aria-data/
검증 기록: 이 편집 작업에서 화면 변경·개발 서버·빌드·브라우저 조작은 실행하지 않았습니다. 위 내용은 독자 실습의 예상 확인 기준이며 실제 결과는 미확인입니다.
다음 학습
다음 글에서는 hover·focus-visible·disabled·aria·data variant를 이용해 사용자 상호작용 상태를 Tailwind class로 표현합니다.
다음 글: Tailwind CSS 상태 variant 사용법: hover focus aria data 기준 잡기
공식 문서
- Responsive design: https://tailwindcss.com/docs/responsive-design
글 소개·학습 준비·전체 목차

Tailwind CSS · 7 / 23 · 필수 학습 · 반응형
Tailwind의 반응형 class는 작은 화면을 기본값으로 두고, 화면이 넓어질수록 필요한 스타일을 덮어쓰는 mobile-first 방식으로 이해하는 것이 중요합니다.
이번 글에서는 sm·md·lg breakpoint를 단순 암기하기보다, 기본 class와 breakpoint variant가 어떤 관계로 동작하는지 익힙니다. 하단에서는 6회차에서 만든 TaskSummary 3열 Grid와 TaskFlow 화면 폭을 작은 화면부터 자연스럽게 확장합니다.
이번 글에서 배우는 내용
- mobile-first 반응형
- sm·md·lg의 의미
- 기본 class와 breakpoint class 조합
- 반응형 Grid와 Flex
- 작은 화면을 기본값으로 두는 이유
- breakpoint를 과도하게 늘리지 않는 기준
단독 학습 선수 지식
HTML 요소·속성, CSS의 선택자·속성·box model을 먼저 익힙니다. React 예제는 함수 컴포넌트와 JSX를 사용합니다. 상단 실습은 새 Vite 앱에서 실행하고, TaskFlow 완료본은 하단에서만 사용합니다.
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 23개 · 필수 16개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.