Cách Mình Xây Dựng Giao Diện

Nguyên tắc xây dựng giao diện nhất quán, dễ bảo trì, và accessible

Giao diện tốt là khi user làm xong việc mà không nghĩ gì về UI. Mục tiêu của mình là giúp user làm việc nhanh hơn, không cản trở.

"User đang cố làm gì, và cách đơn giản nhất để giúp họ là gì?"

Bắt đầu từ câu hỏi này trước khi viết dòng code nào.

Nguyên Tắc

1. Xây Dựng Theo Lớp

Component chia thành 3 tầng: primitive, feature, page. Mỗi tầng chỉ phụ thuộc vào tầng dưới.

┌─────────────────────────────────────────────────────────────┐
│                       PAGES                                  │
│  Component cấp route, data fetching, layout                 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│                    FEATURE COMPONENTS                        │
│  Theo domain: UserProfile, OrderList, Dashboard             │
│  Kết hợp từ primitive, chứa business logic                  │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│                    UI PRIMITIVES                             │
│  Generic: Button, Card, Input, Dialog, Table                │
│  Không business logic, tái sử dụng hoàn toàn               │
└─────────────────────────────────────────────────────────────┘

Mỗi feature mà tự build từ đầu thì sẽ có năm kiểu button khác nhau, spacing lung tung. Bắt đầu với primitive, kết hợp thành feature component, rồi lên page. Thay đổi primitive thì tự lan tỏa khắp app -- nhất quán có sẵn, không cần cố gắng.

2. Composition Hơn Configuration

Component nên lắp ghép được, không phải cấu hình bằng hàng chục prop.

Component Card mà có prop title, subtitle, avatar, actions, footer, headerActions, variant, showBorder, collapsible... thì đến lúc cần layout mới là bó tay. Tách ra thành CardHeader, CardContent, CardFooter -- mỗi phần đơn giản, ghép lại thì linh hoạt.

Tại sao lại tốt hơn? Mỗi phần làm một việc nên dễ hiểu. Cần custom thì swap hoặc thêm phần. Sửa bug thì sửa đúng phần đó, không sợ ảnh hưởng chỗ khác.

3. Thiết Kế Với Ràng Buộc

Dùng design token, đừng hardcode giá trị tùy ý.

Hardcode #3b82f6 chỗ này, #3a80f5 chỗ kia -- nhìn gần giống mà không giống. Spacing thì 17px ở đây, 18px ở kia, chẳng bao giờ thẳng hàng. Giải pháp: định nghĩa một tập giá trị hữu hạn rồi chỉ dùng trong đó thôi.

Design token cần định nghĩa:

  • Màu sắc (kể cả semantic: success, warning, error)
  • Thang spacing (thường là lũy thừa 2 hoặc một tỷ lệ nhất định)
  • Typography (font family, size, weight, line height)
  • Shadow, border radius, breakpoint

Lợi ích thấy rõ: mọi thứ dùng cùng bộ giá trị nên nhất quán. Đổi một token thì cập nhật toàn app. Designer với developer nói chung một ngôn ngữ.

4. Accessibility Là Mặc Định

"Mình sẽ thêm accessibility sau" -- chẳng bao giờ thêm cả. Hoặc thêm ARIA attribute mà không hiểu, làm mọi thứ tệ hơn.

Xây accessible từ đầu đi. Mấy cái cơ bản nhưng quan trọng:

  • Dùng đúng element (<button> cho action, không phải <div onClick>)
  • Color contrast đủ
  • Element tương tác phải focus được và rõ ràng khi focus
  • Hình ảnh có text alternative
  • Form có proper label

Bài test: dùng giao diện chỉ bằng keyboard được không? Nhắm mắt mà vẫn hiểu đang ở đâu không?

5. Mobile Trước, Mở Rộng Sau

Build desktop xong rồi nhồi vào mobile thì mọi thứ vỡ ở 768px. Ngược lại, bắt đầu từ mobile thì buộc phải ưu tiên cái gì quan trọng -- thêm vào luôn dễ hơn bỏ ra.

Mobile-first không phải là "làm cho điện thoại trước" mà là bắt đầu với trải nghiệm thiết yếu nhất, rồi nâng cấp dần.

Khung Ra Quyết Định

"Cái này có nên là component mới không?"

Tạo component mới khi: lặp lại markup hơn hai lần, có trách nhiệm rõ ràng, tái sử dụng được, hoặc đóng gói behavior/styling có ý nghĩa.

Giữ inline khi: chỉ dùng một lần, tách ra chỉ di chuyển code mà không đơn giản hơn, hoặc "component" đó chỉ năm dòng không logic.

Test nhanh: đặt tên cho nó được không? Nếu không đặt được tên thì chắc không nên là component.

"Primitive hay feature component?"

Primitive khi không biết gì về business (không biết user, order, v.v.), đủ generic để dùng mọi nơi, nhận standard HTML attribute. Nó về cách thứ trông như thế nào.

Feature component khi biết về domain (UserCard, OrderList), chứa business logic (format giá, tính tổng), cụ thể cho một phần của app.

"State này để ở đâu?"

Local state (useState): chỉ component này cần, UI-specific (open/closed, hover), reset khi unmount thì không sao.

Lifted state (parent quản lý): sibling cần phối hợp, parent cần biết thay đổi, nhiều children cần cùng data.

Global state (Context/store): nhiều component không liên quan cần truy cập, tồn tại xuyên navigation, thực sự application-wide (user session, theme).

Cái hay bị sai: đặt mọi thứ vào global state "phòng khi cần." Bắt đầu local, lift khi thực sự cần thôi.

"Dùng cách styling nào?"

CáchƯuNhược
TailwindIterate nhanh, không chuyển fileDài dòng khi pattern phức tạp
CSS ModulesScope sẵn, không xung đột tên, toàn bộ sức mạnh CSSTách file, ít colocation
CSS-in-JSStyle động theo prop, colocation tốtChi phí runtime

Chọn một cách chính rồi dùng nhất quán. Trộn lẫn thì ai cũng confused.

"Responsive design xử lý thế nào?"

Định nghĩa breakpoint theo content, không phải device. Đừng nghĩ "iPhone, iPad, Desktop" -- nghĩ "layout này vỡ ở đâu?"

Pattern progressive enhancement:

  1. Base (mobile): một cột, element xếp chồng, tính năng thiết yếu
  2. Nhỏ: hai cột khi hợp lý
  3. Trung bình: sidebar xuất hiện, nhiều item per row
  4. Lớn: layout nhiều cột, tính năng nâng cao

Không phải mọi component đều cần responsive. Để content chảy tự nhiên, chỉ can thiệp khi nó thực sự vỡ.

Sai Lầm Thường Gặp

Component "thần thánh"

Prop kiểu showHeader, showFooter, variant, mode, type, style -- một component cố gắng làm mọi thứ cho mọi người. Tách ra thành các phần nhỏ kết hợp được, để consumer lắp ghép theo nhu cầu.

Pattern không nhất quán

Button thì có cái là <Button>, cái là <button className="btn">, cái là <div onClick>. Loading state xử lý khác nhau ở mỗi nơi. Thiết lập convention rồi dùng nhất quán đi.

Accessibility nghĩ sau

<div role="button" tabIndex="0" onClick> thay vì <button>. aria-label khắp nơi vì label không thiết kế từ đầu. Bắt đầu với semantic HTML -- ARIA dành cho widget phức tạp, không phải để vá markup xấu.

Style lộn xộn

Một element có className, style, và styled() wrapper. Không ai biết style đến từ đâu. Chọn một cách, dùng nhất quán.

Thiếu trạng thái loading và error

Chỉ thiết kế happy path thì component không hiển thị gì khi loading, error thì crash cả trang. Mỗi component fetch data cần ba trạng thái: loading, error, success. Thiết kế cả ba.

Mobile là thứ nghĩ sau

Mọi thứ cuộn ngang trên điện thoại. Touch target quá nhỏ. Action quan trọng giấu trong hover menu (mà mobile không có hover). Bắt đầu với mobile -- hoạt động trên màn hình nhỏ với touch thì sẽ hoạt động ở mọi nơi.

Cách Đánh Giá Giao Diện

Đang ổn nếu:

  • User hoàn thành task mà không cần hỏi trợ giúp
  • Component nhất quán xuyên suốt app
  • Thêm feature mới dùng primitive sẵn có
  • Dùng được chỉ bằng keyboard
  • Trải nghiệm mobile có chủ đích, không phải tình cờ

Cần xem lại nếu:

  • Element tương tự trông hoặc hoạt động khác nhau
  • Mỗi feature mới đòi style mới
  • Designer chỉ ra những thứ không khớp hệ thống
  • Accessibility audit tìm thấy lỗi cơ bản
  • User phàn nàn về mobile

Xây Dựng Component Library

Đừng build design system trước khi cần

Giai đoạn 1: Dùng framework/library đã chọn. Tìm hiểu mình thực sự cần gì.

Giai đoạn 2: Trích xuất pattern lặp lại thành shared component. Button ở đây, Card ở kia.

Giai đoạn 3: Pattern xuất hiện rõ thì ghi tài liệu. Tạo API nhất quán.

Giai đoạn 4: Library phình to thì cân nhắc Storybook, design token system, tài liệu riêng.

App mới có hai trang mà xây design system hoàn chỉnh thì lãng phí. Bắt đầu đơn giản, trích xuất khi thấy lặp lại.

API cho component

  • Prop rõ ràng: variant="primary" chứ không phải v="p"
  • Mở rộng native element: Button nhận mọi thứ <button> nhận được
  • Default hợp lý: use case phổ biến nhất cần ít prop nhất
  • Đặt tên nhất quán: component này dùng size="sm" thì tất cả đều vậy
  • Forward ref: component tương tác phải focusable và ref-able

Theming

Cách theme hoạt động

┌───────────────────────┐
│  Định nghĩa Theme     │
│  (token, biến)        │
└───────────┬───────────┘
┌───────────────────────┐
│   Áp dụng Theme       │
│   (CSS var, class)    │
└───────────┬───────────┘
┌───────────────────────┐
│     Component         │
│  (đọc giá trị theme)  │
└───────────────────────┘

Pattern đơn giản: định nghĩa theme là tập token, áp dụng bằng CSS custom property, component đọc property đó -- không bao giờ hardcode. Chuyển theme thì đổi CSS custom property, component tự cập nhật.

Dark mode

Vài điều cần nhớ:

  • Không chỉ đảo ngược màu -- một số màu cần mapping khác
  • Hình ảnh và shadow có thể cần điều chỉnh
  • Test với dark mode thật, đừng chỉ đảo màu rồi coi như xong
  • Tôn trọng system preference làm mặc định