Kiến Trúc Thực Sự Mở Rộng
Các dự án Frontend thường mở rộng mượt mà cho đến khi bạn gặp phải mớ logic hỗn độn và sự trùng lặp style. Tôi đã từng làm việc trên các dashboard doanh nghiệp với hơn 50 component riêng biệt, nơi các team lãng phí hơn 10 giờ mỗi tuần chỉ để debug xung đột CSS hoặc viết lại cùng một nút (button) chính đến lần thứ sáu. Bằng cách kết hợp Next.js, Tailwind và Class Variance Authority (CVA) bên trong Storybook, bạn sẽ tạo ra một quy trình làm việc thực sự ngăn nắp ngay cả khi codebase phình to.
Tailwind xử lý phần styling bằng các utility class, trong khi CVA quản lý logic phức tạp của các variant component theo cách TypeScript type-safe nghiêm ngặt. Storybook đóng vai trò như một sandbox cô lập, nơi các component này tồn tại và được kiểm thử trước khi chúng chạm vào logic chính của ứng dụng. Trong các bản build production gần đây của tôi, stack này đã giảm gần 40% lỗi liên quan đến UI và giúp quá trình bàn giao thiết kế (design hand-off) nhanh hơn đáng kể.
Vấn Đề ‘Component Hỗn Độn’
Trước đây, chúng ta thường dựa vào các template literal phức tạp hoặc các thư viện CSS-in-JS làm phình to runtime. Khi một nút duy nhất cần năm kích thước, ba bảng màu và nhiều trạng thái như loading hoặc disabled, code thường trở nên khó đọc. CVA giải quyết vấn đề này. Nó cung cấp một cách có cấu trúc để map các variant trực tiếp vào type của TypeScript. Điều này có nghĩa là bạn không thể vô tình truyền một size ‘medium-ish’ vào một component chỉ chấp nhận ‘sm’, ‘md’, hoặc ‘lg’.
Thiết Lập Nền Móng
Hãy bắt đầu với một dự án Next.js sạch. Nếu bạn bắt đầu từ con số 0, hãy sử dụng trình khởi tạo tiêu chuẩn—chỉ cần nhớ bật TypeScript và Tailwind CSS trong quá trình thiết lập.
npx create-next-app@latest my-design-system --typescript --tailwind --eslint
Sau khi dự án đã sẵn sàng, hãy truy cập vào thư mục và cài đặt các công cụ cốt lõi để quản lý variant. Tôi thực sự khuyên dùng clsx và tailwind-merge. Chúng rất cần thiết để xử lý việc gộp class mà không gặp phải các lỗi ghi đè style khó chịu khi các utility class xung đột.
npm install class-variance-authority clsx tailwind-merge
Cuối cùng, hãy khởi tạo Storybook. Lệnh này đủ thông minh để nhận diện Next.js và sẽ tự động xử lý cấu hình cho bạn.
npx storybook@latest init
Cấu Hình Lớp Tiện Ích
Trước khi viết component, bạn cần một tiện ích để gộp các class Tailwind một cách sạch sẽ. Cách tiếp cận utility-first của Tailwind rất tuyệt, nhưng nó có thể trở nên lộn xộn khi các class xung đột—chẳng hạn như cố gắng áp dụng p-4 và p-2 cùng một lúc. tailwind-merge khắc phục điều này bằng cách đảm bảo class cuối cùng được định nghĩa sẽ thực sự có hiệu lực.
Tạo một file tại src/lib/utils.ts:
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Hàm cn này là một “cứu cánh”. Bạn sẽ sử dụng nó trong mọi component để kết hợp các class do CVA tạo ra với bất kỳ class tùy chỉnh nào được truyền vào qua props.
Định Nghĩa Các Design Token
Đảm bảo rằng tailwind.config.ts của bạn phản ánh đúng thương hiệu. Đừng hardcode các giá trị hex bên trong component. Thay vào đó, hãy định nghĩa chúng trong file config. Điều này làm cho hệ thống của bạn thực sự linh hoạt; nếu thương hiệu thay đổi màu xanh chủ đạo, bạn chỉ cần cập nhật một dòng trong config thay vì phải tìm kiếm qua 20 file khác nhau.
Xây Dựng Nút Bấm Type-Safe với CVA
Hãy xây dựng một Button component như là khối nền tảng đầu tiên. CVA cho phép chúng ta định nghĩa một style cơ bản và sau đó chỉ định các biến thể cho mục đích và kích thước. Tạo file src/components/ui/Button.tsx.
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
outline: 'border border-gray-300 bg-transparent hover:bg-gray-100 text-gray-900',
ghost: 'hover:bg-gray-100 text-gray-700',
},
size: {
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4 py-2',
lg: 'h-12 px-8 text-base',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };
Đây là lúc sự thông minh lộ diện: interface ButtonProps giờ đây tự động bao gồm variant và size dưới dạng các property có kiểu dữ liệu (typed). Nếu một lập trình viên cố gắng sử dụng variant="danger" trước khi bạn định nghĩa nó, TypeScript sẽ báo lỗi ngay lập tức, ngăn chặn một giao diện lỗi tiếp cận môi trường production.
Tài Liệu Tương Tác với Storybook
Bây giờ, hãy trình diễn nút bấm này. Tạo file src/components/ui/Button.stories.tsx:
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'UI/Button',
component: Button,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'outline', 'ghost'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Click vào tôi',
},
};
export const Outline: Story = {
args: {
variant: 'outline',
children: 'Hành động phụ',
},
};
Chạy npm run storybook. Giờ đây bạn đã có một trang tài liệu sống động, nơi các designer có thể thử nghiệm các variant and lập trình viên có thể copy code triển khai thực tế.
Kiểm Tra & Các Rào Chắn Bảo Vệ
Design system của bạn chỉ hữu ích nếu các lập trình viên thực sự tin tưởng nó. Khi bạn đã có một thư viện các component, bạn cần đảm bảo rằng một thay đổi nhỏ không làm hỏng mọi thứ khác. Tôi sử dụng chiến lược ba lớp để giữ cho mọi thứ ổn định:
1. Visual Regression Testing (Kiểm Thử Hồi Quy Hình Ảnh)
Sử dụng một công cụ như Chromatic để chụp snapshot của mọi trạng thái component. Nếu bạn tinh chỉnh một margin cơ bản hoặc một biến màu sắc, Chromatic sẽ đánh dấu mọi component bị ảnh hưởng bởi thay đổi đó. Điều này ngăn chặn những sự thay đổi thiết kế ‘vô tình’ mà thường chỉ được phát hiện sau khi deploy.
2. Tự Động Kiểm Tra Khả Năng Tiếp Cận (Accessibility)
Add-on @storybook/addon-a11y là thứ bắt buộc phải có. Nó chạy các kiểm tra tự động theo tiêu chuẩn WCAG ngay trong trình duyệt. Nó sẽ cảnh báo nếu nút ‘ghost’ của bạn không đủ độ tương phản hoặc nếu các nút chỉ có icon bị thiếu aria-label, đảm bảo ứng dụng của bạn hoạt động tốt cho tất cả mọi người.
3. Kiểm Tra An Toàn Lúc Build
Thiết lập pipeline CI/CD để chạy tsc --noEmit và next lint trên mỗi pull request. Vì chúng ta đang sử dụng CVA với TypeScript, hầu hết các thay đổi gây lỗi (breaking changes) đều bị chặn lại trước khi code được merge. Nếu ai đó xóa một variant vẫn đang được sử dụng ở một trang cũ, quá trình build sẽ thất bại và cứu môi trường production của bạn khỏi lỗi runtime crash.
Việc thiết lập này tốn một chút công sức ban đầu, nhưng thành quả mang lại hoàn toàn xứng đáng. Bạn không chỉ đơn thuần là viết CSS; bạn đang xây dựng một ngôn ngữ có hệ thống tài liệu và có thể dự đoán được cho cả team. Nó biến việc phát triển UI từ một trò chơi đoán mò thành một quy trình tinh gọn.

