Thiết Kế Component Library Nội Bộ Với Shadcn/UI Và Radix Primitives
Bài này có bản tiếng Anh. Read in English →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Quyết định ít ai bàn lúc đầu mà về sau ảnh hưởng lâu nhất: component library nội bộ xây trên nền gì.
Bên mình có hai app — admin dashboard và storefront — dùng chung một thư viện, dựng trên Radix primitives và Shadcn/UI. Dưới đây là cách mình tổ chức nó, kèm mấy chỗ đã vấp.
Tự build Dialog từ đầu: cám dỗ và cái giá
Cần một Dialog hay Select thì cám dỗ lớn nhất là tự viết luôn. Nhìn thì đơn giản. Div, thêm state show/hide, xong.
Accessibility của mấy component này mới là phần khó:
- Focus trap đúng chuẩn (focus không “thoát” ra ngoài dialog khi đang mở, và trả lại đúng chỗ khi đóng).
- Điều hướng bàn phím đầy đủ (Escape để đóng, Tab/Shift+Tab xoay vòng trong dialog, mũi tên cho menu/select).
- ARIA attributes đúng (
role,aria-expanded,aria-controls…) để screen reader hiểu đúng trạng thái. - Xử lý đúng khi component unmount giữa chừng, click outside, nested dialog…
Radix Primitives làm hết phần này. Unstyled: đưa behavior và accessibility, còn phần nhìn để mình tự quyết. Mình không cần nuốt cả một design system có sẵn — chỉ lấy đúng phần hành vi khó làm cho đúng.
Shadcn/UI: CLI sinh code vào repo, không phải package
Shadcn/UI không phải package cài qua npm rồi import. Nó là CLI generate code thẳng vào repo của bạn — components/ui/dialog.tsx các thứ. Component library thành code bạn sở hữu. Muốn sửa gì thì sửa, không bị khoá vào API của thư viện bên thứ ba.
// components/ui/dialog.tsx (rút gọn)
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
export function Dialog({ children, ...props }: DialogPrimitive.DialogProps) {
return <DialogPrimitive.Root {...props}>{children}</DialogPrimitive.Root>;
}
export function DialogContent({
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 bg-black/50 animate-in fade-in" />
<DialogPrimitive.Content
className={cn(
"fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
"rounded-xl border bg-background p-6 shadow-lg",
className,
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
}
Focus trap, Escape-to-close, portal rendering: tất cả đến từ @radix-ui/react-dialog. Phần mình thêm vào chỉ là class Tailwind cho đúng theme.
Nhiều app dùng chung thì để library ở đâu
Có từ 2 app trở lên — admin dashboard và storefront — thì hai câu hỏi hiện ra. Library sống ở đâu, và theme khác nhau xử lý kiểu gì.
- Package riêng trong monorepo (
packages/design-system) thay vì copy-paste giữa các app — một bug fix hay cải tiến accessibility chỉ cần sửa một chỗ. - Theming qua CSS variables thay vì hard-code màu trong component: mỗi app override biến
--primary,--radius… ở root của mình, component bên trong không cần biết app nào đang dùng nó.
/* apps/admin/globals.css */
:root {
--primary: 221 83% 53%;
--radius: 0.5rem;
}
/* apps/storefront/globals.css */
:root {
--primary: 160 84% 39%;
--radius: 1rem;
}
Component dùng hsl(var(--primary)) thay vì giá trị cố định — cùng một <Button>, hai app hiển thị màu và bo góc khác nhau mà không cần fork component.
Xếp lại theo lớp thì nó như thế này:
flowchart TB P["Radix Primitives<br/>unstyled: behavior + accessibility<br/>focus trap, Escape, ARIA"] P --> S["Shadcn/UI CLI sinh code<br/>components/ui/dialog.tsx vào repo"] S --> DS["packages/design-system<br/>package dùng chung trong monorepo"] DS --> AD["apps/admin<br/>override --primary, --radius ở root"] DS --> SF["apps/storefront<br/>override --primary, --radius ở root"] AD --> BT["Cùng một Button<br/>đọc hsl(var(--primary))"] SF --> BT AD -.-> DM[":root và .dark<br/>khai báo cặp biến song song từ ngày đầu"] SF -.-> DM
Dark mode không phải chuyện thêm sau
Mình nếm một lần rồi. Theming thiết kế từ đầu mà không tính dark mode, thêm vào sau phải sửa gần hết component đã viết. Từ đó bọn mình định nghĩa cặp biến light/dark song song ngay ngày đầu, kể cả khi app chưa cần:
:root {
--background: 0 0% 100%;
--foreground: 240 10% 4%;
}
.dark {
--background: 240 10% 4%;
--foreground: 0 0% 98%;
}
Versioning khi các app không nâng cấp cùng lúc
Chuyện thật: app A muốn version mới nhất của Button, app B chưa sẵn sàng migrate breaking change. Version từng component riêng lẻ thì quá mệt để maintain. Cách bọn mình chọn:
- Giữ breaking change ở mức tối thiểu, ưu tiên thêm prop mới optional thay vì đổi API cũ.
- Khi bắt buộc phải breaking, release major version mới của cả package, kèm changelog rõ ràng migration path, để từng app tự quyết định thời điểm nâng cấp.
Sau khi chuẩn hoá thì được gì
Thời gian build một màn hình mới giảm hẳn. Dialog, select, combobox, data table đã có sẵn, đã test accessibility kỹ, chỉ việc compose lại.
Hai app giờ nói cùng một ngôn ngữ thiết kế. Không ai phải nhớ quy tắc bất thành văn nữa — quy tắc nằm trong code, chứ không nằm trong đầu một người.
Đọc tiếp
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ờ.