Tailwind CSS Flex 행에서 긴 텍스트가 버튼을 밀어내면 어떻게 해결하나요?
긴 글이 Flex 행의 버튼을 밀면 글 영역에 min-w-0와 줄바꿈을 적용합니다. 크기를 유지할 버튼은 shrink-0로 구분하고 부모 폭과 grow·shrink·flex-wrap도 확인합니다.
적용 조건·범위: Task 제목 전체를 읽어야 하는 화면에서는 무조건 truncate하지 않습니다.
본문 근거: tailwindcss.com/docs/flex · tailwindcss.com/docs/min-width · tailwindcss.com/docs/overflow-wrap
대표 증상
TaskItem이 다음 구조라고 가정합니다.
<li className="flex items-center gap-3">
<button>완료</button>
<span>{task.title}</span>
<button>삭제</button>
</li>
짧은 제목에서는 문제없이 보입니다.
하지만 제목에 공백 없는 긴 문자열이 들어오면 카드 폭을 밀어낼 수 있습니다.
https://example.com/really-really-really-long-task-path-without-natural-break-points
왜 min-w-0가 필요한가
Flex item은 내용 크기 때문에 기대보다 작아지지 않을 수 있습니다.
남은 공간을 사용하는 텍스트 영역에 min-w-0를 주면 Flexbox가 해당 item을 컨테이너 안에서 줄일 수 있게 됩니다.
<span className="min-w-0 grow">
{task.title}
</span>
TaskFlow에서 이미 grow를 사용하고 있어도 min-w-0가 없으면 긴 콘텐츠가 overflow를 일으킬 수 있습니다.
긴 문자열 줄바꿈
일반 문장은 공백에서 줄바꿈됩니다.
공백 없는 URL이나 긴 식별자에는 wrap-anywhere가 유용합니다.
<span className="min-w-0 grow wrap-anywhere">
{task.title}
</span>
이 조합은 “텍스트 영역은 남은 공간을 사용하고, 필요하면 줄어들며, 긴 문자열도 줄바꿈한다”는 의미입니다.
truncate
한 줄만 보여야 하는 UI라면 truncate를 사용할 수 있습니다.
<span className="min-w-0 grow truncate">
{task.title}
</span>
truncate는 보통 다음 CSS 동작을 묶습니다.
overflow hidden
text-overflow ellipsis
white-space nowrap
Task 제목 전체를 읽어야 하는 화면에서는 무조건 truncate하지 않습니다.
정보를 잘라내도 되는 UI인지 먼저 판단합니다.
shrink-0
버튼처럼 내용 크기를 유지해야 하는 요소는 shrink-0가 도움이 될 수 있습니다.
<button
type="button"
className="min-h-11 shrink-0 border border-gray-400 px-3 py-2"
>
삭제
</button>
모든 요소에 shrink-0를 넣으면 줄어들 수 있는 요소가 사라져 오히려 overflow가 심해질 수 있습니다.
flex-wrap
버튼과 제목이 한 줄에 반드시 있을 필요가 없다면 wrap을 허용할 수 있습니다.
<li className="flex flex-wrap items-center gap-3">
...
</li>
하지만 wrap으로 증상을 숨기기 전에 어떤 item이 실제로 폭을 밀어내는지 확인합니다.
부모 width 확인
자식 class만 계속 수정해도 부모가 width constraint를 갖지 않으면 문제가 남을 수 있습니다.
예:
<main className="w-full max-w-3xl">
<TaskList />
</main>
Grid 안의 자식이라면 minmax(0,1fr)와 min-w-0가 필요한지 확인합니다.
<div className="grid grid-cols-[12rem_minmax(0,1fr)]">
<aside>...</aside>
<main className="min-w-0">
...
</main>
</div>
디버깅 순서
1. 어떤 요소가 실제 overflow를 만드는지 DevTools로 확인
2. 부모의 width / max-width 확인
3. flex item의 min-width 확인
4. grow / shrink 역할 확인
5. 긴 텍스트 줄바꿈 정책 확인
6. wrap이 필요한 구조인지 확인
7. overflow-hidden / truncate가 정말 필요한지 판단
잘못된 해결
body에 overflow-x-hidden
body {
overflow-x: hidden;
}
전체 페이지 overflow를 숨기면 실제 문제 요소를 찾지 못한 채 콘텐츠가 잘릴 수 있습니다.
원인을 먼저 수정합니다.
모든 자식에 w-full
Flex row의 모든 item에 w-full을 주면 레이아웃 의도가 달라질 수 있습니다.
모든 버튼에 absolute
정렬 문제를 position으로 우회하지 않습니다.
독립 재현 예제
문제 가능:
export default function BrokenRow() {
return (
<div className="flex w-72 items-center gap-3 border border-gray-300 p-3">
<button className="border px-2 py-1">
A
</button>
<span className="grow">
https://example.com/veryveryveryveryveryverylong
</span>
<button className="border px-2 py-1">
B
</button>
</div>
);
}
수정:
export default function FixedRow() {
return (
<div className="flex w-72 items-center gap-3 border border-gray-300 p-3">
<button className="shrink-0 border px-2 py-1">
A
</button>
<span className="min-w-0 grow wrap-anywhere">
https://example.com/veryveryveryveryveryverylong
</span>
<button className="shrink-0 border px-2 py-1">
B
</button>
</div>
);
}
학습 완료 체크리스트
- flex item의 최소 너비 문제를 설명할 수 있습니다.
- min-w-0가 필요한 상황을 판단할 수 있습니다.
- grow와 shrink-0 역할을 구분할 수 있습니다.
- 긴 문자열에 wrap-anywhere 또는 truncate를 선택할 수 있습니다.
- 전체 overflow를 숨기기 전에 실제 원인을 찾을 수 있습니다.
독립 실습: 프로젝트 없이 결과 확인
긴 문자열이 넘치는 Flex 행과 줄바꿈을 보완한 행을 비교하세요.
예제 크게 보기 (새 탭)직접 실행: 환경 준비와 전체 파일
같은 18rem 폭에서 긴 문자열이 넘치는 행과 줄어드는 행을 비교합니다.
준비와 실행 파일
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"><section className="flex w-72 gap-3 border p-3"><button type="button">A</button><span className="grow">https://example.com/veryveryveryveryveryverylong</span><button type="button">B</button></section><section className="flex w-72 gap-3 border p-3"><button type="button" className="shrink-0">A</button><span className="min-w-0 grow wrap-anywhere">https://example.com/veryveryveryveryveryverylong</span><button type="button" className="shrink-0">B</button></section></main>
);
}
src/index.css 전체 파일
@import "tailwindcss";
src/main.tsx 전체 파일
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(<StrictMode><App /></StrictMode>);
실행과 예상 결과
npm run dev
npm run build
첫 명령이 표시하는 로컬 주소를 브라우저에서 열고, 두 번째 명령은 별도 터미널에서 실행합니다. 첫 행은 문자열 최소 폭 때문에 넘칠 수 있고 둘째 행은 제목이 줄어들며 줄바꿈됩니다. A/B 버튼은 폭 관찰용입니다.
작은 변형으로 확인하기
둘째 행 wrap-anywhere를 truncate로 바꾸면 한 줄 말줄임으로 변합니다. 전체 내용을 읽어야 하는 경우 어느 정책이 맞는지 선택합니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Tailwind CSS로 앱 화면을 다듬습니다.
긴 Task 제목과 좁은 화면을 이용해 Flex 레이아웃을 최종 점검합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 22단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)실행 가능한 시작본과 이번 단계
이번 단계 시작 ZIP을 새 폴더에 압축 해제합니다. 이 ZIP은 직전 단계의 완료 파일과 같으며 앞 글의 코드를 따로 모을 필요가 없습니다. 이번 단계 완료 ZIP은 다른 폴더에 풀어 비교합니다.
기존 min-w-0/grow/wrap-anywhere와 shrink-0 동작 버튼을 확인하고 Settings Grid 자식에도 min-w-0을 적용합니다.
src/components/TaskItem.tsx: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.src/pages/SettingsPage.tsx: 이 파일의 기존 내용을 아래 완료 파일 전체와 대조하여 교체합니다. 다른 파일은 유지합니다.docs/flex-check.md: 새 파일로 저장합니다.
짧은 코드 예제는 변경 위치를 설명합니다. 파일 전체를 작성하거나 교체할 때는 완료 파일 전체를 사용하세요. 생략 표시가 있는 부분 코드를 파일 전체로 저장하지 않습니다.
실행: 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-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 className="shrink-0" 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="min-w-0 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';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
});
위 파일과 ZIP의 동명 파일은 동일합니다. package-lock.json과 회차별 README·CHANGES 문서는 ZIP에 포함합니다. 생성 산출물·node_modules·실제 환경 변수 파일은 포함하지 않습니다.
시작본과 이번 변경
shadcn/ui No import alias found 오류 해결: tsconfig 설정의 하단 완료 상태를 기준으로 이어갑니다. 기존 설명은 이전 글의 변경 내용을 연결하는 안내입니다. 위 시작 ZIP에 해당 상태의 전체 파일을 제공하므로 이제 별도로 조립하지 않아도 됩니다.
아래 변경 파일과 각 코드의 적용 위치를 따라 이번 속성만 바꾼 뒤, 기존 추가·완료·삭제 기능을 확인하고 이 글의 실행 확인 항목으로 화면을 비교합니다. 부분 코드의 생략 영역에는 이전 단계의 props·상태·handler를 유지합니다.
이전 체크포인트
tailwind-step-21-shadcn-alias-fix
현재 상태
TaskFlow 핵심 Tailwind UI는 완성되어 있고 긴 제목에는 wrap-anywhere를 적용했습니다.
이번 목표
긴 Task 제목과 좁은 화면을 이용해 Flex 레이아웃을 최종 점검합니다.
변경 파일
src/components/TaskItem.tsx
src/pages/SettingsPage.tsx
TaskItem 최종 기준
<TaskCard>
<div className="flex flex-col gap-3 @md:flex-row @md:items-center">
<button
type="button"
className="min-h-11 shrink-0 border border-gray-400 px-3 py-2"
>
완료
</button>
<span
className={
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'
}
>
{task.title}
</span>
<button
type="button"
className="min-h-11 shrink-0 border border-gray-400 px-3 py-2"
>
삭제
</button>
</div>
</TaskCard>
Settings content
<div className="grid grid-cols-1 gap-6 md:grid-cols-[12rem_minmax(0,1fr)]">
<aside>...</aside>
<section className="min-w-0">
...
</section>
</div>
테스트 데이터
다음처럼 긴 제목을 추가합니다.
긴일반문장으로작성한작업제목https://example.com/no-natural-break-point/123456789abcdefghijklmnopqrstuvwxyz
실행 확인
- 320~360px 정도의 좁은 viewport에서도 가로 scrollbar가 생기지 않는가
- 버튼이 화면 밖으로 밀리지 않는가
- 제목이 필요한 위치에서 줄바꿈되는가
- settings 본문이 sidebar 때문에 overflow되지 않는가
- body overflow를 강제로 숨기지 않았는가
npm run dev
npm run build
완료 체크포인트
tailwind-step-22-flex-debug
직접 만드는 화면과 적용 전후 점검
독자 질문: Tailwind CSS flex 레이아웃 깨질 때 확인할 것을 읽고 무엇을 직접 확인해야 할까요?
만들 화면: 긴 작업 제목과 여러 동작 버튼이 있는 TaskItem을 만듭니다.
- 본문 flex 자식에 min-w-0, 버튼 그룹에 shrink-0, 부모에 필요 시 flex-wrap을 적용합니다.
- 375px과 900px에서 제목이 줄바꿈되고 버튼이 화면 밖으로 나가지 않는지 비교합니다.
- body overflow-hidden으로 증상을 숨기지 말고 자식의 최소 너비와 고정 폭을 점검합니다.
다음 학습: Tailwind CSS 클래스 적용 안 됨 해결 순서 — https://blogflow.kr/tailwind-css-class-not-applied/
검증 기록: 이 편집 작업에서 화면 변경·개발 서버·빌드·브라우저 조작은 실행하지 않았습니다. 위 내용은 독자 실습의 예상 확인 기준이며 실제 결과는 미확인입니다.
다음 학습
마지막 글에서는 Tailwind class가 아예 적용되지 않을 때 설치부터 source detection, 동적 class, CSS 우선순위까지 전체 과정을 하나의 진단 순서로 정리합니다.
다음 글: Tailwind CSS 클래스 적용 안 됨 해결 순서
공식 문서
- Flex: https://tailwindcss.com/docs/flex
- Min-width: https://tailwindcss.com/docs/min-width
- Overflow wrap: https://tailwindcss.com/docs/overflow-wrap
글 소개·학습 준비·전체 목차

Tailwind CSS · 22 / 23 · 선택 오류 해결 · Flex 레이아웃
긴 텍스트가 flex 영역을 밀어내면 텍스트 영역에 min-w-0와 적절한 줄바꿈을 적용하고, 크기를 유지할 버튼에는 shrink-0를 적용합니다. 이어서 부모의 폭 제한과 각 자식의 grow·shrink 설정을 확인합니다.
이런 문제는 Tailwind 오류라기보다 CSS Flexbox의 최소 크기와 overflow 규칙을 이해해야 해결할 수 있는 경우가 많습니다.
이번 글에서는 TaskFlow에 긴 작업 제목을 넣어 레이아웃을 일부러 깨뜨린 뒤 min-w-0, shrink, grow, flex-wrap, overflow 순서로 진단합니다.
이번 글에서 배우는 내용
- flex item의 기본 최소 너비
- min-w-0
- grow와 shrink
- shrink-0
- flex-wrap
- overflow-hidden
- truncate와 wrap-anywhere
- 부모 width와 자식 width 관계
- Flex 문제를 진단하는 순서
단독 학습 선수 지식
HTML 요소·속성, CSS의 선택자·속성·box model을 먼저 익힙니다. React 예제는 함수 컴포넌트와 JSX를 사용합니다. 상단 실습은 새 Vite 앱에서 실행하고, TaskFlow 완료본은 하단에서만 사용합니다.
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 23개 · 필수 16개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.