Tailwind CSS @theme 사용법: 변수와 디자인 토큰 구분하기

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

Tailwind CSS @theme와 일반 :root 변수는 어떻게 다른가요?

여러 곳에서 반복하는 색·간격·폰트는 공통 토큰으로 정리합니다. @theme는 변수를 Tailwind 유틸리티 이름과 연결하고, 일반 :root 변수는 CSS 값을 보관하므로 쓰임을 구분합니다.

본문 근거: tailwindcss.com/docs/theme

@theme란 무엇인가

Tailwind의 theme variable은 일반 CSS variable이면서 Tailwind utility 생성에 영향을 주는 특별한 변수입니다.

@theme {
  --color-task-surface: #ffffff;
}

이 값을 정의하면 다음과 같은 utility를 사용할 수 있습니다.

<div class="bg-task-surface">
  ...
</div>

즉 @theme는 값 저장만 하는 것이 아니라 Tailwind class API를 확장합니다.

일반 :root와 차이

일반 CSS variable:

:root {
  --app-header-height: 56px;
}

이 값은 그대로 CSS에서 사용할 수 있습니다.

height: var(--app-header-height);

하지만 자동으로 h-app-header 같은 Tailwind utility가 만들어지는 것은 아닙니다.

반면 @theme의 namespace에 맞는 변수는 utility와 연결됩니다.

@theme {
  --color-task-muted: #6b7280;
}
<p class="text-task-muted">
  ...
</p>

언제 @theme를 사용할까

여러 컴포넌트에서 반복
디자인 시스템 규칙
Tailwind utility로 쓰고 싶음
→ @theme


특정 runtime 계산값
Tailwind utility API와 연결할 필요 없음
→ :root 또는 component CSS variable

color token

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

사용:

className="border-task-border bg-task-surface text-task-text"

이름은 역할을 설명해야 합니다

다음처럼 특정 페이지 이름에 지나치게 묶인 token은 재사용성이 낮을 수 있습니다.

--color-task-list-card-1
--color-settings-panel-left

대신 시각적 역할을 기준으로 정리할 수 있습니다.

surface
border
text
muted
accent

프로젝트 규모가 작다면 token을 지나치게 세분화하지 않습니다.

spacing token

Tailwind 기본 spacing scale로 충분하다면 custom spacing을 만들 필요가 없습니다.

하지만 프로젝트 전체에서 반복되는 특수 크기가 있다면 theme variable을 검토할 수 있습니다.

@theme {
  --spacing-touch: 2.75rem;
}

그러면 size-touch, min-h-touch처럼 관련 utility에서 사용할 수 있는 namespace인지 공식 문서를 기준으로 확인합니다.

학습 과정에서는 기본 spacing scale을 우선합니다.

font token

@theme {
  --font-app: Inter, Pretendard, sans-serif;
}

사용:

<body class="font-app">

실제 font 파일 로딩은 @theme와 별개의 문제입니다. font-family 값만 정의한다고 웹폰트가 자동 다운로드되는 것은 아닙니다.

@theme는 top-level에서 정의

theme variable은 Tailwind 설정 역할을 하므로 selector 안에 중첩하는 방식으로 사용하지 않습니다.

@theme {
  --color-task-accent: #111111;
}

dark theme의 실제 값 전환은 일반 CSS variable을 조합하거나 dark utility를 사용하는 식으로 역할을 나눌 수 있습니다.

자주 발생하는 실수

일반 CSS variable과 @theme를 같은 것으로 설명합니다

@theme variable은 Tailwind utility 생성에 영향을 줍니다.

모든 값을 custom token으로 만듭니다

기본 gray, spacing scale로 충분하다면 그대로 사용해도 됩니다.

한 번 쓰는 값까지 token으로 승격합니다

반복성과 의미가 생겼을 때 design token으로 관리합니다.

학습 완료 체크리스트

  • @theme가 Tailwind utility API와 연결된다는 것을 설명할 수 있습니다.
  • @theme와 :root variable을 구분할 수 있습니다.
  • 반복되는 color를 theme token으로 만들 수 있습니다.
  • token 이름을 시각 역할 중심으로 정할 수 있습니다.
  • arbitrary value를 design token으로 승격할 시점을 판단할 수 있습니다.

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

디자인 토큰으로 지정한 카드 배경·테두리·글자색을 확인하세요.

예제 크게 보기 (새 탭)

이 예제 안에서 선언한 app 토큰으로 카드를 칠합니다.

준비와 실행 파일

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

src/App.tsx 전체 파일

export default function Card() {
  return (
    <article className="border border-app-border bg-app-surface p-4 text-app-text">
      <h2 className="font-bold">
        작업
      </h2>
      <p className="mt-2 text-sm text-app-muted">
        디자인 토큰을 사용합니다.
      </p>
    </article>
  );
}

src/index.css 전체 파일

@import "tailwindcss";


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

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

첫 명령이 표시하는 로컬 주소를 브라우저에서 열고, 두 번째 명령은 별도 터미널에서 실행합니다. 카드 배경은 흰색이고 border-app-border와 text-app-text가 @theme 변수에서 생성됩니다. TaskFlow 토큰은 필요하지 않습니다.

작은 변형으로 확인하기

--color-app-surface를 #eeeeee로 바꾸면 App.tsx를 수정하지 않아도 배경이 바뀝니다.

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

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

TaskFlow의 핵심 surface·border·text token을 @theme에 정의하고 주요 컴포넌트에서 사용합니다.

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

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

TaskFlow 프로젝트 적용

TaskFlow 13단계 실행

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

예제 크게 보기 (새 탭)

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

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

CSS에 task surface/border/text/muted 토큰을 정의하고 Header·카드·요약의 반복 색을 token utility로 옮깁니다. 기존 dark override는 유지합니다.

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

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

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

직접 확인할 결과: light 기본색은 @theme에서, dark 색은 dark variant에서 관리합니다.

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

export default function TaskCard({
  children,
  actions,
}: TaskCardProps) {
  return (
    <li 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">
      <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;
};
const options = [
  { value: 'all', label: '전체' },
  { value: 'active', label: '진행 중' },
  { value: 'completed', label: '완료' },
] 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="min-h-11 border border-gray-400 bg-white px-3 py-2 text-sm text-gray-900 aria-pressed:border-black aria-pressed:bg-black aria-pressed:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black">
              {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';

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 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="min-w-0 grow text-gray-900 dark:text-gray-100">{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 {
  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";


@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));


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

시작본과 이번 변경

Tailwind CSS 임의 값 공백 처리: 대괄호 문법이 깨질 때 해결하기의 하단 완료 상태를 기준으로 이어갑니다. 기존 설명은 이전 글의 변경 내용을 연결하는 안내입니다. 위 시작 ZIP에 해당 상태의 전체 파일을 제공하므로 이제 별도로 조립하지 않아도 됩니다.

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

이전 체크포인트

tailwind-step-12-arbitrary-fix

현재 상태

TaskFlow에는 gray-* class와 일부 반복 색상 값이 여러 컴포넌트에 직접 작성되어 있습니다.

이번 목표

TaskFlow의 핵심 surface·border·text token을 @theme에 정의하고 주요 컴포넌트에서 사용합니다.

변경 파일

src/index.css
src/components/TaskItem.tsx
src/components/TaskSummary.tsx
src/components/Header.tsx

TaskItem

<li className="flex flex-wrap items-center gap-3 border border-task-border bg-task-surface p-4 text-task-text dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100">
  ...
</li>

light 기본값은 custom token을 사용하고 dark override는 아직 gray scale을 유지합니다.

필요성이 명확해질 때 dark token을 추가할 수 있습니다.

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>

token으로 만들지 않은 것

p-4
gap-3
text-sm
text-3xl

Tailwind 기본 scale로 충분한 값은 그대로 유지합니다.

Before / After

Before
bg-white
border-gray-300
text-gray-900
text-gray-500
반복


After
bg-task-surface
border-task-border
text-task-text
text-task-muted

실행 확인

npm run dev
npm run build
  • custom utility가 실제 생성되는가
  • 기존 light UI와 시각 결과가 크게 달라지지 않는가
  • dark mode가 기존대로 동작하는가
  • token 이름이 특정 컴포넌트에 과도하게 종속되지 않았는가

완료 체크포인트

tailwind-step-13-theme

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

독자 질문: Tailwind CSS @theme 사용법 변수와 디자인 토큰 구분하기을 읽고 무엇을 직접 확인해야 할까요?

만들 화면: 반복되는 브랜드 색을 하나의 token으로 관리한 작업 버튼을 만듭니다.

  1. CSS @theme에 --color-brand: #171717을 선언하고 버튼에 bg-brand text-white를 적용합니다.
  2. 토큰 변경 전후 버튼 배경이 한 곳의 변수 변경을 따라가는지 Computed 색상으로 확인합니다.
  3. CSS 파일이 import되는지와 @theme 변수가 실제 생성된 utility에 연결되는지 봅니다.

다음 학습: Tailwind Container Query 사용법: 부모 너비 기준 반응형 처리 — https://blogflow.kr/tailwind-css-container-query/

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

다음 학습

다음 글에서는 viewport가 아니라 TaskCard가 실제로 놓인 부모 영역의 너비에 맞춰 레이아웃을 바꾸는 Container Query를 적용합니다.

다음 글: Tailwind Container Query 사용법: 부모 너비 기준 반응형 처리

공식 문서

글 소개·학습 준비·전체 목차
Tailwind CSS: 디자인 토큰 관리 표지

Tailwind CSS · 13 / 23 · 필수 학습 · @theme design token

arbitrary value를 사용하다 보면 같은 색상·간격·폰트 값이 여러 곳에서 반복될 수 있습니다.

반복되는 값이 프로젝트 디자인 규칙이 됐다면 더 이상 “한 번만 쓰는 값”이 아닙니다. Tailwind CSS v4에서는 @theme에 theme variable을 정의해 Tailwind utility API와 직접 연결할 수 있습니다.

이번 글에서는 @theme와 일반 :root CSS variable의 차이를 이해하고 TaskFlow의 색상과 간격을 design token으로 정리합니다.

이번 글에서 배우는 내용

  • theme variable이 무엇인가
  • @theme와 :root 차이
  • color namespace
  • spacing·font token
  • utility 생성과 theme variable 관계
  • design token 이름을 정하는 기준
  • arbitrary value를 token으로 승격하는 기준

단독 학습 선수 지식

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

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

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

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기