React Vite 프로젝트에 Tailwind CSS v4를 어떻게 설치하고 연결하나요?
Tailwind를 Vite 앱에 적용하려면 패키지 설치, Vite 플러그인, CSS의 Tailwind import를 연결합니다. 제목에 text-3xl font-bold를 붙여 크기·굵기가 바뀌는지 확인합니다.
본문 근거: tailwindcss.com/docs/installation · tailwindcss.com/blog/tailwindcss-v4
설치
새 앱이 없다면 아래 명령으로 빈 React TypeScript 앱을 먼저 만듭니다. 이미 만든 앱에서는 이 단계만 건너뜁니다.
npm create vite@latest tailwind-practice -- --template react-ts
cd tailwind-practice
npm install
다음 명령을 실행합니다.
npm install tailwindcss @tailwindcss/vite
설치 후 package.json에 두 패키지가 추가됐는지 확인합니다.
예시는 다음과 같습니다.
{
"dependencies": {
"tailwindcss": "^4.3.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0"
}
}
실제 버전은 설치 시점의 최신 패치에 따라 달라질 수 있습니다.
Vite plugin 연결
vite.config.ts에 Tailwind Vite plugin을 추가합니다.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
});
기존 react()를 지우지 않고 tailwindcss()를 추가합니다.
CSS에 Tailwind import
프로젝트의 전역 CSS 파일에서 Tailwind를 불러옵니다.
@import "tailwindcss";
React Vite 프로젝트라면 보통 src/index.css를 사용합니다.
src/main.tsx에서 해당 CSS가 이미 import되고 있는지 확인합니다.
import './index.css';
첫 class 적용
설치 여부를 확인하기 위해 App.tsx의 제목에 아주 작은 utility만 적용합니다.
function App() {
return (
<main>
<h1 className="text-3xl font-bold">
TaskFlow
</h1>
</main>
);
}
export default App;
브라우저에서 제목의 크기와 굵기가 달라지면 기본 연결은 성공입니다.
Tailwind v3 설치 글과 다른 점
인터넷의 오래된 글에는 다음 명령이 자주 등장합니다.
npx tailwindcss init -p
그리고 다음 파일을 만드는 방식도 많이 보입니다.
tailwind.config.js
postcss.config.js
CSS에서는 @tailwind base/components/utilities를 사용하는 글도 많습니다.
이 방식은 v3 중심의 설치 방법입니다.
Tailwind v4의 Vite 기본 설치에서는 다음 구조를 사용합니다.
@tailwindcss/vite
+
@import "tailwindcss";
tailwind.config.js가 없다는 이유만으로 설치 실패라고 판단하지 않습니다.
Tailwind는 어떻게 class를 찾는가
Tailwind는 소스 파일에 작성된 class 이름을 스캔해서 필요한 CSS를 생성합니다.
예를 들어 다음 class가 소스에 있으면:
<p className="text-sm text-gray-600">
설명
</p>
필요한 utility CSS가 생성됩니다.
반대로 소스에서 class 이름을 완성된 문자열 형태로 찾을 수 없으면 CSS가 생성되지 않을 수 있습니다.
const color = 'red';
const className = "bg-" + color + "-500";
이 문제는 16회차에서 자세히 다룹니다.
설치 단계에서는 완전한 class 문자열을 직접 작성하는 것만 기억하면 됩니다.
기존 CSS와 함께 사용할 수 있는가
가능합니다.
@import "tailwindcss";
body {
margin: 0;
}
기존 CSS와 Tailwind를 동시에 사용할 수 있습니다.
다만 Tailwind의 Preflight가 기본 요소 스타일에 영향을 줄 수 있습니다. 이 부분은 5회차에서 별도로 설명합니다.
처음부터 기존 CSS를 전부 삭제하지 않습니다.
자주 발생하는 오류
class를 넣었는데 아무 변화가 없습니다
다음 순서로 확인합니다.
- tailwindcss가 설치됐는지
- @tailwindcss/vite가 설치됐는지
- vite.config.ts에 tailwindcss()가 있는지
- index.css에 @import “tailwindcss”;가 있는지
- main.tsx가 index.css를 import하는지
- className이 완전한 문자열인지
Unknown at rule 경고가 보입니다
에디터의 CSS 검사기가 Tailwind 전용 at-rule을 인식하지 못해 경고를 보여주는 경우가 있습니다.
실제 Vite build가 정상인지 먼저 구분합니다.
npm run build
에디터 경고와 실제 빌드 오류는 같은 문제가 아닙니다.
기존 스타일이 달라졌습니다
Tailwind를 import하면 Preflight가 적용됩니다.
브라우저 기본 heading·list·button 스타일이 달라질 수 있습니다.
설치 오류로 단정하지 않고 5회차에서 Preflight 기준으로 정리합니다.
학습 완료 체크리스트
- Tailwind CSS v4를 Vite 프로젝트에 설치할 수 있습니다.
- @tailwindcss/vite를 vite.config.ts에 연결할 수 있습니다.
- CSS에서 @import “tailwindcss”;를 사용할 수 있습니다.
- v3 설치 방식과 v4 설치 방식을 구분할 수 있습니다.
- 첫 utility class가 적용되는지 확인할 수 있습니다.
- 기존 CSS와 Tailwind를 단계적으로 함께 사용할 수 있습니다.
독립 실습: 새 Vite 앱에서 첫 클래스 확인
독립 실습: 프로젝트 없이 결과 확인
Tailwind가 빌드한 스타일의 적용 결과를 확인하세요.
예제 크게 보기 (새 탭)위 설치·Vite plugin 연결을 끝낸 새 React TypeScript 프로젝트에서 확인합니다.
준비와 실행 파일
HTML의 요소·속성과 CSS 속성을 알고, 이 JSX 예제는 React 함수 컴포넌트의 반환 구조를 읽을 수 있어야 합니다. 새 Vite React + TypeScript 앱에서 Tailwind v4 설치의 plugin·CSS 연결 절차를 먼저 완료합니다. 이 글 앞부분의 설치 명령을 사용합니다. 생성된 index.html의 root를 유지하고 아래 세 파일을 각 경로에 저장합니다. TaskFlow 상태·컴포넌트·서버는 사용하지 않습니다.
src/App.tsx 전체 파일
export default function App() {
return (
<main className="p-4"><h1 className="text-3xl font-bold">첫 Tailwind 화면</h1></main>
);
}
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에서 제목은 30px, 굵기 700이며 main 안쪽 여백은 16px입니다. 기존 App.css를 import하지 않으므로 시작 화면 CSS가 덮어쓰지 않습니다.
작은 변형으로 확인하기
text-3xl을 text-xl로 바꾸면 20px로 줄어야 합니다. 변화가 없으면 main.tsx의 CSS import를 확인합니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Tailwind CSS로 앱 화면을 다듬습니다.
기존 CSS는 유지한 채 Tailwind CSS v4를 설치하고 제목 한 곳에 utility를 적용합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 2단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)실행 가능한 시작본과 이번 단계
이번 단계 시작 ZIP을 새 폴더에 압축 해제합니다. 이 ZIP은 직전 단계의 완료 파일과 같으며 앞 글의 코드를 따로 모을 필요가 없습니다. 이번 단계 완료 ZIP은 다른 폴더에 풀어 비교합니다.
package.json에 Tailwind 4.3.3과 Vite plugin을 추가하고 CSS import·plugin·Header 제목 한 곳을 연결합니다.
package.json: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.src/components/Header.tsx: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.src/index.css: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.vite.config.ts: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.package-lock.json: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.
짧은 코드 예제는 변경 위치를 설명합니다. 파일 전체를 작성하거나 교체할 때는 완료 파일 전체를 사용하세요. 생략 표시가 있는 부분 코드를 파일 전체로 저장하지 않습니다.
실행: Node.js 22.12 이상에서 ZIP 루트의 package.json·package-lock.json을 유지하고 npm ci 후 npm run dev를 실행합니다. 별도 터미널에서 npm run build를 실행합니다. React·라우터·기존 상태 구조를 유지하고 Tailwind 스타일만 단계적으로 적용합니다.
직접 확인할 결과: 제목은 text-3xl font-bold이며 기존 작업 기능과 화면 경로가 유지됩니다.
이 시작본은 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="app">
<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="eyebrow">React Learning Project</p>
<h1 className="text-3xl font-bold">TaskFlow</h1>
<p>오늘 할 일을 하나씩 정리해 보세요.</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="task-card">
<div className="task-card__content">
{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>
<label htmlFor="task-title">
새 작업
</label>
<input
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">
추가
</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
type="button"
onClick={() =>
dispatch({
type: 'task/toggled',
id: task.id,
})
}
>
{task.completed ? '완료 취소' : '완료'}
</button>
<span>{task.title}</span>
<button
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>
{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;
label?: string;
};
export default function TaskSummary({
total,
completed,
remaining,
label = '작업 현황',
}: TaskSummaryProps) {
return (
<section aria-label={label}>
<p>
전체 {total} · 완료 {completed} · 남음 {remaining}
</p>
</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";
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, sans-serif;
color: #111;
background: #f5f5f5;
}
.app {
width: min(720px, calc(100% - 32px));
margin: 48px auto;
}
.eyebrow {
color: #666;
font-size: 14px;
}
ul {
padding-left: 20px;
}
button, input, select { min-height: 44px; font: inherit; }
button, input, select { border: 1px solid #aaa; padding: 8px 12px; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
ul { list-style: none; padding: 0; display: grid; gap: 12px; }
.task-card { border: 1px solid #ccc; padding: 16px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.task-card__content, .task-card__actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
form, section { margin-block: 20px; }
input:not([type="checkbox"]), select { max-width: 100%; }
details { margin-block: 24px; } summary { cursor: pointer; min-height: 44px; }
body { overflow-wrap: anywhere; }
[role="dialog"], [data-radix-popper-content-wrapper] { --radius: 0; }
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>
<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·실제 환경 변수 파일은 포함하지 않습니다.
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
});
src/index.css
기존 파일 최상단에 Tailwind import를 추가합니다.
@import "tailwindcss";
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, sans-serif;
color: #111;
background: #f5f5f5;
}
기존 reset과 공통 CSS는 아직 제거하지 않습니다.
src/App.tsx
아래 App 부분 코드는 단순한 제목 적용 예시입니다. 실제 누적 시작 ZIP은 라우터 구조를 사용하므로 src/App.tsx 전체를 이 조각으로 바꾸지 않고, 위 변경 파일 목록의 src/components/Header.tsx 제목에 적용합니다.
function App() {
return (
<main className="app">
<header>
<p className="eyebrow">
React Learning Project
</p>
<h1 className="text-3xl font-bold">
TaskFlow
</h1>
<p>
오늘 할 일을 하나씩 정리해 보세요.
</p>
</header>
{/* 기존 TaskFlow 기능 유지 */}
</main>
);
}
export default App;
시작본과 이번 변경
Tailwind CSS 실전 로드맵: 기본·반응형·상태·동적 클래스 학습 순서의 하단 완료 상태를 기준으로 이어갑니다. 기존 설명은 이전 글의 변경 내용을 연결하는 안내입니다. 위 시작 ZIP에 해당 상태의 전체 파일을 제공하므로 이제 별도로 조립하지 않아도 됩니다.
아래 변경 파일과 각 코드의 적용 위치를 따라 이번 속성만 바꾼 뒤, 기존 추가·완료·삭제 기능을 확인하고 이 글의 실행 확인 항목으로 화면을 비교합니다. 부분 코드의 생략 영역에는 이전 단계의 props·상태·handler를 유지합니다.
연속 프로젝트 선수 조건
Node.js와 npm이 준비되어 있어야 합니다.
Vite React + TypeScript 프로젝트가 없다면 먼저 프로젝트를 생성합니다.
npm create vite@latest taskflow -- --template react-ts
cd taskflow
npm install
이미 프로젝트가 있다면 현재 프로젝트 루트에서 설치를 진행합니다.
이전 체크포인트
tailwind-step-01-roadmap
현재 상태
TaskFlow는 React + TypeScript + Vite 프로젝트이며 기존 src/index.css에 일반 CSS가 있습니다.
이번 목표
기존 CSS는 유지한 채 Tailwind CSS v4를 설치하고 제목 한 곳에 utility를 적용합니다.
변경 파일
package.json
vite.config.ts
src/index.css
src/App.tsx
Before / After
Before
h1 스타일
→ 기존 CSS 또는 브라우저 기본값
After
h1
→ text-3xl font-bold
이번 회차에서는 Tailwind를 많이 적용하는 것이 목표가 아닙니다.
설치 성공 여부를 확인할 수 있는 최소 변경만 합니다.
실행 확인
npm run dev
npm run build
확인할 내용:
- Vite 개발 서버가 정상 실행되는가
- TaskFlow 제목이 text-3xl font-bold로 표시되는가
- 기존 작업 기능이 그대로 동작하는가
- 기존 CSS가 갑자기 대량 삭제되지 않았는가
- production build가 성공하는가
현재 확장 단계
✓ 1. 전체 로드맵
→ 2. Tailwind v4 설치
○ 3. 기본 utility
○ 4~7. 레이아웃·반응형
○ 8~12. 상태·dark·arbitrary
○ 13~17. 설계·감지
○ 18~23. 선택 심화·오류 점검
완료 체크포인트
tailwind-step-02-install
직접 만드는 화면과 적용 전후 점검
독자 질문: Tailwind CSS v4 설치 React Vite 프로젝트 연결과 첫 클래스 적용을 읽고 무엇을 직접 확인해야 할까요?
만들 화면: TaskFlow 제목 한 줄에 text-3xl font-bold를 적용한 첫 화면을 만듭니다.
- src/index.css의 @import, vite.config.ts의 플러그인, src/main.tsx의 CSS import를 확인하고 제목 className을 적용합니다.
- 적용 전 제목은 기존 CSS, 적용 후 해당 제목만 지정 크기·굵기로 보이는지 개발자 도구 Computed에서 비교합니다.
- 변화가 없으면 개발 서버 재시작, Vite 플러그인, CSS 진입점, 클래스 철자를 순서대로 봅니다.
다음 학습: Tailwind CSS 기본 클래스 사용법: text font bg spacing 익히기 — https://blogflow.kr/tailwind-css-basic-classes/
검증 기록: 이 편집 작업에서 화면 변경·개발 서버·빌드·브라우저 조작은 실행하지 않았습니다. 위 내용은 독자 실습의 예상 확인 기준이며 실제 결과는 미확인입니다.
다음 학습
다음 글에서는 text·font·background·spacing utility를 이용해 TaskFlow의 기본 타이포그래피와 페이지 간격을 Tailwind로 옮깁니다.
다음 글: Tailwind CSS 기본 클래스 사용법: text·font·bg·spacing 익히기
공식 문서
- Installation: https://tailwindcss.com/docs/installation
- Tailwind CSS v4: https://tailwindcss.com/blog/tailwindcss-v4
글 소개·학습 준비·전체 목차

Tailwind CSS · 2 / 23 · 필수 학습 · 라이브러리 최초 도입
React + TypeScript + Vite 앱에 tailwindcss와 @tailwindcss/vite를 설치하고, Vite plugin과 CSS import를 연결하면 utility class를 사용할 수 있습니다. 제목 한 줄에 text-3xl font-bold를 적용해 세 연결 지점을 확인합니다.
Tailwind v4는 v3와 설치 방식이 크게 달라졌습니다. Vite 프로젝트에서는 @tailwindcss/vite 플러그인을 연결하고 CSS에서 @import “tailwindcss”; 한 줄로 시작하는 방식이 현재 기본입니다.
상단의 새 Vite 앱에서 설치와 첫 클래스 적용을 확인합니다. 하단에서는 React 과정에서 만든 TaskFlow에 Tailwind를 추가하고 기존 CSS와 공존시키면서 설치 성공을 확인합니다.
이번 글에서 배우는 내용
- Tailwind CSS v4 설치
- @tailwindcss/vite 역할
- Vite plugin 연결
- @import “tailwindcss”;
- 첫 utility class 적용
- v3 설치 방식과의 차이
- Tailwind가 class를 감지하는 기본 원리
목차
먼저 알아야 할 내용
HTML 요소·속성, CSS의 선택자·속성·box model을 먼저 익힙니다. React 예제는 함수 컴포넌트와 JSX를 사용합니다. 상단 실습은 새 Vite 앱에서 실행하고, TaskFlow 완료본은 하단에서만 사용합니다.
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 23개 · 필수 16개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.