Tailwind, Radix UI, shadcn/ui는 어떤 역할 차이로 선택하나요?
Tailwind는 스타일 도구, Radix Primitives는 상호작용·접근성 기본 UI, shadcn/ui는 직접 소유·수정하는 컴포넌트 코드입니다. 같은 대체품으로 고르기보다 필요한 역할부터 나눕니다.
적용 조건·범위: Tailwind 과정에서는 도입 기준까지만 정리합니다.
본문 근거: www.radix-ui.com/primitives · ui.shadcn.com
Tailwind CSS
Tailwind는 스타일링을 담당합니다.
spacing
color
layout
responsive
state variant
dark mode
design token
Dialog의 focus trap, Escape 닫기, keyboard navigation 같은 복잡한 behavior를 Tailwind가 구현해 주는 것은 아닙니다.
Radix Primitives
Radix Primitives는 기본적으로 unstyled에 가깝고 behavior와 accessibility에 집중합니다.
예를 들어 Dialog 같은 primitive에서는 다음 구현 비용을 줄일 수 있습니다.
- role과 aria
- focus 관리
- keyboard interaction
- open/close behavior
- portal
- overlay 구조
스타일은 Tailwind 등 원하는 방식으로 직접 작성합니다.
즉 다음 조합이 가능합니다.
Radix behavior
+
Tailwind style
shadcn/ui
shadcn/ui는 일반적인 “npm package에서 완성 컴포넌트를 import해서 끝” 방식과 다릅니다.
CLI를 통해 Button, Dialog 같은 컴포넌트 코드를 프로젝트 안으로 가져오고 개발자가 그 코드를 직접 수정·소유합니다.
shadcn CLI
→ components/ui/button.tsx 생성
→ 프로젝트 코드로 소유
→ 필요하면 직접 수정
최근 shadcn/ui는 Radix 외의 기반 선택지와 preset 기능도 확장되고 있으므로 “shadcn = Radix를 그대로 감싼 패키지”라고만 설명하면 범위가 좁습니다.
핵심은 CLI가 디자인 시스템 코드와 설정을 프로젝트에 생성하고, 사용자가 이를 소유한다는 점입니다.
비교
Tailwind CSS
역할: 스타일
동작/접근성 primitive: 직접 구현 필요
Radix Primitives
역할: headless behavior + accessibility
스타일: 직접 작성
shadcn/ui
역할: 프로젝트가 소유하는 UI component 코드
스타일/variant/theme 구조 포함
필요에 따라 primitive 기반 동작 사용
primitive가 유리한 UI
다음은 직접 구현 난도가 높아집니다.
- Dialog
- Dropdown Menu
- Select
- Popover
- Tooltip
- Tabs
- Combobox
keyboard interaction과 focus management까지 정확히 구현해야 하기 때문입니다.
이런 UI는 Radix 또는 검증된 component layer를 검토할 가치가 있습니다.
shadcn/ui를 선택할 이유
- Tailwind 기반 스타일을 빠르게 시작하고 싶음
- Button/Input/Dialog 등 공통 UI 규칙이 필요함
- 생성된 코드를 직접 수정하고 싶음
- 프로젝트 디자인 시스템에 맞게 소유·변경하고 싶음
반대로 UI가 매우 단순하면 shadcn/ui 전체 도입 없이 native HTML + Tailwind만으로 충분할 수 있습니다.
의존성 비용
새 UI 도구를 도입하면 다음도 함께 생깁니다.
- package dependency
- CLI 설정
- import alias
- components.json
- component 업데이트 전략
- theme token 구조
- 생성 코드 유지보수
“예쁜 버튼을 빨리 만들기 위해” 도입하기 전에 실제 필요한 복합 UI가 있는지 확인합니다.
학습 완료 체크리스트
- Tailwind가 스타일링 도구라는 것을 설명할 수 있습니다.
- Radix Primitives가 behavior와 accessibility를 담당한다는 것을 이해했습니다.
- shadcn/ui가 생성된 컴포넌트 코드를 프로젝트가 소유하는 방식임을 이해했습니다.
- native HTML로 충분한 UI와 primitive가 유리한 UI를 구분할 수 있습니다.
- 라이브러리 도입 비용까지 포함해 선택할 수 있습니다.
독립 실습: 기본 요소에서 선택 기준 확인
이메일을 입력하고 도움말 details를 펼치거나 접어 보세요.
예제 크게 보기 (새 탭)기본 HTML이 이미 제공하는 입력·접힘 동작을 직접 확인한 뒤 도구를 고릅니다.
준비와 실행 파일
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="grid gap-4 p-4"><label>이메일 <input type="email" required className="border p-2" /></label><details className="border p-4"><summary>도움말</summary><p>단순 접힘은 기본 HTML로 시작할 수 있습니다.</p></details></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
첫 명령이 표시하는 로컬 주소를 브라우저에서 열고, 두 번째 명령은 별도 터미널에서 실행합니다. 이메일 입력과 details 열기·닫기는 Radix 없이 동작합니다. Tailwind class는 이 동작을 구현하지 않고 모양을 지정합니다.
작은 변형으로 확인하기
추가 요구가 modal focus 이동·초점 가두기·닫기 후 원래 버튼 복귀라면 이 details를 그대로 쓰기보다 dialog 또는 primitive를 검토합니다. 디자인 소스 소유·수정까지 필요할 때 shadcn 후보를 비교합니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Tailwind CSS로 앱 화면을 다듬습니다.
새 라이브러리를 설치하지 않고 앞으로 필요한 UI를 기준으로 선택표를 작성합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 20단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)실행 가능한 시작본과 이번 단계
이번 단계 시작 ZIP을 새 폴더에 압축 해제합니다. 이 ZIP은 직전 단계의 완료 파일과 같으며 앞 글의 코드를 따로 모을 필요가 없습니다. 이번 단계 완료 ZIP은 다른 폴더에 풀어 비교합니다.
소스와 의존성을 유지하고 docs/ui-library-choice.md에 현재 native UI와 복합 UI의 선택 기준을 작성합니다.
docs/ui-library-choice.md: 새 파일로 저장합니다.
짧은 코드 예제는 변경 위치를 설명합니다. 파일 전체를 작성하거나 교체할 때는 완료 파일 전체를 사용하세요. 생략 표시가 있는 부분 코드를 파일 전체로 저장하지 않습니다.
실행: Node.js 22.12 이상에서 ZIP 루트의 package.json·package-lock.json을 유지하고 npm ci 후 npm run dev를 실행합니다. 별도 터미널에서 npm run build를 실행합니다. React·라우터·기존 상태 구조를 유지하고 Tailwind 스타일만 단계적으로 적용합니다.
직접 확인할 결과: input·필터·테마·TaskCard는 기존 구현을 유지하며 Radix/shadcn 설치는 하지 않습니다.
이 시작본은 React step-28의 실제 전체 소스입니다. 기존 글의 일반화된 App.tsx/props 조각과 달리 실제 라우터의 AppLayout·Context·RHF API를 유지한 위치에 반영했습니다. README와 CHANGES.md에 각 파일의 변경 diff가 포함되어 있습니다.
전체 파일·코드 보기
완료 파일 전체
index.html 전체 파일
<!doctype html>
<html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>TaskFlow</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
package.json 전체 파일
{
"name": "taskflow-step-28-library-choice",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0",
"react-hook-form": "7.88.0",
"react-router": "7.18.4"
},
"devDependencies": {
"typescript": "7.0.2",
"vite": "8.3.0",
"@vitejs/plugin-react": "6.1.1",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/node": "26.6.2",
"@tailwindcss/vite": "4.3.3",
"tailwindcss": "4.3.3"
}
}
src/App.tsx 전체 파일
import {
Navigate,
Route,
Routes,
} from 'react-router';
import AppLayout from './components/AppLayout';
import SettingsPage from './pages/SettingsPage';
import TaskDetailPage from './pages/TaskDetailPage';
import TaskListPage from './pages/TaskListPage';
export default function App() {
return (
<Routes>
<Route element={<AppLayout />}>
<Route
index
element={<Navigate to="/tasks" replace />}
/>
<Route
path="/tasks"
element={<TaskListPage />}
/>
<Route
path="/tasks/:taskId"
element={<TaskDetailPage />}
/>
<Route
path="/settings"
element={<SettingsPage />}
/>
</Route>
</Routes>
);
}
src/actions/saveProfile.ts 전체 파일
export type SaveProfileState = {
message: string;
};
export async function saveProfile(
_previousState: SaveProfileState,
formData: FormData,
): Promise<SaveProfileState> {
const displayName = String(
formData.get('displayName') ?? '',
).trim();
await new Promise((resolve) =>
setTimeout(resolve, 500),
);
if (!displayName) {
return {
message: '이름을 입력해 주세요.',
};
}
return {
message: '프로필을 저장했습니다.',
};
}
src/actions/updateTask.ts 전체 파일
export async function updateTaskOnServer(
taskId: string,
): Promise<void> {
await new Promise((resolve) =>
setTimeout(resolve, 600),
);
if (!taskId || taskId === 'fail') {
throw new Error('작업 ID가 없습니다.');
}
}
src/components/AppLayout.tsx 전체 파일
import {
NavLink,
Outlet,
} from 'react-router';
import Header from './Header';
export default function AppLayout() {
return (
<main className="mx-auto w-full max-w-3xl px-4 py-8 sm:px-6 sm:py-10 lg:py-12 text-gray-950 dark:text-gray-100">
<Header />
<nav aria-label="주요 메뉴">
<NavLink to="/tasks">
작업
</NavLink>
{' · '}
<NavLink to="/settings">
설정
</NavLink>
</nav>
<Outlet />
</main>
);
}
src/components/AsyncDemo.tsx 전체 파일
import { useState } from 'react';
import OptimisticTaskList from './OptimisticTaskList';
import type { Task } from '../types/task';
export default function AsyncDemo() {
const [tasks, setTasks] = useState<Task[]>([{id:'success',title:'저장 성공 실습',completed:false},{id:'fail',title:'저장 실패·복구 실습',completed:false}]);
return <details><summary>낙관적 UI 실습</summary><p>실제 서버가 아닌 지연 모의 요청입니다. 아래 목록은 기본 작업 목록과 분리됩니다.</p>
<OptimisticTaskList tasks={tasks} onCommitToggle={id => setTasks(previous => previous.map(task => task.id === id ? {...task,completed:!task.completed} : task))} /></details>;
}
src/components/Header.tsx 전체 파일
export default function Header() {
return (
<header>
<p className="mb-2 text-sm font-medium text-task-muted dark:text-gray-400">
React Learning Project
</p>
<h1 className="text-3xl font-bold text-task-text dark:text-gray-100">
TaskFlow
</h1>
<p className="mt-3 text-base text-gray-600 dark:text-gray-400">
오늘 할 일을 하나씩 정리해 보세요.
</p>
</header>
);
}
src/components/NetworkStatus.tsx 전체 파일
import { useState } from 'react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
export default function NetworkStatus() {
const [showDebugMessage, setShowDebugMessage] = useState(false);
const isOnline = useNetworkStatus(showDebugMessage);
return <section aria-label="네트워크 상태"><p>네트워크: {isOnline ? '온라인' : '오프라인'}</p>
<label><input type="checkbox" checked={showDebugMessage} onChange={e => setShowDebugMessage(e.target.checked)} />네트워크 로그 표시</label></section>;
}
src/components/OptimisticTaskList.tsx 전체 파일
import {
useTransition,
useOptimistic,
useState,
} from 'react';
import { updateTaskOnServer } from '../actions/updateTask';
import type { Task } from '../types/task';
type OptimisticTaskListProps = {
tasks: Task[];
onCommitToggle: (taskId: string) => void;
};
export default function OptimisticTaskList({
tasks,
onCommitToggle,
}: OptimisticTaskListProps) {
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] =
useState('');
const [optimisticTasks, toggleOptimistic] =
useOptimistic(
tasks,
(currentTasks, taskId: string) =>
currentTasks.map((task) =>
task.id === taskId
? {
...task,
completed: !task.completed,
}
: task,
),
);
const handleToggle = (taskId: string) => {
setErrorMessage('');
startTransition(async () => {
toggleOptimistic(taskId);
try {
await updateTaskOnServer(taskId);
startTransition(() => onCommitToggle(taskId));
} catch {
setErrorMessage(
'저장에 실패했습니다. 기존 상태로 돌아갑니다.',
);
}
});
};
return (
<section>
{errorMessage ? (
<p role="alert">{errorMessage}</p>
) : null}
<ul>
{optimisticTasks.map((task) => (
<li key={task.id}>
<button
type="button"
disabled={isPending}
onClick={() => handleToggle(task.id)}
>
{task.completed ? '완료 취소' : '완료'}
</button>
<span>{task.title}</span>
</li>
))}
</ul>
</section>
);
}
src/components/ProfileSaveForm.tsx 전체 파일
import { useActionState } from 'react';
import {
saveProfile,
type SaveProfileState,
} from '../actions/saveProfile';
const initialState: SaveProfileState = {
message: '',
};
export default function ProfileSaveForm() {
const [state, formAction, isPending] =
useActionState(saveProfile, initialState);
return (
<form action={formAction}>
<label>
표시 이름
<input name="displayName" />
</label>
<button disabled={isPending}>
{isPending ? '저장 중...' : '저장'}
</button>
<p>{state.message}</p>
</form>
);
}
src/components/StateResetDemo.tsx 전체 파일
import { useState } from 'react';
import TaskEditor from './TaskEditor';
import type { Task } from '../types/task';
const examples: Task[] = [
{ id: 'example-1', title: '첫 번째 작업', completed: false },
{ id: 'example-2', title: '두 번째 작업', completed: false },
];
export default function StateResetDemo() {
const [selectedId, setSelectedId] = useState(examples[0].id);
const selectedTask = examples.find(task => task.id === selectedId)!;
return <details><summary>state 초기화 실습</summary>
<p>입력값은 저장되지 않습니다. 작업을 바꾸면 key가 달라져 입력값이 초기화됩니다.</p>
{examples.map(task => <button type="button" key={task.id} onClick={() => setSelectedId(task.id)}>{task.title} 선택</button>)}
<TaskEditor key={selectedTask.id} task={selectedTask} />
</details>;
}
src/components/StatsSkeleton.tsx 전체 파일
export default function StatsSkeleton() {
return (
<section aria-busy="true">
<h2>작업 통계</h2>
<p>통계를 불러오는 중...</p>
</section>
);
}
src/components/TaskCard.tsx 전체 파일
import type { ReactNode } from 'react';
type TaskCardProps = {
children: ReactNode;
actions?: ReactNode;
className?: string;
};
export default function TaskCard({
children,
actions,
className,
}: TaskCardProps) {
return (
<li className={['@container border p-4', className ?? 'border-task-border bg-task-surface text-task-text dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100'].join(' ')}>
<div className="flex flex-col gap-3 @md:flex-row @md:items-center">
{children}
</div>
{actions ? (
<div className="task-card__actions">
{actions}
</div>
) : null}
</li>
);
}
src/components/TaskEditor.tsx 전체 파일
import { useState } from 'react';
import type { Task } from '../types/task';
type TaskEditorProps = {
task: Task;
};
export default function TaskEditor({
task,
}: TaskEditorProps) {
const [title, setTitle] = useState(task.title);
return (
<section>
<h2>작업 편집</h2>
<label htmlFor="editor-title">편집 입력 연습</label>
<input id="editor-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
<p>선택한 작업 ID: {task.id}</p>
</section>
);
}
src/components/TaskFilter.tsx 전체 파일
import type { Dispatch, SetStateAction } from 'react';
import type { TaskStatusFilter } from '../types/task';
type TaskFilterProps = {
keyword: string;
onKeywordChange: Dispatch<SetStateAction<string>>;
status: TaskStatusFilter;
onStatusChange: Dispatch<SetStateAction<TaskStatusFilter>>;
onReset: () => void;
};
const options = [
{ value: 'all', label: '전체' },
{ value: 'active', label: '진행 중' },
{ value: 'completed', label: '완료' },
] as const;
const baseButton = 'transition-colors duration-150 motion-reduce:transition-none min-h-11 border px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black dark:focus-visible:outline-white';
const stateClasses = {
selected: 'border-black bg-black text-white dark:border-white dark:bg-white dark:text-black',
idle: 'border-gray-400 bg-white text-gray-900 dark:border-gray-600 dark:bg-gray-950 dark:text-gray-100',
} as const;
export default function TaskFilter({ keyword, onKeywordChange, status, onStatusChange, onReset }: TaskFilterProps) {
return (
<section aria-label="작업 필터" className="grid gap-3">
<label htmlFor="task-keyword">작업 검색</label>
<input id="task-keyword" value={keyword} onChange={event => onKeywordChange(event.target.value)} className="min-w-0 border border-gray-300 bg-white px-3 py-2 text-gray-950" />
<div className="flex flex-wrap gap-2" role="group" aria-label="작업 상태">
{options.map(option => {
const selected = status === option.value;
return (
<button key={option.value} type="button" aria-pressed={selected} onClick={() => onStatusChange(option.value)} className={[baseButton, selected ? stateClasses.selected : stateClasses.idle].join(' ')}>
{option.label}
</button>
);
})}
</div>
<button type="button" onClick={onReset} className="justify-self-start border border-gray-400 px-3 py-2">필터 초기화</button>
</section>
);
}
src/components/TaskForm.tsx 전체 파일
import { useForm } from 'react-hook-form';
import { useTaskContext } from '../context/TaskContext';
type TaskFormValues = {
title: string;
};
export default function TaskForm() {
const { dispatch } = useTaskContext();
const {
register,
handleSubmit,
setValue,
setFocus,
formState: { errors, isSubmitting: isPending },
} = useForm<TaskFormValues>({
defaultValues: {
title: '',
},
});
const onSubmit = (values: TaskFormValues) => {
const title = values.title.trim();
dispatch({
type: 'task/added',
task: {
id: crypto.randomUUID(),
title,
completed: false,
},
});
setValue('title', '');
setFocus('title');
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="flex flex-col gap-2 sm:flex-row">
<label htmlFor="task-title">
새 작업
</label>
<input
className="peer min-h-11 min-w-0 grow border border-gray-300 bg-white px-3 py-2 text-gray-950"
id="task-title"
required
minLength={2}
aria-invalid={Boolean(errors.title)}
aria-describedby={
errors.title ? 'task-title-error' : undefined
}
{...register('title', {
setValueAs: (value: string) => value.trim(),
required: '작업 제목을 입력해 주세요.',
minLength: {
value: 2,
message: '작업 제목은 2자 이상 입력해 주세요.',
},
validate: (value) =>
value.trim().length > 0 ||
'공백만 입력할 수 없습니다.',
})}
/>
<span className="hidden text-sm peer-invalid:block">작업 제목을 입력하세요.</span>
{errors.title && (
<p id="task-title-error" role="alert">
{errors.title.message}
</p>
)}
<button type="submit" disabled={isPending} className="min-h-11 shrink-0 border border-black bg-black px-4 py-2 text-white disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-500 focus-visible:outline-2 focus-visible:outline-offset-2">
{isPending ? '저장 중...' : '추가'}
</button>
</form>
);
}
src/components/TaskItem.tsx 전체 파일
import { Link } from 'react-router';
import TaskCard from './TaskCard';
import { useTaskContext } from '../context/TaskContext';
import type { Task } from '../types/task';
const taskStateClasses = {
active: 'border-task-border bg-task-surface text-task-text dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100',
completed: 'border-gray-400 bg-gray-100 text-gray-600 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-400',
} as const;
type TaskItemProps = {
task: Task;
};
export default function TaskItem({
task,
}: TaskItemProps) {
const { dispatch } = useTaskContext();
const titleClassName = task.completed
? 'min-w-0 grow wrap-anywhere text-gray-500 line-through dark:text-gray-400'
: 'min-w-0 grow wrap-anywhere text-task-text dark:text-gray-100';
return (
<TaskCard className={taskStateClasses[task.completed ? 'completed' : 'active']}>
<button
className="min-h-11 shrink-0 border border-gray-400 px-3 py-2 aria-pressed:border-black aria-pressed:bg-black aria-pressed:text-white focus-visible:outline-2 focus-visible:outline-offset-2"
type="button"
aria-pressed={task.completed}
onClick={() =>
dispatch({
type: 'task/toggled',
id: task.id,
})
}
>
{task.completed ? '완료 취소' : '완료'}
</button>
<span className={titleClassName}>{task.title}</span>
<button
className="min-h-11 shrink-0 border border-gray-400 px-3 py-2 aria-pressed:border-black aria-pressed:bg-black aria-pressed:text-white focus-visible:outline-2 focus-visible:outline-offset-2"
type="button"
onClick={() =>
dispatch({
type: 'task/deleted',
id: task.id,
})
}
>
삭제
</button>
<Link to={`/tasks/${task.id}`}>상세</Link>
</TaskCard>
);
}
src/components/TaskList.tsx 전체 파일
import { useTaskContext } from '../context/TaskContext';
import type { Task } from '../types/task';
import TaskItem from './TaskItem';
type TaskListProps = {
tasks: Task[];
};
export default function TaskList({
tasks,
}: TaskListProps) {
const { tasks: allTasks } = useTaskContext();
if (allTasks.length === 0) {
return <p>등록된 작업이 없습니다.</p>;
}
if (tasks.length === 0) {
return <p>조건에 맞는 작업이 없습니다.</p>;
}
return (
<ul className="flex flex-col gap-3">
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
);
}
src/components/TaskPreferences.tsx 전체 파일
export default function TaskPreferences() {
return (
<label className="flex min-h-11 items-center gap-3 border border-gray-300 bg-white p-3 has-checked:border-black has-checked:bg-gray-100">
<input
type="checkbox"
className="size-4"
/>
완료 작업 강조
</label>
);
}
src/components/TaskStats.tsx 전체 파일
import type { Task } from '../types/task';
type TaskStatsProps = {
tasks: Task[];
};
export default function TaskStats({
tasks,
}: TaskStatsProps) {
const completedCount = tasks.filter(
(task) => task.completed,
).length;
return (
<section>
<h2>작업 통계</h2>
<p>전체: {tasks.length}</p>
<p>완료: {completedCount}</p>
<p>진행 중: {tasks.length - completedCount}</p>
</section>
);
}
src/components/TaskSummary.tsx 전체 파일
type TaskSummaryProps = {
total: number;
completed: number;
remaining: number;
};
export default function TaskSummary({
total,
completed,
remaining,
}: TaskSummaryProps) {
const items = [
['전체', total],
['완료', completed],
['남음', remaining],
] as const;
return (
<section
aria-label="작업 요약"
className="grid grid-cols-1 gap-3 sm:grid-cols-3"
>
{items.map(([label, value]) => (
<div
key={label}
className="border border-task-border bg-task-surface p-4 text-task-text dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100"
>
<p className="text-sm text-task-muted dark:text-gray-400">
{label}
</p>
<strong className="mt-2 block text-2xl font-bold text-task-text dark:text-gray-100">
{value}
</strong>
</div>
))}
</section>
);
}
src/components/ThemeSettings.tsx 전체 파일
type Theme = 'light' | 'dark';
type ThemeSettingsProps = {
theme: Theme;
onChange: (theme: Theme) => void;
};
export default function ThemeSettings({
theme,
onChange,
}: ThemeSettingsProps) {
return (
<fieldset className="grid gap-2">
<legend className="text-sm font-medium">
화면 테마
</legend>
<div className="flex gap-2">
<button
type="button"
aria-pressed={theme === 'light'}
onClick={() => onChange('light')}
className="min-h-11 border border-gray-400 px-3 py-2 aria-pressed:border-black aria-pressed:bg-black aria-pressed:text-white dark:border-gray-600 dark:aria-pressed:border-white dark:aria-pressed:bg-white dark:aria-pressed:text-black"
>
라이트
</button>
<button
type="button"
aria-pressed={theme === 'dark'}
onClick={() => onChange('dark')}
className="min-h-11 border border-gray-400 px-3 py-2 aria-pressed:border-black aria-pressed:bg-black aria-pressed:text-white dark:border-gray-600 dark:aria-pressed:border-white dark:aria-pressed:bg-white dark:aria-pressed:text-black"
>
다크
</button>
</div>
</fieldset>
);
}
src/context/TaskContext.tsx 전체 파일
import {
createContext,
useContext,
useReducer,
type Dispatch,
type ReactNode,
} from 'react';
import { taskReducer } from '../reducers/taskReducer';
import type {
Task,
TaskAction,
} from '../types/task';
type TaskContextValue = {
tasks: Task[];
dispatch: Dispatch<TaskAction>;
};
const TaskContext = createContext<
TaskContextValue | undefined
>(undefined);
const initialTasks: Task[] = [
{
id: 'task-1',
title: 'Reducer와 Context 연결하기',
completed: false,
},
];
export function TaskProvider({
children,
}: {
children: ReactNode;
}) {
const [tasks, dispatch] = useReducer(
taskReducer,
initialTasks,
);
return (
<TaskContext.Provider
value={{ tasks, dispatch }}
>
{children}
</TaskContext.Provider>
);
}
export function useTaskContext() {
const value = useContext(TaskContext);
if (!value) {
throw new Error(
'useTaskContext는 TaskProvider 안에서 사용해야 합니다.',
);
}
return value;
}
src/hooks/useNetworkStatus.ts 전체 파일
import {
useEffect,
useEffectEvent,
useState,
} from 'react';
export function useNetworkStatus(
showDebugMessage: boolean,
) {
const [isOnline, setIsOnline] = useState(
navigator.onLine,
);
const onOnline = useEffectEvent(() => {
if (showDebugMessage) {
console.log('TaskFlow가 온라인 상태입니다.');
}
setIsOnline(true);
});
const onOffline = useEffectEvent(() => {
if (showDebugMessage) {
console.log('TaskFlow가 오프라인 상태입니다.');
}
setIsOnline(false);
});
useEffect(() => {
const handleOnline = () => onOnline();
const handleOffline = () => onOffline();
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener(
'online',
handleOnline,
);
window.removeEventListener(
'offline',
handleOffline,
);
};
}, []);
return isOnline;
}
src/hooks/useTaskFilter.ts 전체 파일
import {
useDeferredValue,
useState,
useTransition,
type Dispatch,
type SetStateAction,
} from 'react';
import type {
Task,
TaskStatusFilter,
} from '../types/task';
export function useTaskFilter(tasks: Task[]) {
const [keyword, setKeyword] = useState('');
const [status, setStatusState] =
useState<TaskStatusFilter>('all');
const [isPending, startTransition] =
useTransition();
const deferredKeyword =
useDeferredValue(keyword);
const normalizedKeyword = deferredKeyword
.trim()
.toLowerCase();
const visibleTasks = tasks.filter((task) => {
const matchesKeyword = task.title
.toLowerCase()
.includes(normalizedKeyword);
const matchesStatus =
status === 'all' ||
(status === 'active' && !task.completed) ||
(status === 'completed' && task.completed);
return matchesKeyword && matchesStatus;
});
const setStatus: Dispatch<
SetStateAction<TaskStatusFilter>
> = (nextStatus) => {
startTransition(() => {
setStatusState(nextStatus);
});
};
const resetFilter = () => {
setKeyword('');
startTransition(() => {
setStatusState('all');
});
};
return {
keyword,
setKeyword,
status,
setStatus,
visibleTasks,
resetFilter,
isStale: keyword !== deferredKeyword,
isPending,
};
}
src/hooks/useTaskStats.ts 전체 파일
import { useTaskContext } from '../context/TaskContext';
export function useTaskStats() {
const { tasks } = useTaskContext();
const totalCount = tasks.length;
const completedCount = tasks.filter(
(task) => task.completed,
).length;
const remainingCount =
totalCount - completedCount;
return {
totalCount,
completedCount,
remainingCount,
};
}
src/index.css 전체 파일
@import "tailwindcss";
@layer base {
body { font-family: Arial, sans-serif; color: #111; background: #f5f5f5; }
h2 { font-size: 24px; font-weight: 700; }
h3 { font-size: 20px; font-weight: 500; }
button, input, select { min-height: 44px; border: 1px solid #aaa; padding: 8px 12px; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
input:not([type="checkbox"]), select { max-width: 100%; }
form, section { margin-block: 20px; }
details { margin-block: 24px; }
summary { cursor: pointer; min-height: 44px; }
:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
}
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@layer base { html[data-theme=dark] body { background: #111827; color: #f3f4f6; } }
@theme {
--color-task-surface: #ffffff;
--color-task-border: #d1d5db;
--color-task-text: #111827;
--color-task-muted: #6b7280;
}
@utility content-auto { content-visibility: auto; }
src/main.tsx 전체 파일
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import './index.css';
import App from './App';
import { TaskProvider } from './context/TaskContext';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<TaskProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</TaskProvider>
</StrictMode>,
);
src/pages/SettingsPage.tsx 전체 파일
import { useEffect, useState } from 'react';
import ThemeSettings from '../components/ThemeSettings';
import TaskPreferences from '../components/TaskPreferences';
import AsyncDemo from '../components/AsyncDemo';
import ProfileSaveForm from '../components/ProfileSaveForm';
export default function SettingsPage() {
const [theme, setTheme] = useState<'light' | 'dark'>(() => document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light');
useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]);
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-[12rem_minmax(0,1fr)]">
<aside className="border border-gray-300 bg-white p-4 dark:border-gray-700 dark:bg-gray-950">설정 메뉴</aside>
<section className="min-w-0">
<h2>설정</h2>
<p>TaskFlow 설정 화면입니다.</p>
<ThemeSettings theme={theme} onChange={setTheme} />
<TaskPreferences />
<ProfileSaveForm /><AsyncDemo />
</section>
</div>
);
}
src/pages/TaskDetailPage.tsx 전체 파일
import TaskItem from '../components/TaskItem';
import {
Link,
useParams,
} from 'react-router';
import { useTaskContext } from '../context/TaskContext';
export default function TaskDetailPage() {
const { taskId } = useParams();
const { tasks } = useTaskContext();
const task = tasks.find(
(item) => item.id === taskId,
);
if (!task) {
return (
<section>
<h2>작업을 찾을 수 없습니다.</h2>
<Link to="/tasks">
목록으로 돌아가기
</Link>
</section>
);
}
return (
<section>
<h2 className="wrap-anywhere">{task.title}</h2>
<aside className="w-64 max-w-full" aria-label="좁은 작업 카드"><ul><TaskItem task={task} /></ul></aside>
<p>
상태: {task.completed ? '완료' : '진행 중'}
</p>
<Link to="/tasks">
목록으로 돌아가기
</Link>
</section>
);
}
src/pages/TaskListPage.tsx 전체 파일
import { useEffect, lazy, Suspense } from 'react';
import TaskForm from '../components/TaskForm';
import TaskList from '../components/TaskList';
import TaskFilter from '../components/TaskFilter';
import StateResetDemo from '../components/StateResetDemo';
import NetworkStatus from '../components/NetworkStatus';
import TaskSummary from '../components/TaskSummary';
import { useTaskContext } from '../context/TaskContext';
import { useTaskStats } from '../hooks/useTaskStats';
import { useTaskFilter } from '../hooks/useTaskFilter';
import StatsSkeleton from '../components/StatsSkeleton';
const TaskStats = lazy(() => import('../components/TaskStats'));
export default function TaskListPage() {
const { tasks } = useTaskContext();
const { totalCount, completedCount, remainingCount } = useTaskStats();
const { keyword, setKeyword, status, setStatus, visibleTasks, resetFilter, isStale, isPending } = useTaskFilter(tasks);
useEffect(() => { document.title = `TaskFlow (${remainingCount})`; }, [remainingCount]);
return <section className="mt-8">
<h2>작업</h2>
<NetworkStatus />
<TaskSummary total={totalCount} completed={completedCount} remaining={remainingCount} />
<TaskForm />
<TaskFilter keyword={keyword} onKeywordChange={setKeyword} status={status} onStatusChange={setStatus} onReset={resetFilter} />
<p role="status">{isStale ? "검색 결과 갱신 중..." : isPending ? "필터 갱신 중..." : ""}</p>
<section className="content-auto" aria-label="작업 목록"><TaskList tasks={visibleTasks} /></section>
<Suspense fallback={<StatsSkeleton />}><TaskStats tasks={tasks} /></Suspense>
<StateResetDemo />
</section>;
}
src/reducers/taskReducer.ts 전체 파일
import type {
Task,
TaskAction,
} from '../types/task';
export function taskReducer(
tasks: Task[],
action: TaskAction,
): Task[] {
switch (action.type) {
case 'task/added':
return [...tasks, action.task];
case 'task/toggled':
return tasks.map((task) =>
task.id === action.id
? { ...task, completed: !task.completed }
: task,
);
case 'task/deleted':
return tasks.filter(
(task) => task.id !== action.id,
);
default:
return tasks;
}
}
src/types/task.ts 전체 파일
export type TaskId = string;
export type Task = {
id: TaskId;
title: string;
completed: boolean;
};
export type TaskAction =
| {
type: 'task/added';
task: Task;
}
| {
type: 'task/toggled';
id: TaskId;
}
| {
type: 'task/deleted';
id: TaskId;
};
export type TaskStatusFilter = 'all' | 'active' | 'completed';
tsconfig.json 전체 파일
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"types": [
"vite/client"
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src",
"vite.config.ts"
]
}
vite.config.ts 전체 파일
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
});
위 파일과 ZIP의 동명 파일은 동일합니다. package-lock.json과 회차별 README·CHANGES 문서는 ZIP에 포함합니다. 생성 산출물·node_modules·실제 환경 변수 파일은 포함하지 않습니다.
시작본과 이번 변경
Tailwind CSS v4.1~v4.3 신규 유틸리티 정리의 하단 완료 상태를 기준으로 이어갑니다. 기존 설명은 이전 글의 변경 내용을 연결하는 안내입니다. 위 시작 ZIP에 해당 상태의 전체 파일을 제공하므로 이제 별도로 조립하지 않아도 됩니다.
아래 변경 파일과 각 코드의 적용 위치를 따라 이번 속성만 바꾼 뒤, 기존 추가·완료·삭제 기능을 확인하고 이 글의 실행 확인 항목으로 화면을 비교합니다. 부분 코드의 생략 영역에는 이전 단계의 props·상태·handler를 유지합니다.
이전 체크포인트
tailwind-step-19-v43-utilities
현재 상태
TaskFlow는 native input, button과 Tailwind utility만으로 핵심 기능이 동작합니다.
이번 목표
새 라이브러리를 설치하지 않고 앞으로 필요한 UI를 기준으로 선택표를 작성합니다.
TaskFlow에서 Tailwind만으로 충분한 UI
다음 UI는 native HTML + Tailwind로 충분한 경우가 많습니다.
- Button
- Input
- Checkbox
- simple card
- simple form layout
- static alert text
native element의 접근성을 살리면서 스타일만 추가하면 됩니다.
TaskFlow UI 분류
TaskForm input
→ native input + Tailwind 유지
필터 button
→ native button + Tailwind 유지
TaskCard
→ React component + Tailwind 유지
Theme 선택
→ native control + Tailwind 유지
작업 상세 modal이 필요해질 경우
→ Dialog primitive 검토
복잡한 메뉴가 필요할 경우
→ Dropdown Menu primitive 검토
현재 결론
이번 Tailwind 과정에서는 shadcn/ui를 본격 설치하지 않습니다.
React 과정 29회차에서 다음을 별도로 학습합니다.
shadcn/ui 초기화
components.json
Button / Input
Sonner
Tailwind 과정에서는 도입 기준까지만 정리합니다.
Before / After
Before
Tailwind 이후 어떤 UI library를 써야 하는지 모호함
After
단순 native UI
→ Tailwind 유지
복잡한 accessible behavior
→ Radix/shadcn 계열 검토
완료 체크포인트
tailwind-step-20-ui-library-choice
직접 만드는 화면과 적용 전후 점검
독자 질문: Radix UI와 shadcn ui 차이 컴포넌트를 고를 때 헷갈리는 기준 정리을 읽고 무엇을 직접 확인해야 할까요?
만들 화면: TaskFlow 입력창과 복합 Dialog의 선택 기준을 표로 정리합니다.
- 기본 작업 입력은 native input/button으로 두고, 포커스 이동과 닫힘 처리가 필요한 Dialog 요구만 별도 적습니다.
- 이 회차는 라이브러리 설치와 화면 변경이 없으므로 /tasks의 전후 화면이 같아야 합니다.
- 접근성 동작이 필요한지와 컴포넌트 코드 소유 범위를 먼저 확인합니다.
다음 학습: shadcn/ui No import alias found 오류 해결: tsconfig 설정 — https://blogflow.kr/shadcn-ui-components-json-import-alias-error/
검증 기록: 이 편집 작업에서 화면 변경·개발 서버·빌드·브라우저 조작은 실행하지 않았습니다. 위 내용은 독자 실습의 예상 확인 기준이며 실제 결과는 미확인입니다.
다음 학습
다음 글은 선택 오류 해결 글입니다. Vite 프로젝트에서 shadcn/ui CLI가 import alias를 찾지 못하는 상황을 tsconfig와 Vite resolve 기준으로 해결합니다.
다음 글: shadcn/ui No import alias found 오류 해결: tsconfig 설정
공식 문서
- Radix Primitives: https://www.radix-ui.com/primitives
- shadcn/ui: https://ui.shadcn.com/
글 소개·학습 준비·전체 목차

Tailwind CSS · 20 / 23 · 선택 심화 · UI 도구 선택
Tailwind CSS, Radix UI, shadcn/ui는 서로 완전히 대체 관계가 아닙니다.
Tailwind는 스타일링 도구이고, Radix Primitives는 접근성과 상호작용이 필요한 headless UI primitive를 제공하며, shadcn/ui는 이런 primitive와 Tailwind 등을 조합한 컴포넌트 코드를 프로젝트가 직접 소유하는 방식입니다.
이번 글에서는 세 도구의 책임을 구분하고 TaskFlow에서 어느 시점에 추가 도구가 필요한지 판단합니다.
이번 글에서 배우는 내용
- Tailwind CSS의 역할
- Radix Primitives의 역할
- shadcn/ui의 코드 소유 방식
- headless UI 의미
- 접근성 구현 비용
- 설치 의존성과 코드 소유권
- TaskFlow 요구사항별 선택 기준
단독 학습 선수 지식
HTML 요소·속성, CSS의 선택자·속성·box model을 먼저 익힙니다. React 예제는 함수 컴포넌트와 JSX를 사용합니다. 상단 실습은 새 Vite 앱에서 실행하고, TaskFlow 완료본은 하단에서만 사용합니다.
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 23개 · 필수 16개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.