Đang tải...

Ếch Trendy

Cùng chú ếch nhỏ khám phá thế giới Web đầy biến động. Cập nhật trending nhanh như cách ếch đớp mồi! ⌨️🌿


0

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

Ếch Trendy
Ếch Trendy

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:

<div className="flex flex-col bg-white rounded-2xl shadow-md hover:shadow-xl transition-shadow duration-300 p-4 border border-gray-100 cursor-pointer max-w-sm w-full">
  <img className="w-full h-48 object-cover rounded-xl mb-3" />
  <h3 className="text-gray-900 font-semibold text-lg leading-tight mb-1" />
  <p className="text-gray-500 text-sm line-clamp-2 mb-4" />
  <button className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors">
    Thêm vào giỏ
  </button>
</div>

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.

344950 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:

// Định nghĩa token trong tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: {
          primary: '#2563EB',    // xanh chủ đạo
          secondary: '#7C3AED',  // tím phụ
          surface: '#F8FAFC',    // nền card
        },
        text: {
          base: '#1E293B',       // văn bản chính
          muted: '#64748B',      // mô tả, phụ chú
        },
      },
      spacing: {
        'section': '4rem',   // khoảng cách giữa các section lớn
        'card': '1.5rem',    // padding card chuẩn
      },
      borderRadius: {
        'card': '1rem',
        'button': '0.5rem',
      },
    },
  },
}

Sau đó dùng trong component:

// Trước: hardcode utility class
<div className="bg-blue-600 text-white rounded-lg">

// Sau: dùng token có tên rõ nghĩa
<div className="bg-brand-primary text-white rounded-button">

344951 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.

/* styles/components.css */
.btn-primary {
  @apply bg-brand-primary text-white font-medium py-2 px-4 rounded-button
         hover:opacity-90 transition-opacity focus:outline-none focus:ring-2 focus:ring-brand-primary;
}

.input-base {
  @apply w-full border border-gray-300 rounded-button px-3 py-2
         focus:border-brand-primary focus:ring-1 focus:ring-brand-primary outline-none;
}

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):

import { cva } from 'class-variance-authority';

// Định nghĩa biến thể một lần duy nhất
const buttonVariants = cva(
  // Base styles - luôn áp dụng
  'font-medium rounded-button transition-colors focus:outline-none focus:ring-2',
  {
    variants: {
      intent: {
        primary: 'bg-brand-primary text-white hover:opacity-90 focus:ring-brand-primary',
        secondary: 'bg-gray-100 text-text-base hover:bg-gray-200 focus:ring-gray-400',
        danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
      },
      size: {
        sm: 'py-1.5 px-3 text-sm',
        md: 'py-2 px-4 text-base',
        lg: 'py-3 px-6 text-lg',
      },
    },
    defaultVariants: { intent: 'primary', size: 'md' },
  }
);

// Dùng trong component
function Button({ intent, size, children, ...props }) {
  return (
    <button className={buttonVariants({ intent, size })} {...props}>
      {children}
    </button>
  );
}

// Kết quả gọn:
<Button intent="primary">Lưu</Button>
<Button intent="danger" size="sm">Xóa</Button>

344952 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 tag
  • 8px (space-2): gap trong flex nhỏ, margin giữa label và input
  • 16px (space-4): padding card nhỏ, khoảng cách giữa các phần tử trong form
  • 24px (space-6): padding card thường, gap giữa các card trong grid
  • 32px (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.

344953 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 clsxtailwind-merge. Cài một lần, dùng mãi:

npm install clsx tailwind-merge
// lib/utils.js
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

// Merge class Tailwind, tự động giải quyết xung đột
export function cn(...inputs) {
  return twMerge(clsx(inputs));
}

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-4p-6 cùng xuất hiện thì chỉ giữ lại cái sau, không phải cộng dồn.

import { cn } from '@/lib/utils';

// Trong component có class động
function Card({ className, isActive, children }) {
  return (
    <div
      className={cn(
        // Base styles
        'bg-white rounded-card p-card border border-gray-100',
        // Điều kiện
        isActive && 'ring-2 ring-brand-primary',
        // Class từ props (override được)
        className
      )}
    >
      {children}
    </div>
  );
}

// Gọi gọn:
<Card isActive>Nội dung</Card>
<Card className="shadow-lg">Nội dung khác</Card>

344954 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 để render
  • features/: dùng ui/ components, có thể có logic riêng
  • components.css: càng ít @apply càng tốt - chỉ dùng khi không thể tách thành React component (ví dụ: markdown-rendered content)

344955 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:

  1. Design token trong config - đổi màu/spacing từ một chỗ
  2. cva cho component có biến thể - thay vì điều kiện className thủ công
  3. cn() helper - merge class an toàn, không conflict
  4. Phân tầng ui/ và features/ - tái sử dụng dễ hơn
  5. 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.