Tailwind CSS 동적 클래스가 감지되지 않을 때 정적 클래스와 @source를 어떻게 사용하나요?
Tailwind는 JavaScript를 실행하지 않고 소스 문자열을 읽습니다. 클래스 이름을 조각으로 조합하지 말고 완성된 이름의 정적 목록에서 고릅니다. 감지할 소스가 빠진 경우에 @source를 검토합니다.
적용 조건·범위: monorepo처럼 build working directory와 실제 source 위치가 다르면 source()로 기준 경로를 명시할 수 있습니다.
본문 근거: tailwindcss.com/docs/detecting-classes-in-source-files
Tailwind는 코드를 실행하지 않습니다
다음 React 코드를 보겠습니다.
type Color = 'red' | 'blue';
function Badge({
color,
}: {
color: Color;
}) {
return (
<span
className={
'bg-' +
color +
'-500'
}
>
상태
</span>
);
}
브라우저 runtime에서는 color가 red면 bg-red-500이 만들어질 수 있습니다.
하지만 Tailwind source scanner가 보는 소스에는 완성된 bg-red-500 문자열이 없습니다.
bg-
red 또는 blue 변수
-500
따라서 필요한 CSS가 생성되지 않을 수 있습니다.
해결: 완성된 class를 소스에 작성
const colorClasses = {
red:
'bg-red-500 text-white',
blue:
'bg-blue-500 text-white',
} as const;
<span className={colorClasses[color]}>
상태
</span>
이제 소스에 bg-red-500과 bg-blue-500이 완성된 문자열로 존재합니다.
Tailwind가 둘 다 감지할 수 있습니다.
조건부 class는 괜찮습니다
다음 코드는 완성된 class가 양쪽 분기에 존재합니다.
const className = error
? 'text-red-700'
: 'text-gray-700';
이런 조건부 선택은 문제가 없습니다.
문제는 class 이름의 일부를 runtime에 조립하는 것입니다.
자동 source detection
Tailwind v4는 프로젝트 소스 파일을 자동으로 스캔합니다.
일반적으로 다음은 자동 감지 대상에서 제외될 수 있습니다.
- .gitignore에 포함된 파일
- node_modules
- binary file
- CSS file
- lock file
따라서 일반 src 안의 React 컴포넌트는 별도 content 배열 설정 없이 자동으로 감지되는 경우가 많습니다.
@source가 필요한 경우
node_modules 안의 외부 UI package처럼 기본적으로 스캔되지 않는 위치에서 Tailwind class를 사용한다면 @source로 등록할 수 있습니다.
@import "tailwindcss";
@source "../node_modules/@company/ui";
이 지시어는 “동적 문자열을 해석해 달라”는 기능이 아닙니다.
스캔할 파일 위치를 추가하는 기능입니다.
base path 명시
monorepo처럼 build working directory와 실제 source 위치가 다르면 source()로 기준 경로를 명시할 수 있습니다.
@import "tailwindcss" source("../src");
일반 Vite 단일 프로젝트에서는 기본 자동 감지로 충분한 경우가 많습니다.
특정 class 강제 생성
소스에 없는 class를 명시적으로 생성해야 하는 특수 상황에서는 @source inline()을 사용할 수 있습니다.
@import "tailwindcss";
@source inline("underline");
하지만 React prop을 동적 class로 조립해 놓고 수백 개 class를 safelist하는 방식보다 static map으로 구조를 바꾸는 것이 우선입니다.
문제를 구분하기
bg-${color}-500 형태
→ class 이름 생성 방식 문제
→ static map으로 수정
외부 package 안에 완성 class가 있음
→ source path 문제
→ @source 검토
일반 src 파일의 완성 class가 안 잡힘
→ import / build / source base path 등 점검
원인을 섞지 않습니다.
독립 예제
잘못된 방식:
type Size =
| 'sm'
| 'lg';
function Button({
size,
}: {
size: Size;
}) {
return (
<button
className={
'px-' +
(size === 'sm'
? '2'
: '4')
}
>
버튼
</button>
);
}
수정:
const sizeClasses = {
sm:
'px-2 py-1 text-sm',
lg:
'px-4 py-3 text-lg',
} as const;
function Button({
size,
}: {
size: keyof typeof sizeClasses;
}) {
return (
<button
className={
sizeClasses[size]
}
>
버튼
</button>
);
}
자주 발생하는 실수
동적 class 문제에 @source를 추가합니다
@source는 파일 위치를 지정합니다. runtime 문자열을 계산하지 않습니다.
node_modules 전체를 무조건 source로 추가합니다
필요한 외부 package만 대상으로 합니다.
build를 확인하지 않습니다
development HMR만 보고 끝내지 않고 production build에서도 class가 포함되는지 확인합니다.
npm run build
학습 완료 체크리스트
- Tailwind가 소스 파일을 텍스트로 스캔한다는 것을 이해했습니다.
- 동적 문자열 class가 누락되는 이유를 설명할 수 있습니다.
- static map에 완성된 class 문자열을 작성할 수 있습니다.
- @source가 source path용 기능임을 이해했습니다.
- dynamic class 문제와 source path 문제를 구분할 수 있습니다.
독립 실습: 프로젝트 없이 결과 확인
재사용 Button의 sm·lg 크기를 나란히 비교하세요.
예제 크게 보기 (새 탭)크기를 정적 map에서 고르는 버튼 두 개를 독립적으로 만듭니다.
준비와 실행 파일
HTML의 요소·속성과 CSS 속성을 알고, 이 JSX 예제는 React 함수 컴포넌트의 반환 구조를 읽을 수 있어야 합니다. 새 Vite React + TypeScript 앱에서 Tailwind v4 설치의 plugin·CSS 연결 절차를 먼저 완료합니다. 생성된 index.html의 root를 유지하고 아래 세 파일을 각 경로에 저장합니다. TaskFlow 상태·컴포넌트·서버는 사용하지 않습니다.
src/App.tsx 전체 파일
const sizeClasses = { sm: 'px-2 py-1 text-sm', lg: 'px-4 py-3 text-lg' } as const;
function Button({ size }: { size: keyof typeof sizeClasses }) {
return <button type="button" className={sizeClasses[size]}>{size}</button>;
}
export default function App() {
return <main className="flex gap-4 p-4"><Button size="sm" /><Button size="lg" /></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
첫 명령이 표시하는 로컬 주소를 브라우저에서 열고, 두 번째 명령은 별도 터미널에서 실행합니다. sm보다 lg 버튼의 여백과 글자가 큽니다. 두 버튼은 형태 비교용이며 클릭 동작이 없습니다. production CSS에도 px-2와 px-4 규칙이 생성되어야 합니다.
작은 변형으로 확인하기
sm 값을 px-3 py-2 text-base로 바꿔 빌드합니다. 문자열 일부 조립 없이 완성된 class만 바뀌었는지 확인합니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Tailwind CSS로 앱 화면을 다듬습니다.
TaskFlow에 일부러 동적 color class를 만들어 문제를 재현하고 static map으로 수정합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 16단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)실행 가능한 시작본과 이번 단계
이번 단계 시작 ZIP을 새 폴더에 압축 해제합니다. 이 ZIP은 직전 단계의 완료 파일과 같으며 앞 글의 코드를 따로 모을 필요가 없습니다. 이번 단계 완료 ZIP은 다른 폴더에 풀어 비교합니다.
TaskItem의 완료/진행 상태를 정적 taskStateClasses map으로 전달하도록 TaskCard의 선택 class prop을 완결합니다. 동적 문자열 조립은 진단 설명에서만 사용합니다.
src/components/TaskCard.tsx: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.src/components/TaskItem.tsx: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.
짧은 코드 예제는 변경 위치를 설명합니다. 파일 전체를 작성하거나 교체할 때는 완료 파일 전체를 사용하세요. 생략 표시가 있는 부분 코드를 파일 전체로 저장하지 않습니다.
실행: Node.js 22.12 이상에서 ZIP 루트의 package.json·package-lock.json을 유지하고 npm ci 후 npm run dev를 실행합니다. 별도 터미널에서 npm run build를 실행합니다. React·라우터·기존 상태 구조를 유지하고 Tailwind 스타일만 단계적으로 적용합니다.
직접 확인할 결과: 추가 작업의 진행 배경과 완료 후 배경이 구분되며 두 상태 CSS가 production 출력에 존재합니다.
이 시작본은 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 = '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 text-gray-500 line-through dark:text-gray-400'
: 'min-w-0 grow 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 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;
}
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="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>{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>
<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·실제 환경 변수 파일은 포함하지 않습니다.
시작본과 이번 변경
React Tailwind className 정리: 조건부·variant·가독성 기준의 하단 완료 상태를 기준으로 이어갑니다. 기존 설명은 이전 글의 변경 내용을 연결하는 안내입니다. 위 시작 ZIP에 해당 상태의 전체 파일을 제공하므로 이제 별도로 조립하지 않아도 됩니다.
아래 변경 파일과 각 코드의 적용 위치를 따라 이번 속성만 바꾼 뒤, 기존 추가·완료·삭제 기능을 확인하고 이 글의 실행 확인 항목으로 화면을 비교합니다. 부분 코드의 생략 영역에는 이전 단계의 props·상태·handler를 유지합니다.
이전 체크포인트
tailwind-step-15-classname
현재 상태
TaskFilter와 TaskItem의 조건부 class는 static 문자열로 정리됐습니다.
이번 목표
TaskFlow에 일부러 동적 color class를 만들어 문제를 재현하고 static map으로 수정합니다.
재현용 잘못된 코드
type StatusTone =
| 'gray'
| 'green';
const tone =
task.completed
? 'green'
: 'gray';
const className =
'border-' +
tone +
'-500';
이 코드는 학습용 재현입니다.
최종 코드에 남기지 않습니다.
수정
상태 의미가 있는 완료 표현만 제한적으로 색상을 사용한다고 가정합니다.
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;
const stateKey =
task.completed
? 'completed'
: 'active';
<li
className={
taskStateClasses[
stateKey
]
}
>
...
</li>
모든 class 문자열이 완전한 형태로 소스에 존재합니다.
@source를 사용하지 않는 이유
TaskItem은 src/components 안에 있고 Tailwind가 자동 감지할 수 있습니다.
따라서 이 문제를 해결하기 위해 @source를 추가하지 않습니다.
외부 UI package 예시
향후 별도 package에 Tailwind class가 있고 node_modules 경로로 소비한다면:
@source "../node_modules/@my-company/task-ui";
같은 설정을 검토할 수 있습니다.
실행 확인
npm run dev
npm run build
- 재현 코드에서 class가 생성되지 않는 문제를 확인했는가
- static map으로 바꾼 뒤 스타일이 적용되는가
- source path 설정을 불필요하게 추가하지 않았는가
- production build가 성공하는가
완료 체크포인트
tailwind-step-16-source-detection
직접 만드는 화면과 적용 전후 점검
독자 질문: Tailwind CSS 동적 클래스 해결 감지 원리와 @source 사용법을 읽고 무엇을 직접 확인해야 할까요?
만들 화면: 작업 상태에 따라 배경색이 바뀌는 카드의 실패·복구 비교를 만듭니다.
- 동적 조합 bg-${color}-500 대신 조건 분기로 완성된 bg-gray-200 또는 bg-black을 선택합니다.
- 실패 예와 수정 예를 개발자 도구 Styles에서 비교해 생성된 규칙이 있는지 확인합니다.
- 외부 패키지의 소스가 감지되지 않을 때만 @source 경로를 점검합니다.
다음 학습: Tailwind CSS @apply 사용 기준: @utility와 custom variant 구분하기 — https://blogflow.kr/tailwind-css-apply-utility-custom-variant/
검증 기록: 이 편집 작업에서 화면 변경·개발 서버·빌드·브라우저 조작은 실행하지 않았습니다. 위 내용은 독자 실습의 예상 확인 기준이며 실제 결과는 미확인입니다.
다음 학습
다음 글에서는 Tailwind class가 반복될 때 @apply, @utility, custom variant 중 어떤 방식으로 확장해야 하는지 기준을 정리합니다.
다음 글: Tailwind CSS @apply 사용 기준: @utility와 custom variant 구분하기
공식 문서
- Detecting classes in source files: https://tailwindcss.com/docs/detecting-classes-in-source-files
글 소개·학습 준비·전체 목차

Tailwind CSS · 16 / 23 · 필수 학습 · source detection
Tailwind class가 개발 코드에 분명히 있는데 스타일이 생성되지 않는 문제는 Tailwind 입문에서 자주 만나는 오류입니다.
가장 먼저 이해할 것은 Tailwind가 JavaScript 코드를 실행해서 className 결과를 계산하지 않는다는 점입니다. 소스 파일을 일반 텍스트로 스캔하고, 완성된 class 이름처럼 보이는 token을 찾아 필요한 CSS를 생성합니다.
이번 글에서는 동적 문자열 조합이 왜 실패하는지, static map으로 어떻게 해결하는지, @source는 언제 사용하는지를 분리해서 설명합니다.
이번 글에서 배우는 내용
- Tailwind source detection 원리
- 동적 class 이름 조합이 실패하는 이유
- static class map
- 자동 source detection 대상
- @source
- source() base path
- @source inline과 safelist
- 동적 class 문제와 source path 문제 구분
단독 학습 선수 지식
HTML 요소·속성, CSS의 선택자·속성·box model을 먼저 익힙니다. React 예제는 함수 컴포넌트와 JSX를 사용합니다. 상단 실습은 새 Vite 앱에서 실행하고, TaskFlow 완료본은 하단에서만 사용합니다.
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 23개 · 필수 16개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.