Tailwind border flex 사용법: 레이아웃 기본 클래스 기준

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

border와 border 색상으로 요소의 경계를 지정하고, flex·items-*·justify-*·gap-*으로 방향과 정렬·요소 사이 간격을 지정합니다. 늘어날 영역과 크기를 유지할 버튼은 grow와 shrink-0로 구분합니다.

Tailwind CSS: 테두리와 Flex 배치 표지

Tailwind CSS · 학습 2 / 15 · 필수 학습

이번 글에서는 border와 Flexbox utility를 이용해 TaskForm과 TaskItem을 정리합니다. 아직 반응형 breakpoint는 사용하지 않고 한 방향의 레이아웃을 어떻게 구성하는지에 집중합니다.

이번 글에서 배우는 내용

  • border와 border color
  • flex와 flex-col
  • items-와 justify- 차이
  • flex-wrap과 gap
  • grow와 shrink
  • TaskForm과 TaskItem을 Flexbox로 옮기는 방법

단독 학습 선수 지식

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

목차

border 기본

<div class="border border-gray-300">
  내용
</div>

Tailwind CSS v4에서는 border 색상을 명시하는 편이 의도를 읽기 쉽습니다.

<div class="border-2 border-black">
  두꺼운 경계선
</div>

정보 구분 목적이라면 기본 1px border로 충분한 경우가 많습니다.

flex와 flex-col

<div class="flex">
  <span>A</span>
  <span>B</span>
</div>

기본 방향은 row입니다.

<div class="flex flex-col">
  <span>A</span>
  <span>B</span>
</div>

세로 배치가 필요하면 flex-col을 사용합니다.

items-와 justify- 차이

flex-direction이 row일 때:

justify-*
→ 주축인 가로 방향 배치


items-*
→ 교차축인 세로 방향 정렬
<div class="flex items-center justify-between">
  <span>작업 제목</span>
  <button>삭제</button>
</div>

flex-col이면 주축이 세로로 바뀌므로 먼저 방향을 확인해야 합니다.

gap

<div class="flex items-center gap-3">
  <button>완료</button>
  <span>작업 제목</span>
  <button>삭제</button>
</div>

형제 사이의 반복 간격은 각 요소의 margin보다 gap이 읽기 쉬운 경우가 많습니다.

flex-wrap

내용이 한 줄에 들어가지 않을 때 줄바꿈을 허용할 수 있습니다.

<div class="flex flex-wrap gap-2">
  ...
</div>

TaskItem처럼 긴 제목과 여러 action이 함께 있는 UI에서 유용합니다.

grow와 shrink

남은 공간을 제목이 사용하게 만들 수 있습니다.

<span class="grow">
  작업 제목
</span>

버튼은 내용 크기를 유지해야 할 때 shrink-0을 사용할 수 있습니다.

<button class="shrink-0">
  삭제
</button>

실제 overflow 문제는 22회차에서 더 자세히 다룹니다.

자주 발생하는 실수

border만 넣고 색상을 확인하지 않습니다

className="border border-gray-300"

처럼 색상을 명시하면 결과가 더 예측 가능합니다.

items-center와 justify-center를 혼동합니다

현재 flex-direction을 먼저 확인합니다.

모든 요소에 grow를 넣습니다

grow는 남은 공간을 분배합니다. 실제로 늘어나야 하는 요소에만 적용합니다.

학습 완료 체크리스트

  • border와 border color를 구분할 수 있습니다.
  • flex와 flex-col을 사용할 수 있습니다.
  • items-와 justify-의 역할을 설명할 수 있습니다.
  • gap과 flex-wrap을 사용할 수 있습니다.
  • grow와 shrink-0가 필요한 상황을 판단할 수 있습니다.

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

프로필 문구와 버튼이 Flex로 배치된 결과를 확인하세요. 보기 버튼은 배치 예제입니다.

예제 크게 보기 (새 탭)

프로필과 보기 버튼을 한 행에 정렬합니다.

준비와 실행 파일

HTML의 요소·속성과 CSS 속성을 알고, 이 JSX 예제는 React 함수 컴포넌트의 반환 구조를 읽을 수 있어야 합니다. 새 Vite React + TypeScript 앱에서 Tailwind v4 설치의 plugin·CSS 연결 절차를 먼저 완료합니다. 생성된 index.html의 root를 유지하고 아래 세 파일을 각 경로에 저장합니다. TaskFlow 상태·컴포넌트·서버는 사용하지 않습니다.

src/App.tsx 전체 파일

export default function ProfileRow() {
  return (
    <div className="flex items-center gap-3 border border-gray-300 bg-white p-4">
      <div className="grow">
        <p className="font-medium text-gray-900">
          해비
        </p>
        <p className="text-sm text-gray-500">
          Frontend Developer
        </p>
      </div>


      <button
        type="button"
        className="min-h-11 shrink-0 border border-gray-400 px-3 py-2"
      >
        보기
      </button>
    </div>
  );
}

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

첫 명령이 표시하는 로컬 주소를 브라우저에서 열고, 두 번째 명령은 별도 터미널에서 실행합니다. grow 영역이 남은 너비를 쓰고 shrink-0 버튼은 내용 폭을 유지합니다. 보기 버튼은 레이아웃 관찰용이며 이동 동작은 없습니다.

작은 변형으로 확인하기

flex-col을 추가하면 프로필과 버튼이 세로로 쌓입니다. gap-3이 주축 사이 간격으로 유지되는지 봅니다.

TaskFlow 프로젝트 적용

TaskFlow 4단계 실행

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

예제 크게 보기 (새 탭)

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

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

기존 RHF TaskForm과 Context dispatch를 보존하면서 입력·버튼·TaskCard에 Flex/border utility를 적용합니다. 부분 예시의 onAdd props로 기존 상태 구조를 바꾸지 않습니다.

  1. src/components/TaskCard.tsx: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.
  2. src/components/TaskForm.tsx: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.
  3. src/components/TaskItem.tsx: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.

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

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

직접 확인할 결과: 작업 추가·완료·삭제는 기존대로 동작하고 입력/카드의 정렬만 바뀝니다.

이 시작본은 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>
      {tasks.map((task) => (
        <TaskItem key={task.id} task={task} />
      ))}
    </ul>
  );
}
src/components/TaskStats.tsx 전체 파일
import type { Task } from '../types/task';

type TaskStatsProps = {
  tasks: Task[];
};

export default function TaskStats({
  tasks,
}: TaskStatsProps) {
  const completedCount = tasks.filter(
    (task) => task.completed,
  ).length;

  return (
    <section>
      <h2>작업 통계</h2>
      <p>전체: {tasks.length}</p>
      <p>완료: {completedCount}</p>
      <p>진행 중: {tasks.length - completedCount}</p>
    </section>
  );
}
src/components/TaskSummary.tsx 전체 파일
type TaskSummaryProps = {
  total: number;
  completed: number;
  remaining: number;
  label?: string;
};

export default function TaskSummary({
  total,
  completed,
  remaining,
  label = '작업 현황',
}: TaskSummaryProps) {
  return (
    <section aria-label={label}>
      <p>
        전체 {total} · 완료 {completed} · 남음 {remaining}
      </p>
    </section>
  );
}
src/context/TaskContext.tsx 전체 파일
import {
  createContext,
  useContext,
  useReducer,
  type Dispatch,
  type ReactNode,
} from 'react';
import { taskReducer } from '../reducers/taskReducer';
import type {
  Task,
  TaskAction,
} from '../types/task';

type TaskContextValue = {
  tasks: Task[];
  dispatch: Dispatch<TaskAction>;
};

const TaskContext = createContext<
  TaskContextValue | undefined
>(undefined);

const initialTasks: Task[] = [
  {
    id: 'task-1',
    title: 'Reducer와 Context 연결하기',
    completed: false,
  },
];

export function TaskProvider({
  children,
}: {
  children: ReactNode;
}) {
  const [tasks, dispatch] = useReducer(
    taskReducer,
    initialTasks,
  );

  return (
    <TaskContext.Provider
      value={{ tasks, dispatch }}
    >
      {children}
    </TaskContext.Provider>
  );
}

export function useTaskContext() {
  const value = useContext(TaskContext);

  if (!value) {
    throw new Error(
      'useTaskContext는 TaskProvider 안에서 사용해야 합니다.',
    );
  }

  return value;
}
src/hooks/useNetworkStatus.ts 전체 파일
import {
  useEffect,
  useEffectEvent,
  useState,
} from 'react';

export function useNetworkStatus(
  showDebugMessage: boolean,
) {
  const [isOnline, setIsOnline] = useState(
    navigator.onLine,
  );

  const onOnline = useEffectEvent(() => {
    if (showDebugMessage) {
      console.log('TaskFlow가 온라인 상태입니다.');
    }

    setIsOnline(true);
  });

  const onOffline = useEffectEvent(() => {
    if (showDebugMessage) {
      console.log('TaskFlow가 오프라인 상태입니다.');
    }

    setIsOnline(false);
  });

  useEffect(() => {
    const handleOnline = () => onOnline();
    const handleOffline = () => onOffline();

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener(
        'online',
        handleOnline,
      );
      window.removeEventListener(
        'offline',
        handleOffline,
      );
    };
  }, []);

  return isOnline;
}
src/hooks/useTaskFilter.ts 전체 파일
import {
  useDeferredValue,
  useState,
  useTransition,
  type Dispatch,
  type SetStateAction,
} from 'react';
import type {
  Task,
  TaskStatusFilter,
} from '../types/task';

export function useTaskFilter(tasks: Task[]) {
  const [keyword, setKeyword] = useState('');
  const [status, setStatusState] =
    useState<TaskStatusFilter>('all');
  const [isPending, startTransition] =
    useTransition();

  const deferredKeyword =
    useDeferredValue(keyword);

  const normalizedKeyword = deferredKeyword
    .trim()
    .toLowerCase();

  const visibleTasks = tasks.filter((task) => {
    const matchesKeyword = task.title
      .toLowerCase()
      .includes(normalizedKeyword);

    const matchesStatus =
      status === 'all' ||
      (status === 'active' && !task.completed) ||
      (status === 'completed' && task.completed);

    return matchesKeyword && matchesStatus;
  });

  const setStatus: Dispatch<
    SetStateAction<TaskStatusFilter>
  > = (nextStatus) => {
    startTransition(() => {
      setStatusState(nextStatus);
    });
  };

  const resetFilter = () => {
    setKeyword('');
    startTransition(() => {
      setStatusState('all');
    });
  };

  return {
    keyword,
    setKeyword,
    status,
    setStatus,
    visibleTasks,
    resetFilter,
    isStale: keyword !== deferredKeyword,
    isPending,
  };
}
src/hooks/useTaskStats.ts 전체 파일
import { useTaskContext } from '../context/TaskContext';

export function useTaskStats() {
  const { tasks } = useTaskContext();

  const totalCount = tasks.length;

  const completedCount = tasks.filter(
    (task) => task.completed,
  ).length;

  const remainingCount =
    totalCount - completedCount;

  return {
    totalCount,
    completedCount,
    remainingCount,
  };
}
src/index.css 전체 파일
@import "tailwindcss";

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, sans-serif;
  color: #111;
  background: #f5f5f5;
}

ul {
  padding-left: 20px;
}

button, input, select { min-height: 44px; font: inherit; }
button, input, select { border: 1px solid #aaa; padding: 8px 12px; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
ul { list-style: none; padding: 0; display: grid; gap: 12px; }
.task-card { border: 1px solid #ccc; padding: 16px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.task-card__content, .task-card__actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
form, section { margin-block: 20px; }
input:not([type="checkbox"]), select { max-width: 100%; }
details { margin-block: 24px; } summary { cursor: pointer; min-height: 44px; }
body { overflow-wrap: anywhere; }
[role="dialog"], [data-radix-popper-content-wrapper] { --radius: 0; }
src/main.tsx 전체 파일
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import './index.css';
import App from './App';
import { TaskProvider } from './context/TaskContext';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <TaskProvider>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </TaskProvider>
  </StrictMode>,
);
src/pages/SettingsPage.tsx 전체 파일
import AsyncDemo from '../components/AsyncDemo';
import ProfileSaveForm from '../components/ProfileSaveForm';

export default function SettingsPage() {
  return (
    <section>
      <h2>설정</h2>
      <p>TaskFlow 설정 화면입니다.</p>
      <ProfileSaveForm /><AsyncDemo />
    </section>
  );
}
src/pages/TaskDetailPage.tsx 전체 파일
import {
  Link,
  useParams,
} from 'react-router';
import { useTaskContext } from '../context/TaskContext';

export default function TaskDetailPage() {
  const { taskId } = useParams();
  const { tasks } = useTaskContext();

  const task = tasks.find(
    (item) => item.id === taskId,
  );

  if (!task) {
    return (
      <section>
        <h2>작업을 찾을 수 없습니다.</h2>
        <Link to="/tasks">
          목록으로 돌아가기
        </Link>
      </section>
    );
  }

  return (
    <section>
      <h2>{task.title}</h2>
      <p>
        상태: {task.completed ? '완료' : '진행 중'}
      </p>
      <Link to="/tasks">
        목록으로 돌아가기
      </Link>
    </section>
  );
}
src/pages/TaskListPage.tsx 전체 파일
import { useEffect, lazy, Suspense } from 'react';
import TaskForm from '../components/TaskForm';
import TaskList from '../components/TaskList';
import TaskFilter from '../components/TaskFilter';
import StateResetDemo from '../components/StateResetDemo';
import NetworkStatus from '../components/NetworkStatus';
import TaskSummary from '../components/TaskSummary';
import { useTaskContext } from '../context/TaskContext';
import { useTaskStats } from '../hooks/useTaskStats';
import { useTaskFilter } from '../hooks/useTaskFilter';
import StatsSkeleton from '../components/StatsSkeleton';
const TaskStats = lazy(() => import('../components/TaskStats'));
export default function TaskListPage() {
 const { tasks } = useTaskContext();
 const { totalCount, completedCount, remainingCount } = useTaskStats();
 const { keyword, setKeyword, status, setStatus, visibleTasks, resetFilter, isStale, isPending } = useTaskFilter(tasks);
 useEffect(() => { document.title = `TaskFlow (${remainingCount})`; }, [remainingCount]);
 return <section 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·실제 환경 변수 파일은 포함하지 않습니다.

시작본과 이번 변경

Tailwind CSS 기본 클래스 사용법: text font bg spacing 익히기의 하단 완료 상태를 기준으로 이어갑니다. 기존 설명은 이전 글의 변경 내용을 연결하는 안내입니다. 위 시작 ZIP에 해당 상태의 전체 파일을 제공하므로 이제 별도로 조립하지 않아도 됩니다.

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

이전 체크포인트

tailwind-step-03-utilities

현재 상태

페이지와 Header는 Tailwind로 옮겼지만 TaskForm과 TaskItem은 기존 CSS 레이아웃을 사용합니다.

이번 목표

TaskForm과 TaskItem의 border·정렬·간격을 Tailwind Flexbox utility로 교체합니다.

변경 파일

src/components/TaskForm.tsx
src/components/TaskItem.tsx
src/index.css

src/components/TaskForm.tsx

import { useState } from 'react';


type TaskFormProps = {
  onAdd: (title: string) => void;
};


export default function TaskForm({
  onAdd,
}: TaskFormProps) {
  const [title, setTitle] = useState('');


  const handleSubmit = (
    event: React.FormEvent<HTMLFormElement>,
  ) => {
    event.preventDefault();


    const value = title.trim();
    if (!value) return;


    onAdd(value);
    setTitle('');
  };


  return (
    <form
      onSubmit={handleSubmit}
      className="flex flex-wrap gap-2"
    >
      <label
        htmlFor="task-title"
        className="sr-only"
      >
        새 작업
      </label>


      <input
        id="task-title"
        value={title}
        onChange={(event) =>
          setTitle(event.target.value)
        }
        className="min-h-11 min-w-0 grow border border-gray-300 bg-white px-3 py-2 text-gray-950"
        placeholder="할 일을 입력하세요"
      />


      <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

export default function TaskItem({
  task,
  onToggle,
  onDelete,
}: TaskItemProps) {
  return (
    <li className="flex flex-wrap items-center gap-3 border border-gray-300 bg-white p-4">
      <button
        type="button"
        onClick={() => onToggle(task.id)}
        className="min-h-11 shrink-0 border border-gray-400 px-3 py-2"
      >
        {task.completed ? '완료 취소' : '완료'}
      </button>


      <span className="min-w-0 grow text-gray-900">
        {task.title}
      </span>


      <button
        type="button"
        onClick={() => onDelete(task.id)}
        className="min-h-11 shrink-0 border border-gray-400 px-3 py-2"
      >
        삭제
      </button>
    </li>
  );
}

기존 CSS 제거

기존 .task-card에 있던 display, gap, padding, border 규칙은 동일 역할을 Tailwind class가 맡으면 제거합니다.

Before / After

Before
.task-card
form display:flex
개별 gap/padding/border CSS


After
flex
flex-wrap
items-center
gap-*
border
border-gray-*
p-*
grow / shrink-0

실행 확인

npm run dev
npm run build
  • 입력창과 버튼이 자연스럽게 배치되는가
  • 좁은 화면에서 필요한 경우 줄바꿈되는가
  • Task 제목이 남은 공간을 사용하는가
  • 버튼의 최소 높이가 확보되는가
  • 기존 작업 기능이 그대로 동작하는가

현재 확장 단계

✓ 1~3. 로드맵·설치·기본 utility
→ 4. border / Flex
○ 5. Preflight
○ 6. Grid
○ 7. responsive

완료 체크포인트

tailwind-step-04-flex

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

독자 질문: Tailwind border flex 사용법 레이아웃 기본 클래스 기준을 읽고 무엇을 직접 확인해야 할까요?

만들 화면: 작업 입력 폼과 작업 카드의 한 줄 정렬을 만듭니다.

  1. 폼의 입력과 버튼은 flex flex-wrap items-center gap-3, 카드는 border border-gray-300 p-4를 적용합니다.
  2. 전에는 세로로 흩어진 요소가 이후 한 행에서 정렬되고 공간 부족 시 줄이 바뀌는지 확인합니다.
  3. 좁은 화면에서 요소가 잘리면 flex-wrap 적용 여부를, 테두리가 안 보이면 border 색상과 기존 CSS를 확인합니다.

다음 학습: Tailwind CSS Preflight 기준: 기본 스타일 초기화가 생기는 이유 — https://blogflow.kr/tailwind-css-preflight-base-style-reset/

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

다음 학습

다음 글에서는 Tailwind를 설치한 뒤 heading, list, button 등의 기본 스타일이 달라지는 이유를 Preflight 기준으로 정리합니다.

다음 글: Tailwind CSS Preflight 기준: 기본 스타일 초기화가 생기는 이유

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

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

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기