Tailwind CSS cho dự án thực tế: cấu trúc component, design token và cách tránh class name rối

3 tháng trước · 8 phút đọc
Vấn đề bắt đầu khi dự án lớn hơn một trang landing
Bạn bắt đầu với Tailwind CSS - mọi thứ nhanh, gọn, không cần đặt tên class. Nhưng sau vài tuần, file ProductCard.jsx trông như thế này:
Mỗi element gần 10 class. Component lồng nhau 3-4 tầng. Khi cần đổi màu nút toàn site, bạn phải Ctrl+F và hy vọng không sót chỗ nào.
Class name chồng chất - dấu hiệu dự án cần refactor cách tổ chức Tailwind
Đây không phải lỗi của Tailwind. Đây là dấu hiệu dự án cần một workflow rõ ràng hơn. Bài này mình chia sẻ cách mình tổ chức lại sau khi vật lộn với codebase ~40 component.
Design token: bước đầu tiên để không bị loạn màu
Mọi dự án thực tế đều có bộ màu riêng. Vấn đề là nếu bạn dùng thẳng text-blue-600, bg-gray-100 rải khắp nơi - khi khách hàng muốn đổi màu chính, bạn tốn cả buổi để tìm và sửa.
Tailwind giải quyết điều này bằng cách mở rộng config. Mình thường làm như sau trong tailwind.config.js:
Sau đó dùng trong component:
Token có tên rõ nghĩa - đổi một chỗ trong config, toàn bộ site cập nhật
Khi cần refactor màu, bạn chỉ sửa tailwind.config.js. Không cần Ctrl+F khắp project nữa. Nếu bạn đang học nền tảng HTML/CSS trước khi chuyển sang Tailwind, khóa HTML CSS từ Zero đến Hero của F8 sẽ giúp bạn hiểu rõ box model và cascade trước - điều này rất quan trọng khi bạn bắt đầu custom config như thế này.
Tách component đúng cách với @apply và cva
Mình nhớ lần đầu biết @apply - cảm giác như tìm được shortcut. Nhưng dùng sai thì lại tạo ra vấn đề mới.
Dùng @apply khi nào?
Chỉ dùng cho các element lặp lại nhiều lần mà không thể tách thành React/Vue component. Ví dụ điển hình: typography, form input cơ bản.
Nhưng với component phức tạp có nhiều biến thể (primary/secondary/danger button), @apply sẽ đẻ ra đống class CSS khó quản lý. Lúc này mình dùng cva (class-variance-authority):
cva giúp quản lý biến thể button sạch hơn nhiều so với điều kiện className thủ công
Tại sao không dùng điều kiện className thủ công? Vì khi button có 3 intent × 3 size = 9 tổ hợp, bạn sẽ viết template string dài vài chục ký tự. Chưa kể cn() helper để merge class còn cần thêm.
Chuẩn hóa spacing để layout nhất quán
Một trong những điều làm mình mất nhiều giờ nhất khi review code người khác: spacing không nhất quán.
Cùng một loại card, nơi thì p-4, nơi thì p-5, nơi thì p-[18px]. Nhìn thì không sai, nhưng khi đặt cạnh nhau trên cùng một trang thì lệch nhẹ.
Nguyên tắc spacing mình đang dùng:
4px(space-1): khoảng cách inline nhỏ - icon với text, tag với tag8px(space-2): gap trong flex nhỏ, margin giữa label và input16px(space-4): padding card nhỏ, khoảng cách giữa các phần tử trong form24px(space-6): padding card thường, gap giữa các card trong grid32px(space-8): section padding nội bộ64px(space-16): khoảng cách giữa các section lớn
Những giá trị này mình đưa vào tailwind.config.js với tên gợi nhớ (như spacing.card, spacing.section đã nói ở trên). Và cấm dùng arbitrary values như p-[18px] hay mt-[22px] - trừ khi có lý do thật sự rõ ràng.
Spacing scale nhất quán - lệch 4px trong thiết kế nhìn nhỏ nhưng cộng dồn sẽ rất khó chịu
Trong dự án nhóm, mình thường viết comment trong config để mọi người biết dùng cái nào cho use case nào. Tránh việc mỗi người diễn giải khác nhau.
Giữ file JSX sạch với utility function cn()
Sau khi có token và component tốt, bước cuối là giữ cho file JSX không bị nhiễu bởi logic merge class.
Mình dùng cn() - kết hợp clsx và tailwind-merge. Cài một lần, dùng mãi:
Tại sao cần cả hai? clsx xử lý điều kiện (object/array syntax). tailwind-merge giải quyết conflict khi merge - ví dụ p-4 và p-6 cùng xuất hiện thì chỉ giữ lại cái sau, không phải cộng dồn.
cn() giải quyết class conflict tự động - không cần lo p-4 với p-6 đánh nhau nữa
Một trick thêm: dùng Prettier plugin Tailwind để tự động sắp xếp class theo thứ tự nhất quán. Bạn không cần nghĩ thứ tự nữa - commit nào cũng ra output giống nhau, diff clean hơn hẳn.
Cấu trúc thư mục khi project lớn hơn 10 component
Cuối cùng là chuyện tổ chức file. Không có cách nào đúng tuyệt đối, nhưng đây là cấu trúc mình thấy hoạt động tốt cho project vừa:
src/
├── styles/
│ ├── globals.css # Import Tailwind, @layer base
│ └── components.css # Chỉ @apply cho non-JS elements (prose, table...)
├── lib/
│ └── utils.js # cn() helper
├── components/
│ ├── ui/ # Atomic: Button, Input, Badge, Card
│ │ ├── Button.jsx
│ │ └── Input.jsx
│ └── features/ # Composed: ProductCard, UserProfile
│ └── ProductCard.jsx
└── tailwind.config.js # Token tập trung ở đây
Nguyên tắc:
ui/: component không có business logic, chỉ nhận props để renderfeatures/: dùngui/components, có thể có logic riêngcomponents.css: càng ít@applycàng tốt - chỉ dùng khi không thể tách thành React component (ví dụ: markdown-rendered content)
Tách ui/ và features/ giúp tái sử dụng dễ hơn - Button không biết gì về ProductCard
Mình từng nhét tất cả vào một folder components/ phẳng. Đến khi có 30 file thì tìm kiếm rất mệt. Phân cấp đơn giản này giải quyết được 80% vấn đề.
Nếu bạn muốn học Tailwind CSS từ đầu thay vì chỉ đọc về workflow, khóa Tailwind CSS trên F8 cover từ utility cơ bản cho đến cách build giao diện thực tế - phù hợp để xây nền trước khi áp dụng những pattern trong bài này.
Tóm lại, Tailwind CSS không tự làm code xấu hay đẹp. Workflow mới là thứ quyết định:
- Design token trong config - đổi màu/spacing từ một chỗ
- cva cho component có biến thể - thay vì điều kiện className thủ công
- cn() helper - merge class an toàn, không conflict
- Phân tầng ui/ và features/ - tái sử dụng dễ hơn
- Prettier plugin - class order nhất quán, không cần nghĩ
Thử áp dụng từng điểm một - đừng refactor cả project một lúc. Bắt đầu với design token là dễ nhất và impact ngay lập tức.
Bạn đang dùng cách nào để giữ Tailwind project sạch? Drop comment cho mình biết.
