Kiến Trúc CSS Với Tailwind Khi Scale Cho Nhiều Team Cùng Code Chung
Bài này có bản tiếng Anh. Read in English →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Tailwind giải quyết rất tốt bài toán viết CSS nhanh cho một người. Đông người cùng đóng góp vào một repo thì lại là chuyện khác. Giao diện bắt đầu trôi. Mỗi người chọn một sắc độ xám hơi khác nhau, một giá trị padding hơi lệch. Vài tháng sau, UI không còn nhất quán, dù đọc riêng từng dòng thì dòng nào cũng “đúng Tailwind”.
Đúng cú pháp, sai hệ thống
Tailwind không ép bạn dùng đúng token nào. text-gray-500, text-slate-500, text-neutral-500 — cả ba đều hợp lệ về cú pháp. Nhưng ba người cùng cần một thứ “text phụ, ít nổi bật” mà mỗi người chọn một giá trị, thì bạn có ba sắc độ xám gần giống nhau rải khắp app. Không ai cố ý làm sai cả.
Spacing cũng vậy. p-4 hay p-5, gap-3 hay gap-4. Lệch một bậc thì chẳng ai thấy gì. Lặp lại ở hàng trăm component thì UI trông không đều tay.
Ba người, ba lựa chọn đều hợp lệ, một kết quả:
flowchart LR A["Dev A cần 'text phụ'"] --> G["text-gray-500"] B["Dev B cần 'text phụ'"] --> S["text-slate-500"] C["Dev C cần 'text phụ'"] --> N["text-neutral-500"] G --> UI["UI không còn nhất quán"] S --> UI N --> UI
Semantic tokens thay vì raw palette
Thay vì để mỗi người tự chọn từ bảng màu đầy đủ của Tailwind, định nghĩa một tập token semantic — theo ý nghĩa chứ không theo tên màu — trong tailwind.config:
// tailwind.config.js
export default {
theme: {
extend: {
colors: {
surface: {
DEFAULT: "hsl(var(--surface))",
subtle: "hsl(var(--surface-subtle))",
},
content: {
DEFAULT: "hsl(var(--content))",
muted: "hsl(var(--content-muted))",
},
border: {
DEFAULT: "hsl(var(--border))",
},
},
},
},
};
Developer chỉ cần nhớ một khái niệm: text-content-muted. Khỏi băn khoăn “text phụ thì neutral-500 hay slate-500”. Một ý nghĩa, một token. Hết chỗ cho trôi dạt.
Cửa ngõ để token vỡ: arbitrary values
Tailwind cho phép viết giá trị tuỳ ý như mt-[13px] hay text-[#3b82f6]. Linh hoạt thật, nhưng đó cũng là cửa ngõ để token hệ thống vỡ. text-[#3a82f6] lệch đúng một ký tự hex so với màu chuẩn. Mắt người review khó bắt lắm. Thế là một màu gần giống nhưng không phải lọt vào codebase.
Convention tụi mình chốt: arbitrary values chỉ dùng cho giá trị không thể biểu diễn bằng token hệ thống, ví dụ một grid-template-columns đặc thù. Màu sắc với spacing thì không bao giờ. Quy ước miệng thì không bền, nên tụi mình để eslint-plugin-tailwindcss chặn một số pattern arbitrary value ngay ở CI, thay vì trông chờ vào mắt người review.
@apply: dùng có kiểm soát
Tranh cãi muôn thuở: utility class trực tiếp trong JSX, hay gom lại bằng @apply trong file CSS riêng? Tụi mình chọn cả hai, mỗi thứ một chỗ.
- Component dùng một lần, logic đơn giản → utility class trực tiếp, không cần trừu tượng hoá thêm.
- Pattern lặp lại ở nhiều nơi (ví dụ style cho mọi
<Card>, mọi input lỗi validation) → gom vào một class ngữ nghĩa bằng@apply, tránh lặp lại hàng chục lần cùng một chuỗi utility dài:
/* components.css */
.card-surface {
@apply rounded-xl border border-border bg-surface p-4 shadow-sm;
}
.input-error {
@apply border-red-500 focus:ring-red-500/30 text-red-600;
}
Nhưng đừng lạm dụng @apply để dựng lại nguyên hệ thống CSS truyền thống, một class cho mỗi component. Làm vậy thì hết lợi ích của utility-first. Chỉ gom những pattern lặp lại thật nhiều và đã ổn định, ít khi phải sửa.
Một nguồn sự thật cho spacing scale
Mỗi PR tự quyết định dùng gap-3 hay gap-4 cho cùng một bối cảnh — khoảng cách giữa các field trong form chẳng hạn. Thế nên tụi mình viết một trang Spacing Guide ngắn trong Storybook: khoảng cách giữa field là gap-4, giữa section là gap-8, padding card là p-6. Không có rule cứng nào ở compiler. Chỉ là chỗ để tra nhanh, mà cũng cắt hẳn mấy vòng review kiểu “khoảng cách không khớp chỗ khác”.
Sau khi đổi
Sau semantic tokens + giới hạn arbitrary value + @apply có kiểm soát, mấy comment review kiểu “chỗ này màu hơi khác chỗ kia” giảm hẳn. Đổi theme cho một chiến dịch thì chỉ sửa biến CSS ở một chỗ, thay vì tìm-và-thay hàng trăm chỗ dùng blue-500 rải khắp repo.
Spacing scale thì vẫn chỉ là tài liệu, không phải hàng rào. Ai dùng lệch thì người review bắt. Với tụi mình thế là đủ.
Đọc tiếp
- Deep dive kiến trúc ReAct runtime trong Go: Xây dựng AI Agent tương thích chuẩn MCPKỹ Thuật Chuyên Sâu
- Kiến Trúc Multi-Provider Payment Gateway: Xử Lý Stripe, Adyen và Các Cổng Thanh Toán Khu Vực Châu ÁKỹ Thuật Chuyên Sâu
- TradingView Advanced Charts + Web Worker: Từ Tick Đến Candle Ở Cả Frontend Lẫn BackendKỹ Thuật Chuyên Sâu
Cần người cho dự án contract hoặc freelance?
Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.