Tailwind CSS 클래스 적용 안 됨 해결 순서

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

Tailwind CSS 클래스가 적용되지 않을 때 어떤 순서로 확인하나요?

Tailwind가 적용되지 않으면 Vite 플러그인, CSS import, 완성된 클래스 문자열을 차례로 확인합니다. 해당 CSS가 생성됐다면 설치보다 다른 규칙의 우선순위와 덮어쓰기를 살펴봅니다.

적용 조건·범위: 이번 글에서는 Tailwind CSS v4 + React + Vite 기준으로 class 미적용 문제를 처음부터 끝까지 진단하는 순서를 정리하고 TaskFlow 과정 전체를 마무리합니다.

본문 근거: tailwindcss.com/docs/installation · tailwindcss.com/docs/detecting-classes-in-source-files · tailwindcss.com/docs/preflight

1. package 설치 확인

npm ls tailwindcss
npm ls @tailwindcss/vite

Vite 프로젝트에서는 두 package가 정상 설치됐는지 확인합니다.

필요하면 설치합니다.

npm install tailwindcss @tailwindcss/vite

2. Vite plugin 확인

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';


export default defineConfig({
  plugins: [
    react(),
    tailwindcss(),
  ],
});

tailwindcss()가 빠져 있으면 Vite가 Tailwind 변환을 수행하지 않습니다.

3. CSS import 확인

src/index.css:

@import "tailwindcss";

v4 Vite 기본 설치에서 가장 먼저 확인할 부분입니다.

4. main entry가 CSS를 읽는지 확인

import './index.css';

index.css를 아무리 수정해도 main.tsx에서 실제로 import하지 않으면 화면에 적용되지 않습니다.

프로젝트에 app.css, styles.css, index.css가 여러 개 있다면 현재 entry가 어떤 파일을 읽는지 확인합니다.

5. 아주 단순한 class로 테스트

복잡한 class로 바로 테스트하지 않습니다.

<h1 className="text-3xl font-bold text-red-600">
  Test
</h1>

이 class도 적용되지 않으면 arbitrary value나 variant 문제가 아니라 기본 연결부터 확인해야 합니다.

6. 완성된 class 문자열인지 확인

잘못된 패턴:

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

Tailwind는 JavaScript runtime 결과를 계산하지 않습니다.

수정:

const colorClasses = {
  red:
    'bg-red-500 text-white',
  blue:
    'bg-blue-500 text-white',
} as const;

7. source 위치 확인

일반 src 파일은 v4 자동 detection으로 처리되는 경우가 많습니다.

node_modules의 외부 UI package처럼 자동 감지되지 않는 위치는 @source를 검토합니다.

@source "../node_modules/@company/ui";

@source를 동적 class 해결책으로 사용하지 않습니다.

8. arbitrary value 문법 확인

잘못된 예:

className="grid-cols-[1fr 2fr]"

수정:

className="grid-cols-[1fr_2fr]"

공백 때문에 class token이 잘린 것은 source path 문제가 아닙니다.

9. DevTools에서 생성 CSS 확인

Elements panel에서 실제 className을 확인합니다.

Styles panel에서 해당 selector가 존재하는지 봅니다.

class는 있음
CSS rule도 있음
하지만 다른 rule이 덮음
→ cascade / specificity 문제


class는 있음
CSS rule이 없음
→ detection / syntax / build 문제


class 자체가 없음
→ React 조건 / 잘못된 component 수정 가능

문제를 세 단계로 분리합니다.

10. 기존 CSS 충돌

Tailwind를 점진 도입하면 기존 CSS가 남아 있을 수 있습니다.

button {
  background: white !important;
}

이런 rule이 있으면 Tailwind bg-black이 보이지 않을 수 있습니다.

무조건 !important로 다시 덮지 않고 기존 rule의 필요성을 먼저 점검합니다.

11. Preflight 변화와 미적용을 구분

h1의 기본 크기가 없어졌다고 Tailwind가 “안 먹는다”고 판단하지 않습니다.

Preflight가 정상 적용된 결과일 수 있습니다.

utility를 직접 넣어 확인합니다.

<h1 class="text-3xl font-bold">

12. dev server 재시작

vite.config.ts, package 설치, 일부 build 설정을 바꿨다면 개발 서버를 다시 시작합니다.

npm run dev

에디터 경고만 남아 있어도 실제 browser/build가 정상인지 구분합니다.

13. production build

최종적으로 build를 확인합니다.

npm run build

개발 환경에서만 우연히 보이는 상태로 끝내지 않습니다.

Tailwind class 미적용 진단표

모든 class 미적용
→ package / Vite plugin / CSS import / entry CSS 확인


특정 동적 class만 미적용
→ 완성 class 문자열인지 확인


외부 package class 미적용
→ @source 검토


arbitrary class만 미적용
→ 문법 / 공백 확인


CSS rule 존재하지만 화면이 다름
→ 기존 CSS / cascade 확인


기본 element 스타일만 달라짐
→ Preflight 확인

해결할 때 하지 않을 것

  • 원인을 모른 채 tailwind.config.js부터 생성
  • @source에 프로젝트 전체 경로 무작정 추가
  • 모든 class safelist
  • 기존 CSS에 !important 반복
  • body overflow hidden으로 레이아웃 문제 숨기기
  • package 삭제/재설치를 첫 단계로 반복

작은 테스트 class부터 원인을 좁힙니다.

학습 완료 체크리스트

  • Tailwind 설치 연결을 처음부터 점검할 수 있습니다.
  • source detection과 dynamic class 문제를 구분할 수 있습니다.
  • arbitrary value 문법 문제를 찾을 수 있습니다.
  • CSS가 생성됐는지 DevTools에서 확인할 수 있습니다.
  • 기존 CSS cascade 문제를 구분할 수 있습니다.
  • production build까지 검증할 수 있습니다.

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

생성된 Tailwind class가 실제 화면에 적용되는지 확인하세요.

예제 크게 보기 (새 탭)

새 프로젝트에서 정상 화면을 기준으로 고장 지점을 하나씩 분리합니다.

준비와 실행 파일

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 text-red-600">연결 확인</h1><p className="mt-4 bg-black p-4 text-white">생성 CSS와 덮어쓰기 구분</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

첫 명령이 표시하는 로컬 주소를 브라우저에서 열고, 두 번째 명령은 별도 터미널에서 실행합니다. 제목은 빨강 30px·700, 문단은 검은 배경·흰 글자입니다. CSS가 생성되었는지는 Styles에서 해당 selector를 찾습니다.

작은 변형으로 확인하기

index.css 마지막에 p { background: white !important; }를 잠시 추가하면 bg-black 규칙은 존재하지만 흰색이 이깁니다. 이 경우 source 오류가 아니라 cascade입니다. 비교 후 추가 규칙을 지웁니다.

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

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

의도적으로 class 미적용 문제를 하나씩 재현하고, 과정에서 배운 순서로 정상 상태를 검증합니다.

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

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

TaskFlow 프로젝트 최종 점검

TaskFlow 23단계 실행

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

예제 크게 보기 (새 탭)

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

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

최종 앱 소스는 유지하고 설치/plugin·정적 class·공백·cascade·overflow를 구분하는 진단 문서를 추가합니다.

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

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

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

직접 확인할 결과: 최종 build와 작업 동작이 통과하며 잘못된 class는 완료 소스에 남아 있지 않습니다.

이 시작본은 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 dark:text-gray-100">
      <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-task-muted dark:text-gray-400">
        React Learning Project
      </p>


      <h1 className="text-3xl font-bold text-task-text dark:text-gray-100">
        TaskFlow
      </h1>


      <p className="mt-3 text-base text-gray-600 dark:text-gray-400">
        오늘 할 일을 하나씩 정리해 보세요.
      </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;
  className?: string;
};

export default function TaskCard({
  children,
  actions,
  className,
}: TaskCardProps) {
  return (
    <li className={['@container border p-4', className ?? 'border-task-border bg-task-surface text-task-text dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100'].join(' ')}>
      <div className="flex flex-col gap-3 @md:flex-row @md:items-center">
        {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;
};
const options = [
  { value: 'all', label: '전체' },
  { value: 'active', label: '진행 중' },
  { value: 'completed', label: '완료' },
] as const;
const baseButton = 'transition-colors duration-150 motion-reduce:transition-none min-h-11 border px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black dark:focus-visible:outline-white';
const stateClasses = {
  selected: 'border-black bg-black text-white dark:border-white dark:bg-white dark:text-black',
  idle: 'border-gray-400 bg-white text-gray-900 dark:border-gray-600 dark:bg-gray-950 dark:text-gray-100',
} as const;
export default function TaskFilter({ keyword, onKeywordChange, status, onStatusChange, onReset }: TaskFilterProps) {
  return (
    <section aria-label="작업 필터" className="grid gap-3">
      <label htmlFor="task-keyword">작업 검색</label>
      <input id="task-keyword" value={keyword} onChange={event => onKeywordChange(event.target.value)} className="min-w-0 border border-gray-300 bg-white px-3 py-2 text-gray-950" />
      <div className="flex flex-wrap gap-2" role="group" aria-label="작업 상태">
        {options.map(option => {
          const selected = status === option.value;
          return (
            <button key={option.value} type="button" aria-pressed={selected} onClick={() => onStatusChange(option.value)} className={[baseButton, selected ? stateClasses.selected : stateClasses.idle].join(' ')}>
              {option.label}
            </button>
          );
        })}
      </div>
      <button type="button" onClick={onReset} className="justify-self-start border border-gray-400 px-3 py-2">필터 초기화</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, isSubmitting: isPending },
  } = 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="peer min-h-11 min-w-0 grow border border-gray-300 bg-white px-3 py-2 text-gray-950"
        id="task-title"
        required
        minLength={2}
        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 ||
            '공백만 입력할 수 없습니다.',
        })}
      />

      <span className="hidden text-sm peer-invalid:block">작업 제목을 입력하세요.</span>

      {errors.title && (
        <p id="task-title-error" role="alert">
          {errors.title.message}
        </p>
      )}

      <button type="submit" disabled={isPending} className="min-h-11 shrink-0 border border-black bg-black px-4 py-2 text-white disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-500 focus-visible:outline-2 focus-visible:outline-offset-2">
        {isPending ? '저장 중...' : '추가'}
      </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';

const taskStateClasses = {
  active: 'border-task-border bg-task-surface text-task-text dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100',
  completed: 'border-gray-400 bg-gray-100 text-gray-600 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-400',
} as const;

type TaskItemProps = {
  task: Task;
};

export default function TaskItem({
  task,
}: TaskItemProps) {
  const { dispatch } = useTaskContext();

  const titleClassName = task.completed
    ? 'min-w-0 grow wrap-anywhere text-gray-500 line-through dark:text-gray-400'
    : 'min-w-0 grow wrap-anywhere text-task-text dark:text-gray-100';

  return (
    <TaskCard className={taskStateClasses[task.completed ? 'completed' : 'active']}>
      <button
        className="min-h-11 shrink-0 border border-gray-400 px-3 py-2 aria-pressed:border-black aria-pressed:bg-black aria-pressed:text-white focus-visible:outline-2 focus-visible:outline-offset-2"
        type="button"
        aria-pressed={task.completed}
        onClick={() =>
          dispatch({
            type: 'task/toggled',
            id: task.id,
          })
        }
      >
        {task.completed ? '완료 취소' : '완료'}
      </button>

      <span className={titleClassName}>{task.title}</span>

      <button
        className="min-h-11 shrink-0 border border-gray-400 px-3 py-2 aria-pressed:border-black aria-pressed:bg-black aria-pressed:text-white focus-visible:outline-2 focus-visible:outline-offset-2"
        type="button"
        onClick={() =>
          dispatch({
            type: 'task/deleted',
            id: task.id,
          })
        }
      >
        삭제
      </button>
    <Link className="shrink-0" 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/TaskPreferences.tsx 전체 파일

export default function TaskPreferences() {
  return (
    <label className="flex min-h-11 items-center gap-3 border border-gray-300 bg-white p-3 has-checked:border-black has-checked:bg-gray-100">
      <input
        type="checkbox"
        className="size-4"
      />
      완료 작업 강조
    </label>
  );
}

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-task-border bg-task-surface p-4 text-task-text dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100"
        >
          <p className="text-sm text-task-muted dark:text-gray-400">
            {label}
          </p>
          <strong className="mt-2 block text-2xl font-bold text-task-text dark:text-gray-100">
            {value}
          </strong>
        </div>
      ))}
    </section>
  );
}

src/components/ThemeSettings.tsx 전체 파일

type Theme = 'light' | 'dark';


type ThemeSettingsProps = {
  theme: Theme;
  onChange: (theme: Theme) => void;
};


export default function ThemeSettings({
  theme,
  onChange,
}: ThemeSettingsProps) {
  return (
    <fieldset className="grid gap-2">
      <legend className="text-sm font-medium">
        화면 테마
      </legend>


      <div className="flex gap-2">
        <button
          type="button"
          aria-pressed={theme === 'light'}
          onClick={() => onChange('light')}
          className="min-h-11 border border-gray-400 px-3 py-2 aria-pressed:border-black aria-pressed:bg-black aria-pressed:text-white dark:border-gray-600 dark:aria-pressed:border-white dark:aria-pressed:bg-white dark:aria-pressed:text-black"
        >
          라이트
        </button>


        <button
          type="button"
          aria-pressed={theme === 'dark'}
          onClick={() => onChange('dark')}
          className="min-h-11 border border-gray-400 px-3 py-2 aria-pressed:border-black aria-pressed:bg-black aria-pressed:text-white dark:border-gray-600 dark:aria-pressed:border-white dark:aria-pressed:bg-white dark:aria-pressed:text-black"
        >
          다크
        </button>
      </div>
    </fieldset>
  );
}

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; }
}

@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@layer base { html[data-theme=dark] body { background: #111827; color: #f3f4f6; } }

@theme {
  --color-task-surface: #ffffff;
  --color-task-border: #d1d5db;
  --color-task-text: #111827;
  --color-task-muted: #6b7280;
}

@utility content-auto { content-visibility: auto; }

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 { useEffect, useState } from 'react';
import ThemeSettings from '../components/ThemeSettings';
import TaskPreferences from '../components/TaskPreferences';
import AsyncDemo from '../components/AsyncDemo';
import ProfileSaveForm from '../components/ProfileSaveForm';

export default function SettingsPage() {
  const [theme, setTheme] = useState<'light' | 'dark'>(() => document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light');
  useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]);
  return (
    <div className="grid grid-cols-1 gap-6 md:grid-cols-[12rem_minmax(0,1fr)]">
      <aside className="min-w-0 border border-gray-300 bg-white p-4 dark:border-gray-700 dark:bg-gray-950">설정 메뉴</aside>
      <section className="min-w-0">
      <h2>설정</h2>
      <p>TaskFlow 설정 화면입니다.</p>
      <ThemeSettings theme={theme} onChange={setTheme} />
      <TaskPreferences />
      <ProfileSaveForm /><AsyncDemo />
      </section>
    </div>
  );
}

src/pages/TaskDetailPage.tsx 전체 파일

import TaskItem from '../components/TaskItem';
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 className="wrap-anywhere">{task.title}</h2>
      <aside className="w-64 max-w-full" aria-label="좁은 작업 카드"><ul><TaskItem task={task} /></ul></aside>
      <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>
 <section className="content-auto" aria-label="작업 목록"><TaskList tasks={visibleTasks} /></section>
 <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';
import { fileURLToPath, URL } from 'node:url';

export default defineConfig({
  plugins: [react(), tailwindcss()],
  resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
});

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

시작본과 이번 변경

Tailwind CSS flex 레이아웃 깨질 때 확인할 것의 하단 완료 상태를 기준으로 이어갑니다. 기존 설명은 이전 글의 변경 내용을 연결하는 안내입니다. 위 시작 ZIP에 해당 상태의 전체 파일을 제공하므로 이제 별도로 조립하지 않아도 됩니다.

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

이전 체크포인트

tailwind-step-22-flex-debug

현재 상태

TaskFlow는 다음 구조를 갖췄습니다.

Tailwind CSS v4
기본 utility
Flex / Grid
mobile-first responsive
aria / data / focus-visible variant
group / peer / has
dark mode
arbitrary value
@theme token
Container Query
정적 class map
source detection 기준
custom utility 기준
motion 접근성
최신 v4 utility 이해

이번 목표

의도적으로 class 미적용 문제를 하나씩 재현하고, 과정에서 배운 순서로 정상 상태를 검증합니다.

검증 시나리오 1

vite.config.ts에서 tailwindcss()를 임시 제거합니다.

기본 Test class가 사라지는지 확인한 뒤 즉시 원복합니다.

최종 코드에는 오류를 남기지 않습니다.

검증 시나리오 2

다음 동적 class를 임시 작성합니다.

const broken =
  "text-" +
  color +
  "-600";

정적 map으로 바꿔 해결합니다.

검증 시나리오 3

arbitrary Grid에 공백을 넣어 실패를 확인한 뒤 underscore 표현으로 원복합니다.

검증 시나리오 4

긴 Task 제목을 넣고 Flex overflow가 없는지 확인합니다.

최종 실행 확인

npm run dev
npm run build

그리고 다음을 직접 확인합니다.

모바일 360px 전후
태블릿 768px 전후
데스크톱 1024px 이상


light
dark


keyboard Tab 이동
filter selected
button disabled


긴 Task 제목
Settings Grid
TaskCard container query

최종 Before / After

Before
React TaskFlow
+ 일반 CSS
+ class selector 중심
+ 기능은 완성


After
React TaskFlow
+ Tailwind CSS v4
+ utility-first
+ responsive
+ accessible state variants
+ dark mode
+ @theme design token
+ Container Query
+ static class detection
+ 최소 custom CSS

완료 체크포인트

tailwind-step-23-style-debug

이 체크포인트가 Tailwind CSS 과정의 최종 TaskFlow UI입니다.

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

독자 질문: Tailwind CSS 클래스 적용 안 됨 해결 순서을 읽고 무엇을 직접 확인해야 할까요?

만들 화면: 클래스 미적용을 재현하고 복구 순서를 기록한 TaskFlow 화면을 만듭니다.

  1. 설치→Vite plugin→CSS @import→main 진입점→정적 class→Styles 우선순위 순서로 한 단계씩 확인합니다.
  2. 실패 클래스에는 생성 규칙이 없고 수정한 클래스에는 CSS 규칙과 Computed 값이 나타나는지 비교합니다.
  3. CSS 우선순위 문제와 클래스 생성 누락을 개발자 도구 Styles에서 먼저 구분합니다.

다음 학습: Tailwind 과정 완료 후 필요한 React UI 또는 상태 관리 과정으로 이어갑니다.

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

다음 학습

Tailwind CSS 과정 이후에는 프로젝트 요구에 따라 다음 방향으로 연결할 수 있습니다.

공통 UI component 체계
→ React 29회차 shadcn/ui


더 복잡한 design system
→ shadcn/ui / Radix / variant 설계


서버 상태
→ TanStack Query


클라이언트 전역 상태
→ Zustand

Tailwind는 스타일 계층에 집중하고 각 기술의 책임을 분리합니다.

공식 문서

글 소개·학습 준비·전체 목차
Tailwind CSS: 클래스 적용 오류 표지

Tailwind CSS · 23 / 23 · 선택 오류 해결 · 최종 진단

Tailwind class가 적용되지 않으면 Vite plugin, CSS 진입점의 Tailwind import, 감지 가능한 클래스 문자열을 순서대로 확인합니다. 개발자 도구에 utility 규칙이 생성되어 있다면 설치보다 다른 CSS의 우선순위와 덮어쓰기를 살펴봅니다.

원인은 크게 설치 연결, CSS import, source detection, class 문자열 문법, CSS cascade/우선순위, 잘못된 파일 수정으로 나눌 수 있습니다.

이번 글에서는 Tailwind CSS v4 + React + Vite 기준으로 class 미적용 문제를 처음부터 끝까지 진단하는 순서를 정리하고 TaskFlow 과정 전체를 마무리합니다.

이번 글에서 배우는 내용

  • Tailwind package 설치 확인
  • Vite plugin 연결
  • CSS import 확인
  • main entry CSS 연결
  • source detection
  • dynamic class 이름
  • @source
  • arbitrary value 문법
  • 기존 CSS와 cascade 충돌
  • DevTools 확인
  • production build 재검증

단독 학습 선수 지식

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

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

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

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기