Tailwind 설치 후 제목 크기와 목록 점이 달라지는 Preflight는 무엇인가요?
Tailwind 설치 뒤 제목이 작아지거나 목록 점이 사라지면 Preflight를 확인합니다. 기본 스타일을 초기화하므로 필요한 제목 크기·목록 표시·버튼 모양은 유틸리티로 명시합니다.
적용 조건·범위: Tailwind v4에서는 border color가 currentColor 기준으로 동작하는 변경이 있습니다.
본문 근거: tailwindcss.com/docs/preflight · tailwindcss.com/docs/upgrade-guide
Preflight란 무엇인가
브라우저는 HTML 요소마다 기본 스타일을 제공합니다.
예를 들어 h1은 큰 글자, ul은 bullet, button은 브라우저별 기본 모양을 가집니다.
Tailwind는 이런 기본값 일부를 정리하고 개발자가 utility로 명시적으로 스타일을 결정하도록 합니다.
브라우저 기본값에 의존
→ 환경마다 차이 가능
Preflight + utility
→ 프로젝트에서 의도한 값 명시
heading
Tailwind를 import하면 heading 요소가 브라우저 기본 크기를 그대로 유지하지 않을 수 있습니다.
따라서 다음처럼 명시합니다.
<h1 class="text-3xl font-bold">
TaskFlow
</h1>
h1이라는 태그 자체가 시맨틱 의미를 담당하고, 시각 크기는 utility가 담당합니다.
list
ul과 ol의 기본 marker가 초기화될 수 있습니다.
목록 점이 필요하면 직접 지정합니다.
<ul class="list-disc pl-5">
<li>첫 번째</li>
<li>두 번째</li>
</ul>
TaskFlow 작업 목록처럼 카드 UI를 사용하는 경우 marker가 없는 현재 형태가 자연스럽습니다.
border
Tailwind v4에서는 border color가 currentColor 기준으로 동작하는 변경이 있습니다.
예상 가능한 UI를 만들려면 다음처럼 색상을 함께 지정합니다.
<div class="border border-gray-300">
...
</div>
button과 input
브라우저 기본 UI가 정리되기 때문에 버튼 모양을 브라우저 기본값에 맡기지 않습니다.
<button
className="min-h-11 border border-black bg-black px-4 py-2 text-white"
>
저장
</button>
input도 동일합니다.
<input
className="min-h-11 border border-gray-300 bg-white px-3 py-2 text-gray-950"
/>
기존 reset CSS와 겹치는 경우
Tailwind 도입 전 프로젝트에 다음과 같은 reset이 있을 수 있습니다.
* {
box-sizing: border-box;
}
button,
input,
select {
font: inherit;
}
ul {
list-style: none;
padding: 0;
}
Preflight가 이미 담당하는 부분과 중복될 수 있습니다.
중복 자체가 즉시 오류는 아니지만 어디서 스타일이 결정되는지 추적하기 어려워집니다.
Tailwind로 이전한 영역부터 불필요한 reset을 제거합니다.
Preflight를 끄는 것이 답인가
기존 사이트를 Tailwind로 옮길 때 Preflight 때문에 시각 변화가 크다면 비활성화를 검토할 수 있는 특수 상황도 있습니다.
하지만 새 학습 프로젝트에서는 Preflight를 기본으로 받아들이고 필요한 스타일을 명시적으로 작성하는 편이 Tailwind의 기본 흐름을 이해하기 쉽습니다.
시맨틱 HTML과 시각 스타일은 별개입니다
h1이 기본적으로 크게 보이지 않더라도 h1을 div로 바꿀 이유는 없습니다.
<h1 class="text-3xl font-bold">
문서의 주제
</h1>
시맨틱 구조는 HTML이 담당하고 시각 표현은 Tailwind가 담당합니다.
자주 발생하는 오류
h1이 작아졌습니다
Preflight 이후 text size를 명시하지 않은 경우입니다.
ul 점이 없어졌습니다
marker가 필요한 목록이라면 list-disc 등을 직접 지정합니다.
border 색이 이상합니다
v4에서는 색상을 명시합니다.
<div class="border border-gray-300">
학습 완료 체크리스트
- Preflight의 목적을 설명할 수 있습니다.
- heading과 list 기본 스타일 변화가 설치 오류가 아님을 이해했습니다.
- 시맨틱 태그와 시각 스타일을 분리할 수 있습니다.
- 기존 reset CSS와 Tailwind Preflight의 중복을 점검할 수 있습니다.
- v4 border 색상을 명시적으로 작성할 수 있습니다.
독립 실습: 프로젝트 없이 결과 확인
Preflight의 기본 요소와 utility를 명시한 제목·목록을 비교하세요.
예제 크게 보기 (새 탭)직접 실행: 환경 준비와 전체 파일
초기화된 요소와 utility를 명시한 요소를 같은 화면에서 비교합니다.
준비와 실행 파일
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 space-y-4"><section><h1>기본 제목</h1><ul><li>기본 목록</li></ul></section><section><h2 className="text-2xl font-medium">명시한 제목</h2><ul className="list-disc pl-5"><li>명시한 목록</li></ul><button type="button" className="border border-black px-3 py-2">예제 버튼</button></section></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
첫 명령이 표시하는 로컬 주소를 브라우저에서 열고, 두 번째 명령은 별도 터미널에서 실행합니다. 위쪽 제목은 기본 글자 크기·굵기를 따르고 목록 점이 없으며 아래쪽 제목과 목록은 지정 크기와 점을 가집니다. 버튼은 모양 관찰용입니다.
작은 변형으로 확인하기
아래 ul에서 list-disc만 제거하면 점이 사라지고 pl-5 들여쓰기는 남습니다. h2 태그의 의미는 utility와 별개입니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Tailwind CSS로 앱 화면을 다듬습니다.
Preflight와 중복되는 전역 reset을 점검하고 TaskFlow에 필요한 스타일은 Tailwind utility로 명시합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 5단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)실행 가능한 시작본과 이번 단계
이번 단계 시작 ZIP을 새 폴더에 압축 해제합니다. 이 ZIP은 직전 단계의 완료 파일과 같으며 앞 글의 코드를 따로 모을 필요가 없습니다. 이번 단계 완료 ZIP은 다른 폴더에 풀어 비교합니다.
중복 reset을 Preflight에 맡기고 아직 utility로 옮기지 않은 기존 보조 폼의 기본 스타일은 base layer에 보존합니다. TaskList는 명시한 목록·간격 class를 사용합니다.
src/components/TaskList.tsx: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.src/index.css: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.
짧은 코드 예제는 변경 위치를 설명합니다. 파일 전체를 작성하거나 교체할 때는 완료 파일 전체를 사용하세요. 생략 표시가 있는 부분 코드를 파일 전체로 저장하지 않습니다.
실행: Node.js 22.12 이상에서 ZIP 루트의 package.json·package-lock.json을 유지하고 npm ci 후 npm run dev를 실행합니다. 별도 터미널에서 npm run build를 실행합니다. React·라우터·기존 상태 구조를 유지하고 Tailwind 스타일만 단계적으로 적용합니다.
직접 확인할 결과: 목록 점은 없고 Header 크기와 TaskForm 경계는 utility로 유지됩니다.
이 시작본은 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-12 text-gray-950">
<Header />
<nav aria-label="주요 메뉴">
<NavLink to="/tasks">
작업
</NavLink>
{' · '}
<NavLink to="/settings">
설정
</NavLink>
</nav>
<Outlet />
</main>
);
}
src/components/AsyncDemo.tsx 전체 파일
import { useState } from 'react';
import OptimisticTaskList from './OptimisticTaskList';
import type { Task } from '../types/task';
export default function AsyncDemo() {
const [tasks, setTasks] = useState<Task[]>([{id:'success',title:'저장 성공 실습',completed:false},{id:'fail',title:'저장 실패·복구 실습',completed:false}]);
return <details><summary>낙관적 UI 실습</summary><p>실제 서버가 아닌 지연 모의 요청입니다. 아래 목록은 기본 작업 목록과 분리됩니다.</p>
<OptimisticTaskList tasks={tasks} onCommitToggle={id => setTasks(previous => previous.map(task => task.id === id ? {...task,completed:!task.completed} : task))} /></details>;
}
src/components/Header.tsx 전체 파일
export default function Header() {
return (
<header>
<p className="mb-2 text-sm font-medium text-gray-500">
React Learning Project
</p>
<h1 className="text-3xl font-bold text-black">
TaskFlow
</h1>
<p className="mt-3 text-base text-gray-600">
오늘 할 일을 하나씩 정리해 보세요.
</p>
</header>
);
}
src/components/NetworkStatus.tsx 전체 파일
import { useState } from 'react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
export default function NetworkStatus() {
const [showDebugMessage, setShowDebugMessage] = useState(false);
const isOnline = useNetworkStatus(showDebugMessage);
return <section aria-label="네트워크 상태"><p>네트워크: {isOnline ? '온라인' : '오프라인'}</p>
<label><input type="checkbox" checked={showDebugMessage} onChange={e => setShowDebugMessage(e.target.checked)} />네트워크 로그 표시</label></section>;
}
src/components/OptimisticTaskList.tsx 전체 파일
import {
useTransition,
useOptimistic,
useState,
} from 'react';
import { updateTaskOnServer } from '../actions/updateTask';
import type { Task } from '../types/task';
type OptimisticTaskListProps = {
tasks: Task[];
onCommitToggle: (taskId: string) => void;
};
export default function OptimisticTaskList({
tasks,
onCommitToggle,
}: OptimisticTaskListProps) {
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] =
useState('');
const [optimisticTasks, toggleOptimistic] =
useOptimistic(
tasks,
(currentTasks, taskId: string) =>
currentTasks.map((task) =>
task.id === taskId
? {
...task,
completed: !task.completed,
}
: task,
),
);
const handleToggle = (taskId: string) => {
setErrorMessage('');
startTransition(async () => {
toggleOptimistic(taskId);
try {
await updateTaskOnServer(taskId);
startTransition(() => onCommitToggle(taskId));
} catch {
setErrorMessage(
'저장에 실패했습니다. 기존 상태로 돌아갑니다.',
);
}
});
};
return (
<section>
{errorMessage ? (
<p role="alert">{errorMessage}</p>
) : null}
<ul>
{optimisticTasks.map((task) => (
<li key={task.id}>
<button
type="button"
disabled={isPending}
onClick={() => handleToggle(task.id)}
>
{task.completed ? '완료 취소' : '완료'}
</button>
<span>{task.title}</span>
</li>
))}
</ul>
</section>
);
}
src/components/ProfileSaveForm.tsx 전체 파일
import { useActionState } from 'react';
import {
saveProfile,
type SaveProfileState,
} from '../actions/saveProfile';
const initialState: SaveProfileState = {
message: '',
};
export default function ProfileSaveForm() {
const [state, formAction, isPending] =
useActionState(saveProfile, initialState);
return (
<form action={formAction}>
<label>
표시 이름
<input name="displayName" />
</label>
<button disabled={isPending}>
{isPending ? '저장 중...' : '저장'}
</button>
<p>{state.message}</p>
</form>
);
}
src/components/StateResetDemo.tsx 전체 파일
import { useState } from 'react';
import TaskEditor from './TaskEditor';
import type { Task } from '../types/task';
const examples: Task[] = [
{ id: 'example-1', title: '첫 번째 작업', completed: false },
{ id: 'example-2', title: '두 번째 작업', completed: false },
];
export default function StateResetDemo() {
const [selectedId, setSelectedId] = useState(examples[0].id);
const selectedTask = examples.find(task => task.id === selectedId)!;
return <details><summary>state 초기화 실습</summary>
<p>입력값은 저장되지 않습니다. 작업을 바꾸면 key가 달라져 입력값이 초기화됩니다.</p>
{examples.map(task => <button type="button" key={task.id} onClick={() => setSelectedId(task.id)}>{task.title} 선택</button>)}
<TaskEditor key={selectedTask.id} task={selectedTask} />
</details>;
}
src/components/StatsSkeleton.tsx 전체 파일
export default function StatsSkeleton() {
return (
<section aria-busy="true">
<h2>작업 통계</h2>
<p>통계를 불러오는 중...</p>
</section>
);
}
src/components/TaskCard.tsx 전체 파일
import type { ReactNode } from 'react';
type TaskCardProps = {
children: ReactNode;
actions?: ReactNode;
};
export default function TaskCard({
children,
actions,
}: TaskCardProps) {
return (
<li className="border border-gray-300 bg-white p-4">
<div className="flex flex-wrap items-center gap-3">
{children}
</div>
{actions ? (
<div className="task-card__actions">
{actions}
</div>
) : null}
</li>
);
}
src/components/TaskEditor.tsx 전체 파일
import { useState } from 'react';
import type { Task } from '../types/task';
type TaskEditorProps = {
task: Task;
};
export default function TaskEditor({
task,
}: TaskEditorProps) {
const [title, setTitle] = useState(task.title);
return (
<section>
<h2>작업 편집</h2>
<label htmlFor="editor-title">편집 입력 연습</label>
<input id="editor-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
<p>선택한 작업 ID: {task.id}</p>
</section>
);
}
src/components/TaskFilter.tsx 전체 파일
import type {
Dispatch,
SetStateAction,
} from 'react';
import type { TaskStatusFilter } from '../types/task';
type TaskFilterProps = {
keyword: string;
onKeywordChange: Dispatch<SetStateAction<string>>;
status: TaskStatusFilter;
onStatusChange: Dispatch<
SetStateAction<TaskStatusFilter>
>;
onReset: () => void;
};
export default function TaskFilter({
keyword,
onKeywordChange,
status,
onStatusChange,
onReset,
}: TaskFilterProps) {
return (
<section>
<label htmlFor="task-keyword">
작업 검색
</label>
<input
id="task-keyword"
value={keyword}
onChange={(event) =>
onKeywordChange(event.target.value)
}
/>
<label htmlFor="task-status">
상태
</label>
<select
id="task-status"
value={status}
onChange={(event) =>
onStatusChange(
event.target.value as TaskStatusFilter,
)
}
>
<option value="all">전체</option>
<option value="active">진행 중</option>
<option value="completed">완료</option>
</select>
<button type="button" onClick={onReset}>
필터 초기화
</button>
</section>
);
}
src/components/TaskForm.tsx 전체 파일
import { useForm } from 'react-hook-form';
import { useTaskContext } from '../context/TaskContext';
type TaskFormValues = {
title: string;
};
export default function TaskForm() {
const { dispatch } = useTaskContext();
const {
register,
handleSubmit,
setValue,
setFocus,
formState: { errors },
} = useForm<TaskFormValues>({
defaultValues: {
title: '',
},
});
const onSubmit = (values: TaskFormValues) => {
const title = values.title.trim();
dispatch({
type: 'task/added',
task: {
id: crypto.randomUUID(),
title,
completed: false,
},
});
setValue('title', '');
setFocus('title');
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="flex flex-wrap gap-2">
<label htmlFor="task-title">
새 작업
</label>
<input
className="min-h-11 min-w-0 grow border border-gray-300 bg-white px-3 py-2 text-gray-950"
id="task-title"
aria-invalid={Boolean(errors.title)}
aria-describedby={
errors.title ? 'task-title-error' : undefined
}
{...register('title', {
setValueAs: (value: string) => value.trim(),
required: '작업 제목을 입력해 주세요.',
minLength: {
value: 2,
message: '작업 제목은 2자 이상 입력해 주세요.',
},
validate: (value) =>
value.trim().length > 0 ||
'공백만 입력할 수 없습니다.',
})}
/>
{errors.title && (
<p id="task-title-error" role="alert">
{errors.title.message}
</p>
)}
<button type="submit" className="min-h-11 shrink-0 border border-black bg-black px-4 py-2 text-white">
추가
</button>
</form>
);
}
src/components/TaskItem.tsx 전체 파일
import { Link } from 'react-router';
import TaskCard from './TaskCard';
import { useTaskContext } from '../context/TaskContext';
import type { Task } from '../types/task';
type TaskItemProps = {
task: Task;
};
export default function TaskItem({
task,
}: TaskItemProps) {
const { dispatch } = useTaskContext();
return (
<TaskCard>
<button
className="min-h-11 shrink-0 border border-gray-400 px-3 py-2"
type="button"
onClick={() =>
dispatch({
type: 'task/toggled',
id: task.id,
})
}
>
{task.completed ? '완료 취소' : '완료'}
</button>
<span className="min-w-0 grow text-gray-900">{task.title}</span>
<button
className="min-h-11 shrink-0 border border-gray-400 px-3 py-2"
type="button"
onClick={() =>
dispatch({
type: 'task/deleted',
id: task.id,
})
}
>
삭제
</button>
<Link to={`/tasks/${task.id}`}>상세</Link>
</TaskCard>
);
}
src/components/TaskList.tsx 전체 파일
import { useTaskContext } from '../context/TaskContext';
import type { Task } from '../types/task';
import TaskItem from './TaskItem';
type TaskListProps = {
tasks: Task[];
};
export default function TaskList({
tasks,
}: TaskListProps) {
const { tasks: allTasks } = useTaskContext();
if (allTasks.length === 0) {
return <p>등록된 작업이 없습니다.</p>;
}
if (tasks.length === 0) {
return <p>조건에 맞는 작업이 없습니다.</p>;
}
return (
<ul className="flex flex-col gap-3">
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
);
}
src/components/TaskStats.tsx 전체 파일
import type { Task } from '../types/task';
type TaskStatsProps = {
tasks: Task[];
};
export default function TaskStats({
tasks,
}: TaskStatsProps) {
const completedCount = tasks.filter(
(task) => task.completed,
).length;
return (
<section>
<h2>작업 통계</h2>
<p>전체: {tasks.length}</p>
<p>완료: {completedCount}</p>
<p>진행 중: {tasks.length - completedCount}</p>
</section>
);
}
src/components/TaskSummary.tsx 전체 파일
type TaskSummaryProps = {
total: number;
completed: number;
remaining: number;
label?: string;
};
export default function TaskSummary({
total,
completed,
remaining,
label = '작업 현황',
}: TaskSummaryProps) {
return (
<section aria-label={label}>
<p>
전체 {total} · 완료 {completed} · 남음 {remaining}
</p>
</section>
);
}
src/context/TaskContext.tsx 전체 파일
import {
createContext,
useContext,
useReducer,
type Dispatch,
type ReactNode,
} from 'react';
import { taskReducer } from '../reducers/taskReducer';
import type {
Task,
TaskAction,
} from '../types/task';
type TaskContextValue = {
tasks: Task[];
dispatch: Dispatch<TaskAction>;
};
const TaskContext = createContext<
TaskContextValue | undefined
>(undefined);
const initialTasks: Task[] = [
{
id: 'task-1',
title: 'Reducer와 Context 연결하기',
completed: false,
},
];
export function TaskProvider({
children,
}: {
children: ReactNode;
}) {
const [tasks, dispatch] = useReducer(
taskReducer,
initialTasks,
);
return (
<TaskContext.Provider
value={{ tasks, dispatch }}
>
{children}
</TaskContext.Provider>
);
}
export function useTaskContext() {
const value = useContext(TaskContext);
if (!value) {
throw new Error(
'useTaskContext는 TaskProvider 안에서 사용해야 합니다.',
);
}
return value;
}
src/hooks/useNetworkStatus.ts 전체 파일
import {
useEffect,
useEffectEvent,
useState,
} from 'react';
export function useNetworkStatus(
showDebugMessage: boolean,
) {
const [isOnline, setIsOnline] = useState(
navigator.onLine,
);
const onOnline = useEffectEvent(() => {
if (showDebugMessage) {
console.log('TaskFlow가 온라인 상태입니다.');
}
setIsOnline(true);
});
const onOffline = useEffectEvent(() => {
if (showDebugMessage) {
console.log('TaskFlow가 오프라인 상태입니다.');
}
setIsOnline(false);
});
useEffect(() => {
const handleOnline = () => onOnline();
const handleOffline = () => onOffline();
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener(
'online',
handleOnline,
);
window.removeEventListener(
'offline',
handleOffline,
);
};
}, []);
return isOnline;
}
src/hooks/useTaskFilter.ts 전체 파일
import {
useDeferredValue,
useState,
useTransition,
type Dispatch,
type SetStateAction,
} from 'react';
import type {
Task,
TaskStatusFilter,
} from '../types/task';
export function useTaskFilter(tasks: Task[]) {
const [keyword, setKeyword] = useState('');
const [status, setStatusState] =
useState<TaskStatusFilter>('all');
const [isPending, startTransition] =
useTransition();
const deferredKeyword =
useDeferredValue(keyword);
const normalizedKeyword = deferredKeyword
.trim()
.toLowerCase();
const visibleTasks = tasks.filter((task) => {
const matchesKeyword = task.title
.toLowerCase()
.includes(normalizedKeyword);
const matchesStatus =
status === 'all' ||
(status === 'active' && !task.completed) ||
(status === 'completed' && task.completed);
return matchesKeyword && matchesStatus;
});
const setStatus: Dispatch<
SetStateAction<TaskStatusFilter>
> = (nextStatus) => {
startTransition(() => {
setStatusState(nextStatus);
});
};
const resetFilter = () => {
setKeyword('');
startTransition(() => {
setStatusState('all');
});
};
return {
keyword,
setKeyword,
status,
setStatus,
visibleTasks,
resetFilter,
isStale: keyword !== deferredKeyword,
isPending,
};
}
src/hooks/useTaskStats.ts 전체 파일
import { useTaskContext } from '../context/TaskContext';
export function useTaskStats() {
const { tasks } = useTaskContext();
const totalCount = tasks.length;
const completedCount = tasks.filter(
(task) => task.completed,
).length;
const remainingCount =
totalCount - completedCount;
return {
totalCount,
completedCount,
remainingCount,
};
}
src/index.css 전체 파일
@import "tailwindcss";
@layer base {
body { font-family: Arial, sans-serif; color: #111; background: #f5f5f5; }
h2 { font-size: 24px; font-weight: 700; }
h3 { font-size: 20px; font-weight: 500; }
button, input, select { min-height: 44px; border: 1px solid #aaa; padding: 8px 12px; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
input:not([type="checkbox"]), select { max-width: 100%; }
form, section { margin-block: 20px; }
details { margin-block: 24px; }
summary { cursor: pointer; min-height: 44px; }
:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
}
src/main.tsx 전체 파일
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import './index.css';
import App from './App';
import { TaskProvider } from './context/TaskContext';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<TaskProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</TaskProvider>
</StrictMode>,
);
src/pages/SettingsPage.tsx 전체 파일
import AsyncDemo from '../components/AsyncDemo';
import ProfileSaveForm from '../components/ProfileSaveForm';
export default function SettingsPage() {
return (
<section>
<h2>설정</h2>
<p>TaskFlow 설정 화면입니다.</p>
<ProfileSaveForm /><AsyncDemo />
</section>
);
}
src/pages/TaskDetailPage.tsx 전체 파일
import {
Link,
useParams,
} from 'react-router';
import { useTaskContext } from '../context/TaskContext';
export default function TaskDetailPage() {
const { taskId } = useParams();
const { tasks } = useTaskContext();
const task = tasks.find(
(item) => item.id === taskId,
);
if (!task) {
return (
<section>
<h2>작업을 찾을 수 없습니다.</h2>
<Link to="/tasks">
목록으로 돌아가기
</Link>
</section>
);
}
return (
<section>
<h2>{task.title}</h2>
<p>
상태: {task.completed ? '완료' : '진행 중'}
</p>
<Link to="/tasks">
목록으로 돌아가기
</Link>
</section>
);
}
src/pages/TaskListPage.tsx 전체 파일
import { useEffect, lazy, Suspense } from 'react';
import TaskForm from '../components/TaskForm';
import TaskList from '../components/TaskList';
import TaskFilter from '../components/TaskFilter';
import StateResetDemo from '../components/StateResetDemo';
import NetworkStatus from '../components/NetworkStatus';
import TaskSummary from '../components/TaskSummary';
import { useTaskContext } from '../context/TaskContext';
import { useTaskStats } from '../hooks/useTaskStats';
import { useTaskFilter } from '../hooks/useTaskFilter';
import StatsSkeleton from '../components/StatsSkeleton';
const TaskStats = lazy(() => import('../components/TaskStats'));
export default function TaskListPage() {
const { tasks } = useTaskContext();
const { totalCount, completedCount, remainingCount } = useTaskStats();
const { keyword, setKeyword, status, setStatus, visibleTasks, resetFilter, isStale, isPending } = useTaskFilter(tasks);
useEffect(() => { document.title = `TaskFlow (${remainingCount})`; }, [remainingCount]);
return <section className="mt-8">
<h2>작업</h2>
<NetworkStatus />
<TaskSummary total={totalCount} completed={completedCount} remaining={remainingCount} />
<TaskForm />
<TaskFilter keyword={keyword} onKeywordChange={setKeyword} status={status} onStatusChange={setStatus} onReset={resetFilter} />
<p role="status">{isStale ? "검색 결과 갱신 중..." : isPending ? "필터 갱신 중..." : ""}</p>
<TaskList tasks={visibleTasks} />
<Suspense fallback={<StatsSkeleton />}><TaskStats tasks={tasks} /></Suspense>
<StateResetDemo />
</section>;
}
src/reducers/taskReducer.ts 전체 파일
import type {
Task,
TaskAction,
} from '../types/task';
export function taskReducer(
tasks: Task[],
action: TaskAction,
): Task[] {
switch (action.type) {
case 'task/added':
return [...tasks, action.task];
case 'task/toggled':
return tasks.map((task) =>
task.id === action.id
? { ...task, completed: !task.completed }
: task,
);
case 'task/deleted':
return tasks.filter(
(task) => task.id !== action.id,
);
default:
return tasks;
}
}
src/types/task.ts 전체 파일
export type TaskId = string;
export type Task = {
id: TaskId;
title: string;
completed: boolean;
};
export type TaskAction =
| {
type: 'task/added';
task: Task;
}
| {
type: 'task/toggled';
id: TaskId;
}
| {
type: 'task/deleted';
id: TaskId;
};
export type TaskStatusFilter = 'all' | 'active' | 'completed';
tsconfig.json 전체 파일
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"types": [
"vite/client"
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src",
"vite.config.ts"
]
}
vite.config.ts 전체 파일
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
});
위 파일과 ZIP의 동명 파일은 동일합니다. package-lock.json과 회차별 README·CHANGES 문서는 ZIP에 포함합니다. 생성 산출물·node_modules·실제 환경 변수 파일은 포함하지 않습니다.
src/index.css 점검
기존 예:
@import "tailwindcss";
* {
box-sizing: border-box;
}
button,
input,
select {
font: inherit;
}
ul {
list-style: none;
padding: 0;
}
Tailwind v4의 Preflight를 기준으로 중복 reset을 제거하고 프로젝트 전용 global 스타일만 남길 수 있습니다.
@import "tailwindcss";
body {
background: #f5f5f5;
}
body 배경도 이후 utility나 @theme로 옮길 예정이므로 임시로 유지할 수 있습니다.
시작본과 이번 변경
Tailwind border flex 사용법: 레이아웃 기본 클래스 기준의 하단 완료 상태를 기준으로 이어갑니다. 기존 설명은 이전 글의 변경 내용을 연결하는 안내입니다. 위 시작 ZIP에 해당 상태의 전체 파일을 제공하므로 이제 별도로 조립하지 않아도 됩니다.
아래 변경 파일과 각 코드의 적용 위치를 따라 이번 속성만 바꾼 뒤, 기존 추가·완료·삭제 기능을 확인하고 이 글의 실행 확인 항목으로 화면을 비교합니다. 부분 코드의 생략 영역에는 이전 단계의 props·상태·handler를 유지합니다.
이전 체크포인트
tailwind-step-04-flex
현재 상태
Header, TaskForm, TaskItem 일부가 Tailwind로 옮겨졌고 기존 index.css에는 reset과 element selector가 남아 있습니다.
이번 목표
Preflight와 중복되는 전역 reset을 점검하고 TaskFlow에 필요한 스타일은 Tailwind utility로 명시합니다.
변경 파일
src/index.css
src/components/Header.tsx
src/components/TaskList.tsx
src/components/TaskForm.tsx
TaskList
작업 목록은 marker가 필요하지 않습니다.
return (
<ul className="flex flex-col gap-3">
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
/>
))}
</ul>
);
Header
<h1 className="text-3xl font-bold text-black">
TaskFlow
</h1>
heading의 시맨틱은 유지하면서 시각 스타일을 명시합니다.
실행 확인
npm run dev
npm run build
확인할 내용:
- h1 크기와 굵기가 의도대로 유지되는가
- 작업 목록 marker가 의도대로 숨겨지는가
- button/input 스타일이 유지되는가
- 기존 reset 제거 후 기능에 변화가 없는가
- border 색상이 의도와 일치하는가
현재 확장 단계
✓ 1~4. 설치·utility·Flex
→ 5. Preflight
○ 6. Grid
○ 7. responsive
○ 8~17. 상태·설계
완료 체크포인트
tailwind-step-05-preflight
직접 만드는 화면과 적용 전후 점검
독자 질문: Tailwind CSS Preflight 기준 기본 스타일 초기화가 생기는 이유을 읽고 무엇을 직접 확인해야 할까요?
만들 화면: TaskFlow 제목·목록·버튼의 기본 스타일을 명시한 화면을 만듭니다.
- h1과 ul, button의 초기 모양을 기록하고 필요한 text-2xl, list-none, border 등만 요소에 지정합니다.
- 적용 전 브라우저 기본 heading/marker와 Tailwind import 후 값을 개발자 도구 Computed에서 비교합니다.
- reset CSS 중복과 Preflight를 구분하고 동일 요소의 사용자 CSS 우선순위를 확인합니다.
다음 학습: Tailwind CSS Grid 실무 레이아웃 정리: 카드 목록부터 대시보드까지 — https://blogflow.kr/tailwind-css-grid-practical-layout/
검증 기록: 이 편집 작업에서 화면 변경·개발 서버·빌드·브라우저 조작은 실행하지 않았습니다. 위 내용은 독자 실습의 예상 확인 기준이며 실제 결과는 미확인입니다.
다음 학습
다음 글에서는 2차원 레이아웃이 필요한 Task 요약 카드와 설정 영역을 Grid utility로 구성합니다.
다음 글: Tailwind CSS Grid 실무 레이아웃 정리: 카드 목록부터 대시보드까지
공식 문서
- Preflight: https://tailwindcss.com/docs/preflight
- Upgrade guide: https://tailwindcss.com/docs/upgrade-guide
글 소개·학습 준비·전체 목차

Tailwind CSS · 5 / 23 · 필수 학습 · Preflight
Tailwind를 설치한 뒤 h1이 갑자기 작아지거나 ul의 점이 사라지고 button의 기본 모양이 달라졌다면 설치가 잘못된 것이 아닐 수 있습니다.
Tailwind CSS는 Preflight라는 기본 스타일 계층을 포함합니다. 브라우저마다 다른 기본 스타일을 정리하고 utility 기반으로 명시적인 스타일을 작성하기 쉽게 만드는 역할입니다.
이번 글에서 배우는 내용
- Preflight의 역할
- heading 기본 스타일이 사라지는 이유
- list style이 초기화되는 이유
- border 기본 동작
- button·input이 달라지는 이유
- 기존 reset CSS와 중복을 줄이는 기준
단독 학습 선수 지식
HTML 요소·속성, CSS의 선택자·속성·box model을 먼저 익힙니다. React 예제는 함수 컴포넌트와 JSX를 사용합니다. 상단 실습은 새 Vite 앱에서 실행하고, TaskFlow 완료본은 하단에서만 사용합니다.
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 23개 · 필수 16개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.