Tailwind CSS 실전 로드맵: 기본·반응형·상태·동적 클래스 학습 순서

··설명 약 15분·작성: 해비·블로그 소개

Tailwind CSS는 기본 클래스부터 동적 클래스 감지까지 어떤 순서로 배우나요?

Tailwind는 짧은 클래스를 조합해 스타일을 작성합니다. 설치·기본 클래스에서 시작해 레이아웃·반응형, 상태·다크 모드, 토큰·재사용, 감지 순으로 익히며 작은 요소부터 적용합니다.

적용 조건·범위: 기존 UI에 적용할 때는 기능과 상태 흐름을 유지하면서 CSS를 작은 단위로 옮깁니다. 기본 utility를 확인한 뒤 반응형과 상태 조건을 더하면, Preflight의 영향과 직접 바꾼 스타일을 구분해 확인할 수 있습니다.

본문 근거: tailwindcss.com · tailwindcss.com/blog/tailwindcss-v4-3 · tailwindcss.com/docs/installation

Tailwind CSS는 무엇을 해결하는가

일반 CSS에서는 보통 의미 있는 class 이름을 만든 뒤 CSS 파일에서 스타일을 정의합니다.

<button className="primary-button">
  작업 추가
</button>
.primary-button {
  min-height: 44px;
  padding: 8px 12px;
  border: 1px solid #111;
  background: #111;
  color: #fff;
}

Tailwind CSS에서는 작은 역할의 utility를 조합해 같은 결과를 표현합니다.

<button
  className="min-h-11 border border-black bg-black px-3 py-2 text-white"
>
  작업 추가
</button>

핵심은 CSS를 쓰지 않는 것이 아닙니다.

Tailwind는 자주 사용하는 CSS 속성을 일관된 class API로 제공하고, 디자인 규칙을 theme token과 utility로 연결하는 방식입니다.

utility-first는 무엇인가

utility-first는 하나의 class가 작은 스타일 책임을 갖는 방식입니다.

px-4
→ 좌우 padding


text-sm
→ 작은 글자 크기


font-medium
→ 중간 굵기


border
→ border-width


grid
→ display: grid

이 작은 class를 컴포넌트에서 조합합니다.

처음에는 className이 길어 보여도 다음 장점이 있습니다.

  • 컴포넌트와 스타일의 연결 위치가 가깝습니다.
  • 사용하지 않는 의미 없는 CSS class 이름이 줄어듭니다.
  • spacing·color·font scale을 일관되게 사용할 수 있습니다.
  • 반응형과 상태 variant를 같은 className 안에서 읽을 수 있습니다.

반대로 class 조합이 너무 길어지거나 같은 패턴이 반복되면 컴포넌트 분리, design token, custom utility 등을 검토해야 합니다. 이 과정 후반부에서 그 기준을 다룹니다.

Tailwind CSS v4에서 달라진 시작 방식

Tailwind CSS v4는 이전 v3 중심 튜토리얼과 설치 방식이 다릅니다.

Vite 환경에서는 현재 다음 구성이 기본입니다.

npm install tailwindcss @tailwindcss/vite

Vite plugin을 연결하고 CSS 파일에는 한 줄을 추가합니다.

@import "tailwindcss";

v3에서 흔히 보던 다음 흐름을 기본 시작 방식으로 사용하지 않습니다.

npx tailwindcss init -p
tailwind.config.js 생성
@tailwind base/components/utilities

설정 파일이 필요한 특수 상황은 있을 수 있지만, v4의 기본 학습 시작점은 CSS-first 설정과 자동 source detection입니다.

Tailwind 학습 순서

BlogFlow Tailwind CSS 과정은 다음 순서로 진행합니다.

초급 기반

  1. 전체 학습 로드맵
  2. Tailwind CSS v4 설치
  3. text·font·background·spacing
  4. border·Flexbox
  5. Preflight
  6. Grid
  7. 반응형

상태와 UI 구조

  1. hover·focus-visible·aria·data variant
  2. group·peer·has
  3. dark mode
  4. arbitrary value
  5. arbitrary value 문법 오류 해결

설계와 재사용

  1. @theme design token
  2. Container Query
  3. React className 구조화
  4. 동적 class 감지와 @source
  5. @apply·@utility·custom variant

선택 심화·오류 해결

  1. transition·animation·motion-reduce
  2. v4.1~v4.3 신규 utility
  3. Radix UI와 shadcn/ui 선택 기준
  4. shadcn/ui import alias 오류
  5. Flex 레이아웃 디버깅
  6. Tailwind class 미적용 종합 진단

기존 CSS를 한 번에 제거하지 않는 이유

Tailwind를 도입한다고 해서 첫날 기존 CSS를 모두 삭제할 필요는 없습니다.

한 번에 전체 스타일을 바꾸면 다음 문제가 생깁니다.

  • 시각적 회귀가 생겼을 때 원인을 찾기 어렵습니다.
  • Preflight 영향과 utility 변경을 구분하기 어렵습니다.
  • React 기능 오류와 CSS 오류가 동시에 발생할 수 있습니다.
  • 회차별 학습 주제가 불분명해집니다.

이 과정에서는 다음 원칙을 사용합니다.

기존 UI가 정상 동작하는 상태
→ Tailwind 설치
→ 작은 영역 하나 교체
→ 화면 검증
→ 다음 영역 교체

기능과 스타일을 동시에 크게 바꾸지 않습니다.

Tailwind 과정에서 중요한 역할 경계

Tailwind는 스타일을 담당합니다.

Task 완료 여부
→ React state


Task 목록 데이터
→ React 또는 서버 상태


필터 선택
→ React state 또는 Zustand


완료 상태의 시각 표현
→ Tailwind

예를 들어 완료 여부 자체를 CSS class로 관리하지 않습니다.

React가 상태를 결정하고 Tailwind는 그 상태에 맞는 class를 표현합니다.

자주 하는 시작 단계 실수

class를 외우는 것부터 시작합니다

처음부터 모든 utility를 암기할 필요는 없습니다.

CSS 속성을 알고 있다면 “이 속성을 Tailwind에서는 어떤 이름으로 표현하는가”를 공식 문서에서 확인하는 방식이 더 효율적입니다.

기존 CSS를 전부 지웁니다

설치 직후 전체 CSS를 삭제하면 어떤 변화가 Tailwind 때문인지 추적하기 어렵습니다.

v3 설치 글을 그대로 따라갑니다

tailwind.config.js가 없다고 설치 실패라고 판단하지 않습니다. v4는 기본 설정 방식이 다릅니다.

className을 문자열 보간으로 만듭니다

다음 패턴은 뒤에서 문제가 됩니다.

const className = "bg-" + color + "-500";

Tailwind는 소스 파일을 class 이름 토큰으로 스캔하므로 완성된 class 문자열을 정적으로 작성하는 것이 기본입니다.

16회차에서 자세히 다룹니다.

학습 완료 체크리스트

  • Tailwind CSS를 utility-first CSS framework라고 설명할 수 있습니다.
  • v4와 v3의 기본 설치 방식 차이를 설명할 수 있습니다.
  • 기존 CSS를 단계적으로 교체해야 하는 이유를 설명할 수 있습니다.
  • 반응형·상태·design token·동적 class가 어떤 순서로 이어지는지 이해했습니다.
  • Tailwind와 React 상태 관리의 역할을 구분할 수 있습니다.

독립 실습: 프로젝트 없이 결과 확인

utility class가 적용된 독립 화면을 확인하세요.

예제 크게 보기 (새 탭)

Tailwind를 처음 접한다면 아래 작은 공지 화면으로 읽는 순서를 확인합니다.

준비와 실행 파일

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-2xl font-medium">공지</h1><p className="mt-2 text-sm text-gray-600">HTML은 의미, utility는 표현을 담당합니다.</p></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

첫 명령이 표시하는 로컬 주소를 브라우저에서 열고, 두 번째 명령은 별도 터미널에서 실행합니다. h1은 제목 의미를 유지하며 text-2xl은 크기, font-medium은 굵기, mt-2는 문단 위 간격을 담당합니다.

작은 변형으로 확인하기

text-2xl을 text-lg로 바꿔 제목 의미는 그대로이고 크기만 달라지는지 확인합니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Tailwind CSS로 앱 화면을 다듬습니다.

코드를 크게 변경하지 않고 Tailwind 과정에서 교체할 스타일 영역을 정리합니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

TaskFlow 1단계 실행

시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.

예제 크게 보기 (새 탭)

실행 가능한 시작본과 이번 단계

이번 단계 시작 ZIP을 새 폴더에 압축 해제합니다. 이 ZIP은 직전 단계의 완료 파일과 같으며 앞 글의 코드를 따로 모을 필요가 없습니다. 이번 단계 완료 ZIP은 다른 폴더에 풀어 비교합니다.

React step-28의 전체 라우팅·Context·RHF 폼·추가/완료/삭제·상세·설정 파일을 그대로 시작본에 담고 변경 경계를 문서화합니다.

  1. docs/style-plan.md: 새 파일로 저장합니다.

짧은 코드 예제는 변경 위치를 설명합니다. 파일 전체를 작성하거나 교체할 때는 완료 파일 전체를 사용하세요. 생략 표시가 있는 부분 코드를 파일 전체로 저장하지 않습니다.

실행: Node.js 22.12 이상에서 ZIP 루트의 package.json·package-lock.json을 유지하고 npm ci 후 npm run dev를 실행합니다. 별도 터미널에서 npm run build를 실행합니다. React·라우터·기존 상태 구조를 유지하고 Tailwind 스타일만 단계적으로 적용합니다.

직접 확인할 결과: 작업을 추가·완료·삭제하고 상세/설정으로 이동할 수 있습니다. 화면 스타일은 기존 일반 CSS입니다.

이 시작본은 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"
  }
}

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>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 전체 파일

* {
  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';
export default defineConfig({ plugins: [react()] });

위 파일과 ZIP의 동명 파일은 동일합니다. package-lock.json과 회차별 README·CHANGES 문서는 ZIP에 포함합니다. 생성 산출물·node_modules·실제 환경 변수 파일은 포함하지 않습니다.

시작본과 이번 변경

React 라이브러리 조합 가이드 Zustand TanStack Query shadcn ui 선택 기준의 하단 완료 상태를 기준으로 이어갑니다. 기존 설명은 이전 글의 변경 내용을 연결하는 안내입니다. 위 시작 ZIP에 해당 상태의 전체 파일을 제공하므로 이제 별도로 조립하지 않아도 됩니다.

아래 변경 파일과 각 코드의 적용 위치를 따라 이번 속성만 바꾼 뒤, 기존 추가·완료·삭제 기능을 확인하고 이 글의 실행 확인 항목으로 화면을 비교합니다. 부분 코드의 생략 영역에는 이전 단계의 props·상태·handler를 유지합니다.

연속 프로젝트 선수 조건

이 과정은 React 기본 문법을 알고 있다는 전제에서 진행합니다.

최소한 다음 내용을 이해하면 좋습니다.

  • JSX와 className
  • 컴포넌트
  • props
  • state
  • 이벤트 처리
  • React Router 기본
  • CSS의 box model, Flexbox, Grid 기초

TaskFlow는 React 과정의 다음 체크포인트를 기반으로 시작합니다.

step-28-library-choice

이 시점의 TaskFlow는 기능과 라우팅 구조가 갖춰져 있지만 스타일은 일반 CSS를 중심으로 작성되어 있습니다.

여기부터는 React 과정의 TaskFlow를 계속 발전시키는 누적 실습입니다.

이전 체크포인트

step-28-library-choice

현재 상태

TaskFlow의 기능 구조는 완성되어 있습니다.

React + TypeScript + Vite
React Router
Task 목록
Task 상세
설정 화면
작업 추가·완료·삭제
일반 CSS / index.css

이번 목표

코드를 크게 변경하지 않고 Tailwind 과정에서 교체할 스타일 영역을 정리합니다.

Before

src/index.css
일반 CSS class
일부 element selector
기능 중심 UI

After 목표

Tailwind utility
반응형 레이아웃
상태 variant
dark mode
design token
container query
정적 className 구조
최소 custom CSS

과정에서 유지할 기능

Tailwind 과정 내내 다음 기능은 그대로 유지합니다.

  • 작업 추가
  • 작업 완료·취소
  • 작업 삭제
  • 목록·상세 화면
  • 설정 화면
  • React Router
  • 기존 상태 구조

스타일 학습을 위해 기능을 임의로 바꾸지 않습니다.

현재 확장 단계

→ 1. Tailwind 전체 로드맵
○ 2. Tailwind v4 설치
○ 3. 기본 utility
○ 4~7. Flex·Grid·반응형
○ 8~12. 상태·dark·arbitrary
○ 13~17. token·구조·감지
○ 18~23. 선택 심화·오류 점검

완료 체크포인트

tailwind-step-01-roadmap

이번 단계에서는 화면을 크게 바꾸지 않습니다.

중요한 결과는 “TaskFlow의 기능은 유지하고 스타일만 단계적으로 개선한다”는 작업 경계를 확보하는 것입니다.

직접 만드는 화면과 적용 전후 점검

독자 질문: Tailwind CSS 실전 로드맵 기본 반응형 상태 동적 클래스 학습 순서을 읽고 무엇을 직접 확인해야 할까요?

만들 화면: TaskFlow 작업 목록과 요약 영역의 현재 화면을 기준선으로 기록합니다.

  1. /tasks의 목록·추가 버튼·완료 표시와 /settings의 배치가 이후 회차에서 유지될지 캡처합니다.
  2. 1회차는 코드 변경이 없어 전후 화면이 같아야 합니다. 버튼 클릭과 화면 이동을 각각 비교합니다.
  3. 화면이 다르면 이 회차에서 변경한 파일이 없는지 먼저 확인합니다.

다음 학습: Tailwind CSS v4 설치: React Vite 프로젝트 연결과 첫 클래스 적용 — https://blogflow.kr/tailwind-css-v4-install-react-vite/

검증 기록: 이 편집 작업에서 화면 변경·개발 서버·빌드·브라우저 조작은 실행하지 않았습니다. 위 내용은 독자 실습의 예상 확인 기준이며 실제 결과는 미확인입니다.

다음 학습

다음 글에서는 React + TypeScript + Vite 프로젝트에 Tailwind CSS v4를 설치하고 첫 utility class가 실제로 적용되는지 확인합니다.

다음 글: Tailwind CSS v4 설치: React Vite 프로젝트 연결과 첫 클래스 적용

공식 문서

글 소개·학습 준비·전체 목차
Tailwind CSS: 실전 학습 로드맵 표지

Tailwind CSS · 1 / 23 · 필수 길잡이 · 과정 안내

Tailwind CSS는 설치와 기본 utility → 레이아웃·반응형 → 상태 variant·dark mode → 토큰·재사용 → 동적 class 감지 순서로 익힙니다. 각 단계에서 작은 요소 하나를 바꾸고 화면·생성 CSS를 확인한 뒤 범위를 넓힙니다.

기존 UI에 적용할 때는 기능과 상태 흐름을 유지하면서 CSS를 작은 단위로 옮깁니다. 기본 utility를 확인한 뒤 반응형과 상태 조건을 더하면, Preflight의 영향과 직접 바꾼 스타일을 구분해 확인할 수 있습니다.

상단에서는 이 글만 찾아온 독자도 Tailwind CSS 학습 순서를 이해할 수 있게 설명하고, 하단에서는 1회차부터 따라오는 독자를 위해 TaskFlow의 현재 스타일 구조와 Tailwind 과정의 최종 목표를 정리합니다.

이번 글에서 배우는 내용

  • Tailwind CSS가 해결하려는 문제
  • utility-first 스타일링의 기본 개념
  • Tailwind CSS v4 학습 순서
  • 기존 CSS와 Tailwind를 한 번에 바꾸지 않는 이유
  • 반응형·상태·dark mode·design token이 연결되는 흐름
  • 동적 class가 누락되는 이유를 어느 시점에 배우는지
  • TaskFlow UI가 23회 동안 어떻게 바뀌는지

먼저 알아야 할 내용

HTML 요소·속성, CSS의 선택자·속성·box model을 먼저 익힙니다. React 예제는 함수 컴포넌트와 JSX를 사용합니다. 상단 실습은 새 Vite 앱에서 실행하고, TaskFlow 완료본은 하단에서만 사용합니다.

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 23개 · 필수 16개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기