← Design System← Design System
Design SystemDesign System19 Th7, 2026Jul 19, 202626 phút đọc21 min read

Component cốt lõi: Input & Hành độngCore Components: Inputs & Actions

Thuộc bộ kiến thức Design System Roadmap.

Tổng quan

Mọi design system đều có một nhóm nhỏ component xuất hiện ở gần như mọi màn hình: Button, text Input, Checkbox, Radio, Switch, Select. Đây chính là các component cốt lõi (core components), và chúng xứng đáng với cái tên đó không phải vì đẹp mắt, mà vì tần suất tái sử dụng cực cao. Một trang marketing có thể chỉ dùng component “hero banner” một lần trên mỗi trang; trong khi đó, Button có thể được khởi tạo hàng chục lần chỉ riêng trong một luồng checkout. Sự bất đối xứng này kéo theo hệ quả trực tiếp về lượng công sức thiết kế và kỹ thuật nên đầu tư: một lỗi nhỏ ở component ít dùng thì tốn kém không đáng kể, nhưng một lỗi nhỏ ở Button — thiếu focus state, thang size không nhất quán, prop disabled âm thầm không có tác dụng trên button được render bằng <a> — sẽ bị nhân lên trên toàn bộ sản phẩm và trở nên cực kỳ tốn kém để sửa về sau, khi đã có hàng trăm nơi phụ thuộc vào hành vi lỗi đó.

Bài này bao quát hai nhóm component cốt lõi tạo nên phần lớn UI tương tác:

Bài cũng bao quát cách các “nguyên liệu” (primitives) này kết hợp thành form, cũng như các nguyên tắc thiết kế API component (đặt tên prop, controlled vs. uncontrolled) giúp bộ component library nhất quán khi lớn dần lên. Các nền tảng thị giác mà những component này sử dụng — màu sắc, khoảng cách, thang typography — được trình bày trong Design Token & Nền tảng thị giác. Các component hiển thị thông tin thay vì thu thập thông tin (alert, toast, tooltip, badge) được trình bày trong Component cốt lõi — Nội dung & Feedback. Hành vi bàn phím và trình đọc màn hình cho từng component được nói ở đây sẽ được mở rộng trong Accessibility & Thiết kế bao trùm.

Vì sao component “cốt lõi” xứng đáng được đầu tư nhiều hơn

Ba đặc điểm phân biệt một component cốt lõi với một component thông thường:

  1. Tần suất tái sử dụng. Nó xuất hiện ở gần như mọi tính năng, thường nhiều lần trên cùng một màn hình.
  2. Vai trò nền tảng. Các component phức tạp hơn (action ở mỗi hàng của data table, nút confirm trong modal, mỗi field trong form) đều được xây trên nó. Một lỗi ở đây sẽ lan ra khắp nơi.
  3. Khả năng chịu đựng thay đổi API thấp. Vì quá nhiều nơi phụ thuộc vào nó, việc thay đổi hình dạng prop về sau là một breaking change có phạm vi ảnh hưởng rất lớn. Làm đúng API ngay từ đầu rẻ hơn nhiều so với sửa sau này.

Đây là lý do các đội xây design system thường xây Button và text Input trước tiên, đầu tư nhiều nhất vào tài liệu và test cho chúng, và coi mỗi lần thêm prop mới cho chúng là một buổi review thiết kế thu nhỏ chứ không phải một PR thông thường.

Kiến thức nền tảng

Button

Button kích hoạt một hành động — submit form, mở dialog, điều hướng, xóa một bản ghi. Nó được định nghĩa theo ba trục độc lập: variant (thứ bậc thị giác / ý định), size (mật độ), và state (phản hồi tương tác).

Variant

VariantÝ địnhCách dùng điển hìnhTrọng lượng thị giác
primaryHành động quan trọng nhất trên màn hình”Save”, “Submit”, “Continue”Cao nhất — nền đặc, màu thương hiệu
secondaryHành động thay thế hoặc hỗ trợ đi kèm với primary”Cancel” đứng cạnh “Save”Trung bình — viền ngoài (outline) hoặc nền nhạt
tertiaryHành động ít nhấn mạnh, thường nằm trong dòng text hoặc UI dày đặc”Learn more”, nút phân trangThấp — không viền, giống text
ghostHành động tối giản, hòa vào nền cho đến khi được tương tácIcon button trong toolbar, action trong cardThấp nhất — trong suốt cho đến khi hover/focus
dangerHành động phá hủy hoặc không thể hoàn tác”Delete account”, “Remove member”Màu riêng biệt (đỏ/cam), bất kể pairing với primary/secondary

Một màn hình chỉ nên có tối đa một nút primary trong mỗi nhóm hành động logic — đó chính là điều tạo nên ý nghĩa “bước tiếp theo được đề xuất”. Một màn hình đầy nút primary thì không còn thứ bậc nào cả, làm mất đi mục đích của việc có variant.

Size

SizeChiều cao điển hìnhTrường hợp dùng
sm28–32pxUI dày đặc, action trong hàng của table, toolbar
md36–40pxMặc định cho phần lớn form và dialog
lg44–48pxTrang marketing, CTA chính, UI ưu tiên chạm (touch-first) trên mobile

Các size nên ánh xạ vào cùng bộ token spacing/typography dùng ở nơi khác trong hệ thống (xem Design Token & Nền tảng thị giác) thay vì các giá trị pixel tùy tiện, để Button size="md"Input size="md" thẳng hàng về mặt thị giác khi đặt cạnh nhau trong một form.

State

StateKích hoạt bởiThay đổi gì
DefaultỞ trạng thái nghỉStyle cơ bản
HoverCon trỏ đặt trên phần tửThay đổi nhẹ nền/viền — chỉ áp dụng với chuột, không áp dụng trên touch
FocusFocus bằng bàn phím (:focus-visible)Vòng focus rõ ràng, phải đạt yêu cầu về độ tương phản
ActiveĐang nhấn / :activeHơi tối hơn/lún xuống, tạo cảm giác phản hồi xúc giác
DisabledProp disabledGiảm opacity, pointer-events: none, bị loại khỏi tab order, dùng aria-disabled hoặc disabled native
LoadingProp loadingSpinner thay thế hoặc đi kèm label, nút không còn tương tác được, label thường được giữ lại để tránh lệch chiều rộng

Trạng thái loading cần được chăm chút riêng: thay label bằng spinner mà không cố định chiều rộng sẽ gây layout shift; một pattern phổ biến là giữ nguyên kích thước nút, hoặc chồng spinner lên trên label đã ẩn, hoặc đặt spinner cạnh label và vô hiệu hóa tương tác khi loading là true. disabledloading không nên bị gộp chung trong API — một nút loading thường vẫn muốn truyền tải “hành động này đang được xử lý”, trong khi disabled truyền tải “hành động này hiện không hợp lệ”, và trình đọc màn hình nên thông báo chúng khác nhau (aria-busy cho loading, aria-disabled cho disabled).

Ví dụ API component

// Cách dùng cơ bản
<Button variant="primary" size="md">
  Save changes
</Button>

// Hành động phụ đi kèm
<Button variant="secondary" size="md">
  Cancel
</Button>

// Hành động phá hủy
<Button variant="danger" size="sm" onClick={handleDelete}>
  Delete account
</Button>

// Trạng thái loading — nút vẫn disabled trong lúc xử lý
<Button variant="primary" size="md" loading disabled={isSubmitting}>
  Submit
</Button>

// Icon-only button — bắt buộc phải có accessible name vì không có label hiển thị
<Button variant="ghost" size="sm" iconOnly aria-label="Close dialog">
  <CloseIcon />
</Button>

// Icon + label — icon mang tính trang trí, label mang ý nghĩa
<Button variant="secondary" size="md" icon={<DownloadIcon />}>
  Download report
</Button>

Hình dạng prop TypeScript tiêu biểu cho Button:

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'tertiary' | 'ghost' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  disabled?: boolean;
  loading?: boolean;
  iconOnly?: boolean;
  icon?: React.ReactNode;
  type?: 'button' | 'submit' | 'reset';
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
  'aria-label'?: string; // bắt buộc theo lint rule khi iconOnly là true
  children?: React.ReactNode;
}

Icon-only vs. icon + label

Mặc định nên dùng icon + label — rõ ràng, dễ quét mắt hơn với người dùng mới, và không phụ thuộc vào việc người dùng nhận ra ý nghĩa của icon khi tách khỏi ngữ cảnh. Chỉ dùng nút icon-only khi:

Mọi nút icon-only bắt buộc phải có accessible name qua aria-label (hoặc aria-labelledby) — bản thân icon không truyền tải gì cho trình đọc màn hình, và chỉ dựa vào tooltip title là không đủ vì tooltip không được công nghệ hỗ trợ (assistive technology) hiển thị một cách đáng tin cậy, và cũng không hoạt động tốt trên thiết bị cảm ứng.

Text Input & Textarea

InputTextarea thu thập giá trị dạng văn bản tự do. Mô hình state của chúng tương tự Button nhưng có thêm các state liên quan đến validation:

StateKích hoạt bởiThay đổi gì
DefaultNghỉ, chưa được chạm vàoViền/nền cơ bản
Focus:focusVòng focus, thay đổi màu viền
ErrorValidation thất bạiViền đỏ, icon lỗi, thông báo lỗi hiển thị bên dưới
DisabledProp disabledGiảm opacity, không focus được, giá trị không được gửi kèm form
Read-onlyProp readOnlyGiá trị vẫn hiển thị và có thể chọn/copy, nhưng không sửa được; vẫn focus được và vẫn được submit

Disabled và read-only thường bị nhầm lẫn: disabled loại field khỏi tab order và khỏi việc submit form hoàn toàn (hữu ích cho field “chưa áp dụng được”); readOnly giữ field có thể focus, chọn, và vẫn nằm trong dữ liệu submit, nhưng ngăn chỉnh sửa (hữu ích cho giá trị được tính toán hoặc điền sẵn mà người dùng nên copy được nhưng không được thay đổi).

Cấu trúc (anatomy)

Một text input hiếm khi chỉ là mỗi phần tử <input> — cấu trúc đầy đủ mà một design system thường chuẩn hóa là:

Cấu trúc một form field
  1. 1
    Labeltùy chọn
  2. 2
    [prefix] value [suffix]bản thân field
  3. 3
    Helper text hoặc thông báo lỗi

Ví dụ API component

<FormField
  label="Email address"
  helperText="We'll never share your email."
  error={errors.email}
  required
>
  <Input
    type="email"
    name="email"
    value={email}
    onChange={(e) => setEmail(e.target.value)}
    placeholder="you@example.com"
    disabled={isSubmitting}
  />
</FormField>

Khi được compose lại, nó sẽ render ra đại khái:

<div class="form-field">
  <label for="email">Email address <span aria-hidden="true">*</span></label>
  <input
    id="email"
    type="email"
    name="email"
    aria-required="true"
    aria-invalid="true"
    aria-describedby="email-error email-helper"
  />
  <p id="email-helper" class="helper-text">We'll never share your email.</p>
  <p id="email-error" class="error-text" role="alert">Enter a valid email address.</p>
</div>

Lưu ý aria-describedby có thể tham chiếu nhiều ID cùng lúc (cả helper text lẫn error message), và aria-invalid="true" được đặt bất cứ khi nào error khác rỗng, độc lập với viền đỏ hiển thị — tín hiệu thị giác và tín hiệu lập trình luôn phải đi cùng nhau, nếu không người dùng bình thường sẽ thấy lỗi trong khi người dùng trình đọc màn hình không hề được thông báo.

Textarea dùng chung cấu trúc và mô hình state với Input, chỉ thêm mối quan tâm về rows / autoResize cho kích thước — nó vẫn nên nhận label, helperText, error, và disabled qua cùng một wrapper FormField để mọi component nhập text trong hệ thống trông và hoạt động nhất quán.

Khái niệm chính

Checkbox, Radio, và Switch — chọn đúng control

Ba control này đều toggle một trạng thái kiểu boolean, đó chính là lý do chúng dễ bị nhầm lẫn với nhau. Sự khác biệt nằm ở hình dạng của lựa chọn, chứ không chỉ ở style thị giác.

Khía cạnhCheckboxRadioSwitch
Mô hình lựa chọnĐộc lập, chọn từ không đến nhiều; hoặc một boolean đơn lẻLoại trừ lẫn nhau, chọn đúng một trong tập hiển thịĐộc lập, bật/tắt ngay lập tức
Kích thước nhóm điển hình0 hoặc nhiều từ một danh sách2+ lựa chọn, tất cả hiển thị cùng lúcĐứng riêng lẻ, mỗi setting một cái
Khi nào thay đổi có hiệu lựcThường khi submit formThường khi submit formNgay lập tức, không cần bước submit
Ví dụDanh sách “chọn tất cả các mục phù hợp”, một checkbox “Tôi đồng ý với điều khoản""Tốc độ giao hàng: Standard / Express / Overnight""Bật dark mode”, “Wi-Fi”
Khi KHÔNG nên dùngKhông dùng cho một mục đơn lẻ mà trạng thái có hiệu lực ngay (dùng Switch)Không dùng khi chỉ có một lựa chọn, hoặc khi lựa chọn cần thay đổi ngay lập tức (dùng Switch hoặc Select nếu danh sách dài)Không dùng khi hành động cần xác nhận, có tác dụng phụ mang tính phá hủy, hoặc là một phần của form được submit theo lô (dùng Checkbox + Submit)

Quy tắc chung: checkbox = “đánh dấu không hoặc nhiều mục trong số này” hoặc “xác nhận một sự thật/thỏa thuận đơn lẻ”; radio = “chọn đúng một, và tôi muốn thấy tất cả lựa chọn trước khi chọn”; switch = “bật/tắt một setting ngay bây giờ, không cần hành động gì thêm.”

Sai lầm phổ biến: switch cho việc cần bước xác nhận

Một lỗi sai thường gặp là đặt Switch cho một hành động thực chất có hệ quả lớn, có thể không thể hoàn tác — “Bật xác thực hai yếu tố”, “Đưa repository này thành public”, “Tự động gia hạn subscription”. Switch truyền tải “tức thì, ít rủi ro, có thể hoàn tác” hoàn toàn qua mô hình tương tác của nó (gạt nó, xong ngay, gạt lại nếu đổi ý). Nếu hành động thực tế không phải như vậy — nếu nó cần một dialog tiếp theo, gửi email, hoặc không thể dễ dàng hoàn tác — thì việc dùng Switch sẽ tạo kỳ vọng sai, và người dùng sẽ bật/tắt nó mà không nhận ra hệ quả vừa xảy ra. Trong những trường hợp này, nên ưu tiên Checkbox bên trong một form có nút Save/Confirm rõ ràng, hoặc một Switch mở ngay dialog xác nhận trước khi thực sự áp dụng thay đổi (và tự trả lại trạng thái thị giác nếu người dùng hủy).

// Ổn: tức thì, có thể hoàn tác, không có tác dụng phụ ngoài preference UI
<Switch
  checked={darkMode}
  onChange={setDarkMode}
  label="Dark mode"
/>

// Rủi ro nếu để switch trần — có hệ quả lớn và khó hoàn tác
<Switch
  checked={isPublic}
  onChange={handleTogglePublic} // kích hoạt ngay lập tức, không có xác nhận
  label="Make repository public"
/>

// Tốt hơn: switch mở bước xác nhận trước khi thực sự áp dụng
<Switch
  checked={isPublic}
  onChange={(next) => next ? openConfirmDialog() : handleTogglePublic(false)}
  label="Make repository public"
/>

Ví dụ API cho Checkbox và Radio

// Nhóm Checkbox — chọn từ không đến nhiều
<CheckboxGroup label="Notify me about" name="notifications">
  <Checkbox value="comments" label="New comments" />
  <Checkbox value="mentions" label="Mentions" defaultChecked />
  <Checkbox value="digest" label="Weekly digest" />
</CheckboxGroup>

// Checkbox đơn lẻ — một sự đồng ý dạng boolean
<Checkbox
  checked={agreed}
  onChange={(e) => setAgreed(e.target.checked)}
  label="I agree to the Terms of Service"
  required
/>

// Nhóm Radio — chọn đúng một trong tập hiển thị
<RadioGroup
  label="Shipping speed"
  name="shipping"
  value={shipping}
  onChange={setShipping}
>
  <Radio value="standard" label="Standard (5–7 days)" />
  <Radio value="express" label="Express (2–3 days)" />
  <Radio value="overnight" label="Overnight" />
</RadioGroup>

Lưu ý sự bất đối xứng trong API: CheckboxGroup chỉ là một wrapper tiện lợi cho layout và chia sẻ label/name, nhưng trạng thái checked của từng Checkbox là độc lập — bỏ chọn một cái không ảnh hưởng đến những cái khác. Ngược lại, RadioGroup sở hữu duy nhất một value/onChange cho cả nhóm, vì các lựa chọn loại trừ lẫn nhau theo định nghĩa — một Radio riêng lẻ không có trạng thái checked độc lập nào có ý nghĩa bên ngoài nhóm của nó.

Select & Dropdown

Cả hai đều cho phép người dùng chọn một giá trị (hoặc nhiều giá trị) từ một danh sách, nhưng chúng giải quyết vấn đề theo cách rất khác nhau, và lựa chọn giữa chúng là một trong những quyết định về accessibility có hệ quả lớn nhất mà một design system phải đưa ra.

<select> native:

<label for="country">Country</label>
<select id="country" name="country">
  <option value="">Select a country</option>
  <option value="vn">Vietnam</option>
  <option value="us">United States</option>
  <option value="jp">Japan</option>
</select>

Component Dropdown tùy chỉnh:

<Dropdown
  label="Country"
  value={country}
  onChange={setCountry}
  options={[
    { value: 'vn', label: 'Vietnam', icon: <FlagVN /> },
    { value: 'us', label: 'United States', icon: <FlagUS /> },
    { value: 'jp', label: 'Japan', icon: <FlagJP /> },
  ]}
  placeholder="Select a country"
  searchable
/>
Khía cạnh<select> nativeDropdown tùy chỉnh
AccessibilityMiễn phí — điều hướng bàn phím, thông báo cho trình đọc màn hình, type-ahead đều có sẵn trong trình duyệt/hệ điều hànhPhải tự cài đặt lại bằng tay: phím mũi tên, Home/End, type-ahead, aria-expanded, aria-activedescendant hoặc roving tabindex, focus trapping
Hành vi trên mobileMở picker wheel/sheet native của hệ điều hành — nhanh, quen thuộc, thân thiện với ngón tay cáiRender một overlay tùy chỉnh phải tự tinh chỉnh cho touch, viewport clipping, và safe-area insets
Kiểm soát thị giácRất hạn chế — khó hiển thị icon, option nhiều dòng, hoặc định dạng phong phú bên trong optionToàn quyền kiểm soát — icon, avatar, mô tả, tiêu đề nhóm, layout option tùy chỉnh
Tính năng bổ sungKhông có sẵn — không search/filter, không multi-select nếu không có mẹo markup thêmCó thể thêm search-to-filter, multi-select với tag, option tải bất đồng bộ, gom nhóm
Chi phí triển khaiGần như bằng khôngĐáng kể — một custom dropdown đúng đắn, accessible là một trong những component khó xây dựng nhất từ đầu
Phù hợp nhất choDanh sách đơn giản, ngắn đến trung bình, không cần hiển thị đặc biệt (quốc gia, tiểu bang, giá trị enum thuần)Danh sách dài cần search, option cần hiển thị phong phú, hoặc multi-select với tag hiển thị

Lựa chọn thực dụng mặc định là ưu tiên <select> native trước, và chỉ xây Dropdown tùy chỉnh khi có yêu cầu thực sự đòi hỏi (search, icon, multi-select) — và khi đó, component phải cài đặt lại đúng đắn pattern ARIA combobox/listbox, bao gồm cả hỗ trợ bàn phím, thay vì chỉ là một <div> được style đẹp mà chỉ hoạt động được với chuột.

Form như một sự kết hợp (composition)

Một form không hẳn là một component tự thân mà đúng hơn là một bài toán về layout và điều phối (orchestration) được xây trên các primitive ở trên. Hai quyết định lặp lại ở gần như mọi form:

Vị trí đặt label

PatternƯu điểmNhược điểmTrường hợp dùng điển hình
Label phía trên fieldDễ quét mắt, hoạt động tốt ở mọi chiều rộng field, thứ tự đọc mặc định thân thiện với accessibility nhấtTốn nhiều không gian theo chiều dọcLựa chọn mặc định cho hầu hết form
Label bên cạnh field (căn trái)Gọn theo chiều dọc, quen thuộc trong UI admin dày đặcCần căn chỉnh cẩn thận khi độ dài label khác nhau; khó vừa với viewport hẹp/mobileTrang settings, panel admin dày đặc dữ liệu
Label nổi / inlineRất gọn, hợp xu hướngMơ hồ khi đã điền xong, khó xử lý với giá trị điền sẵn hoặc dài, chuyển động thêm có thể ảnh hưởng người dùng nhạy cảm tiền đìnhForm marketing, UI hạn chế về không gian (nên dùng dè dặt)

Trình bày lỗi: summary vs. inline

Hầu hết hệ thống trưởng thành làm cả hai: một error summary ở đầu khi submit (được thông báo qua role="alert" hoặc chuyển focus vào một vùng aria-live), cộng với lỗi inline ở mỗi field bị ảnh hưởng để cung cấp ngữ cảnh khi người dùng đến đó.

Thời điểm validation

Thời điểmHành viĐánh đổi
Chỉ khi submitLỗi chỉ xuất hiện sau khi người dùng cố submitÍt gây khó chịu nhất khi đang gõ, nhưng có thể khiến người dùng bất ngờ với một loạt lỗi ở cuối
Khi blurField được validate khi người dùng rời khỏi nóĐiểm cân bằng tốt — phản hồi theo từng field mà không làm gián đoạn việc gõ
Live / khi thay đổiField được validate ở mỗi lần gõ phímTốt nhất cho phản hồi tức thì như độ mạnh mật khẩu hay kiểm tra username còn trống, nhưng có thể gây khó chịu nếu hiển thị “lỗi” trước khi người dùng gõ xong (ví dụ báo email không hợp lệ trước khi họ kịp gõ dấu @)

Một cách dung hòa phổ biến và đã được kiểm chứng: validate khi blur cho lần đầu tiên (để field không bị gắn cờ lỗi khi người dùng vẫn đang gõ dở), sau đó chuyển sang validate live cho field cụ thể đó một khi nó đã từng hiện lỗi — để người dùng nhận được xác nhận tức thì rằng việc sửa của họ đã đúng, mà không bị làm phiền trước khi lần thử đầu tiên kịp hoàn thành.

Best Practices

Đặt tên prop nhất quán giữa các component

Khoản đầu tư có đòn bẩy cao nhất trong API của một component library là đặt tên cùng một khái niệm theo cùng một cách ở mọi component. Nếu Button dùng size="sm" | "md" | "lg", Input cũng nên dùng đúng bộ giá trị đó — chứ không phải small/medium/large ở component này và sm/md/lg ở component khác. Tương tự với:

PropÝ nghĩa ở mọi nơi nó xuất hiệnAnti-pattern cần tránh
sizeCùng một thang (sm/md/lg, hoặc thang số) ở mọi component có thể đổi kích thướcButton dùng size, Input dùng inputSize
variantTrục style thị giác/ngữ nghĩaTrộn lẫn varianttype cho cùng một khái niệm giữa các component
disabledComponent không thể tương tác, bị loại khỏi tab orderMột component dùng isDisabled trong khi các component khác dùng disabled
errorValidation thất bại, thường nhận chính thông báo lỗi hoặc một booleanMột component nhận string, component khác chỉ nhận boolean mà không có cách hiển thị nội dung thông báo
labelĐoạn text hiển thị, được liên kết với controlMột số component yêu cầu aria-label riêng mà không có prop label hiển thị nào cả
onChangeKích hoạt với giá trị mới (hoặc event) mỗi khi giá trị thay đổiMột số component chỉ kích hoạt onChange khi blur, số khác ở mỗi lần gõ phím, mà không có cách nào biết được từ API

Một bảng ngắn gọn, được xuất bản như thế này — đôi khi được gọi thẳng là “prop glossary” — nên nằm trong tài liệu của design system (xem Guideline Component & Mẫu tài liệu nếu có trong bộ kiến thức này) để mọi người đóng góp khi xây component mới sẽ tái sử dụng từ vựng sẵn có thay vì tự sáng tạo ra một bộ mới.

Controlled vs. uncontrolled component

Mọi component kiểu input trong một design system cần có một lập trường rõ ràng về việc ai sở hữu state:

// Controlled — component cha điều khiển giá trị
<Input value={email} onChange={(e) => setEmail(e.target.value)} />

// Uncontrolled — component tự sở hữu state, cha đọc khi submit
<Input defaultValue="" name="email" ref={emailRef} />
// sau này: emailRef.current.value, hoặc qua FormData(formEl) khi submit

Hầu hết design system hỗ trợ cả hai để linh hoạt, phản ánh đúng cách các phần tử form HTML native hoạt động (<input value={x} onChange={...}> so với <input defaultValue={x}>), và coi một component là “controlled” hoàn toàn dựa trên việc value có được truyền vào hay không — chính là quy ước mà bản thân React sử dụng. Điều một design system cần tránh là một component controlled khi đọc (nhận value) nhưng lại âm thầm bỏ qua các cập nhật đến nó (không gọi onChange, hoặc không re-render khi value thay đổi từ bên ngoài) — đây là lỗi controlled-component phổ biến nhất và khiến component không thể dự đoán được hành vi.

Các hướng dẫn thực tế khác

Tài liệu tham khảo

Part of the Design System Roadmap knowledge base.

Overview

Every design system has a small set of components that appear on almost every screen: buttons, text inputs, checkboxes, radios, switches, selects. These are the core components, and they earn that name not because they are visually impressive but because of sheer reuse frequency. A marketing site might use a “hero banner” component once per page; a Button component gets instantiated dozens of times across a single checkout flow alone. That asymmetry has a direct consequence for how much design and engineering effort is worth investing: a rounding error in a rarely-used component costs little, but a rounding error in Button — a missing focus state, an inconsistent size scale, a disabled prop that silently does nothing on <a>-rendered buttons — gets multiplied across the entire product and becomes expensive to fix retroactively once hundreds of call sites depend on the broken behavior.

This note covers the two core component families that make up the bulk of interactive UI:

It also covers how these primitives compose into forms, and the API design principles (prop naming, controlled vs. uncontrolled) that keep a component library coherent as it grows. Visual foundations that these components consume — color, spacing, typography scales — are covered in Design Tokens & Visual Foundations. Components that display information rather than collect it (alerts, toasts, tooltips, badges) are covered in Core Components — Content & Feedback. Keyboard and screen-reader behavior for every component discussed here is expanded on in Accessibility & Inclusive Design.

Why “core” components deserve disproportionate care

Three properties distinguish a core component from an ordinary one:

  1. Reuse frequency. It appears in nearly every feature, often many times per screen.
  2. Foundational role. Other, more complex components (a data table’s row actions, a modal’s confirm button, a form’s every field) are built out of it. A bug here propagates outward.
  3. Low tolerance for API churn. Because so many call sites depend on it, changing its prop shape later is a breaking change with a large blast radius. Getting the API right early is cheaper than fixing it later.

This is why teams building a design system typically build Button and text Input first, invest the most in their documentation and tests, and treat any prop addition to them as a mini design review rather than a routine PR.

Fundamentals

Button

Button triggers an action — submitting a form, opening a dialog, navigating, deleting a record. It is defined along three independent axes: variant (visual hierarchy / intent), size (density), and state (interaction feedback).

Variants

VariantIntentTypical usageVisual weight
primaryThe single most important action on the screen”Save”, “Submit”, “Continue”Highest — solid fill, brand color
secondaryAn alternative or supporting action alongside a primary one”Cancel” next to “Save”Medium — outlined or tinted
tertiaryLow-emphasis action, often inline with text or in dense UI”Learn more”, pagination controlsLow — no border, text-like
ghostMinimal-chrome action, blends into the background until interacted withIcon buttons in toolbars, card actionsLowest — transparent until hover/focus
dangerA destructive or irreversible action”Delete account”, “Remove member”Distinct color (red/orange), regardless of primary/secondary pairing

A screen should have at most one primary button per logical group of actions — that’s what gives it meaning as “the recommended next step.” A screen full of primary buttons has no hierarchy at all, which defeats the purpose of having variants.

Sizes

SizeTypical heightUse case
sm28–32pxDense UI, table row actions, toolbars
md36–40pxDefault for most forms and dialogs
lg44–48pxMarketing pages, primary CTAs, touch-first mobile UI

Sizes should map onto the same spacing/typography tokens used elsewhere in the system (see Design Tokens & Visual Foundations) rather than one-off pixel values, so that a Button size="md" and an Input size="md" line up visually when placed side by side in a form.

States

StateTriggerWhat changes
DefaultIdleBaseline style
HoverPointer over elementSubtle background/border shift — pointer-only, does not apply on touch
FocusKeyboard focus (:focus-visible)Visible focus ring, must meet contrast requirements
ActivePressed / :activeSlightly darker/pressed appearance, gives tactile feedback
Disableddisabled propReduced opacity, pointer-events: none, removed from tab order, aria-disabled or native disabled
Loadingloading propSpinner replaces or accompanies label, button becomes non-interactive, label is often preserved for width stability

Loading deserves special care: swapping the label for a spinner with no fixed width causes layout shift; a common pattern is to keep the button’s dimensions fixed and either overlay the spinner on top of a hidden label, or place the spinner beside the label and disable interaction while loading is true. disabled and loading should never be conflated in the API — a loading button is usually still meant to communicate “this specific action is in flight,” while disabled communicates “this action is currently invalid,” and screen readers should announce them differently (aria-busy for loading vs. aria-disabled for disabled).

Component API example

// Basic usage
<Button variant="primary" size="md">
  Save changes
</Button>

// Secondary action alongside it
<Button variant="secondary" size="md">
  Cancel
</Button>

// Destructive action
<Button variant="danger" size="sm" onClick={handleDelete}>
  Delete account
</Button>

// Loading state — button stays disabled while in flight
<Button variant="primary" size="md" loading disabled={isSubmitting}>
  Submit
</Button>

// Icon-only button — requires an accessible name since there's no visible label
<Button variant="ghost" size="sm" iconOnly aria-label="Close dialog">
  <CloseIcon />
</Button>

// Icon + label — icon is decorative, label carries the meaning
<Button variant="secondary" size="md" icon={<DownloadIcon />}>
  Download report
</Button>

A representative TypeScript prop shape for Button:

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'tertiary' | 'ghost' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  disabled?: boolean;
  loading?: boolean;
  iconOnly?: boolean;
  icon?: React.ReactNode;
  type?: 'button' | 'submit' | 'reset';
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
  'aria-label'?: string; // required by lint rule when iconOnly is true
  children?: React.ReactNode;
}

Icon-only vs. icon + label

Use icon + label by default — it’s unambiguous, scans faster for new users, and doesn’t rely on the user recognizing an icon’s meaning out of context. Reserve icon-only buttons for:

Every icon-only button must carry an accessible name via aria-label (or aria-labelledby) — the icon alone conveys nothing to a screen reader, and relying on a title tooltip alone is not sufficient because tooltips are not reliably exposed to assistive technology and don’t work well on touch.

Text Input & Textarea

Input and Textarea collect free-text values. Their state model is similar to Button but adds validation-related states:

StateTriggerWhat changes
DefaultIdle, untouchedBaseline border/background
Focus:focusFocus ring, border color shift
ErrorValidation failedRed border, error icon, error message shown below
Disableddisabled propReduced opacity, not focusable, value not submitted with form
Read-onlyreadOnly propValue visible and selectable/copyable, but not editable; still focusable and submitted

Disabled and read-only are often confused: disabled removes the field from the tab order and from form submission entirely (useful for “not applicable yet” fields); readOnly keeps the field focusable, selectable, and included in submission, but prevents edits (useful for a computed or pre-filled value the user should be able to copy but not change).

Anatomy

A text input is rarely just the <input> element — the full anatomy that a design system typically standardizes is:

Anatomy of a form field
  1. 1
    Labeloptional
  2. 2
    [prefix] value [suffix]the field itself
  3. 3
    Helper text or error message

Component API example

<FormField
  label="Email address"
  helperText="We'll never share your email."
  error={errors.email}
  required
>
  <Input
    type="email"
    name="email"
    value={email}
    onChange={(e) => setEmail(e.target.value)}
    placeholder="you@example.com"
    disabled={isSubmitting}
  />
</FormField>

Which, once composed, renders roughly to:

<div class="form-field">
  <label for="email">Email address <span aria-hidden="true">*</span></label>
  <input
    id="email"
    type="email"
    name="email"
    aria-required="true"
    aria-invalid="true"
    aria-describedby="email-error email-helper"
  />
  <p id="email-helper" class="helper-text">We'll never share your email.</p>
  <p id="email-error" class="error-text" role="alert">Enter a valid email address.</p>
</div>

Note that aria-describedby can reference multiple IDs (helper text and error message both), and aria-invalid="true" is set whenever error is non-empty, independent of the visual red border — the visual and the programmatic signal must always travel together, otherwise a sighted user sees an error that a screen reader user never hears about.

Textarea shares the same anatomy and state model as Input, adding only a rows / autoResize concern for sizing — it should still accept label, helperText, error, and disabled through the same FormField wrapper so that every text-entry component in the system looks and behaves consistently.

Key Concepts

Checkbox, Radio, and Switch — choosing the right control

These three controls all toggle boolean-ish state, which is exactly why they get confused for one another. The distinction is about the shape of the choice, not just visual style.

AspectCheckboxRadioSwitch
Selection modelIndependent, zero-to-many selections; or a single standalone booleanMutually exclusive, exactly one of a visible setIndependent, immediate on/off
Typical group size0 or more from a list2+ options, all visible at onceStandalone, one per setting
When the change takes effectUsually on form submitUsually on form submitImmediately, no submit step
Example”Select all that apply” list, a single “I agree to the terms""Shipping speed: Standard / Express / Overnight""Enable dark mode”, “Wi-Fi”
When NOT to useDon’t use for a single item where the state applies immediately (use Switch)Don’t use when only one option exists, or when the choice needs to change instantly (use Switch or Select if the list is long)Don’t use when the action needs confirmation, has a destructive side effect, or is part of a form that’s submitted as a batch (use Checkbox + Submit)

The rule of thumb: checkbox = “mark zero or more of these” or “confirm a single fact/agreement”; radio = “pick exactly one, and I want to see all the choices before picking”; switch = “flip a setting right now, no further action needed.”

The common mistake: switches that need a confirm step

A frequent misuse is putting a Switch on something that actually triggers a consequential, possibly irreversible action — “Enable two-factor authentication,” “Make this repository public,” “Auto-renew subscription.” A switch communicates “instant, low-stakes, reversible” purely through its interaction model (flip it, it’s done, flip it back if you change your mind). If the action is not actually like that — if it requires a follow-up dialog, sends an email, or can’t be trivially undone — using a Switch sets the wrong expectation and users will toggle it without realizing the weight of what just happened. In those cases, prefer a Checkbox inside a form with an explicit Save/Confirm button, or a Switch that immediately opens a confirmation dialog before committing the change (and reverts visually if the user cancels).

// Fine: instant, reversible, no side effects beyond a UI preference
<Switch
  checked={darkMode}
  onChange={setDarkMode}
  label="Dark mode"
/>

// Risky as a bare switch — consequential and hard to reverse
<Switch
  checked={isPublic}
  onChange={handleTogglePublic} // fires immediately, no confirmation
  label="Make repository public"
/>

// Better: switch opens a confirmation step before committing
<Switch
  checked={isPublic}
  onChange={(next) => next ? openConfirmDialog() : handleTogglePublic(false)}
  label="Make repository public"
/>

Checkbox and Radio API examples

// Checkbox group — zero or more selections
<CheckboxGroup label="Notify me about" name="notifications">
  <Checkbox value="comments" label="New comments" />
  <Checkbox value="mentions" label="Mentions" defaultChecked />
  <Checkbox value="digest" label="Weekly digest" />
</CheckboxGroup>

// Single standalone checkbox — a boolean agreement
<Checkbox
  checked={agreed}
  onChange={(e) => setAgreed(e.target.checked)}
  label="I agree to the Terms of Service"
  required
/>

// Radio group — exactly one of a visible set
<RadioGroup
  label="Shipping speed"
  name="shipping"
  value={shipping}
  onChange={setShipping}
>
  <Radio value="standard" label="Standard (5–7 days)" />
  <Radio value="express" label="Express (2–3 days)" />
  <Radio value="overnight" label="Overnight" />
</RadioGroup>

Note the API asymmetry: a CheckboxGroup is a convenience wrapper for layout and shared label/name, but each Checkbox’s checked state is independent — unchecking one doesn’t affect the others. A RadioGroup, by contrast, owns the single value/onChange for the whole group, because the options are mutually exclusive by definition — an individual Radio has no meaningful standalone checked state outside its group.

Select & Dropdown

Both let a user pick one value (or several) from a list, but they solve the problem very differently, and the choice between them is one of the more consequential accessibility decisions a design system makes.

Native <select>:

<label for="country">Country</label>
<select id="country" name="country">
  <option value="">Select a country</option>
  <option value="vn">Vietnam</option>
  <option value="us">United States</option>
  <option value="jp">Japan</option>
</select>

Custom Dropdown component:

<Dropdown
  label="Country"
  value={country}
  onChange={setCountry}
  options={[
    { value: 'vn', label: 'Vietnam', icon: <FlagVN /> },
    { value: 'us', label: 'United States', icon: <FlagUS /> },
    { value: 'jp', label: 'Japan', icon: <FlagJP /> },
  ]}
  placeholder="Select a country"
  searchable
/>
AspectNative <select>Custom Dropdown
AccessibilityFree — keyboard nav, screen reader announcements, type-ahead all built into the browser/OSMust be reimplemented by hand: arrow keys, Home/End, type-ahead, aria-expanded, aria-activedescendant or roving tabindex, focus trapping
Mobile behaviorOpens the OS’s native picker wheel/sheet — fast, familiar, thumb-friendlyRenders a custom overlay that must be manually tuned for touch, viewport clipping, and safe-area insets
Visual controlVery limited — can’t easily show icons, multi-line options, or rich formatting inside optionsFull control — icons, avatars, descriptions, grouped headings, custom option layout
Extra featuresNone natively — no search/filter, no multi-select without extra markup tricksCan add search-to-filter, multi-select with tags, async-loaded options, grouping
Implementation costEssentially zeroSignificant — a correct, accessible custom dropdown is one of the harder components to build from scratch
Best forSimple, short-to-medium lists where no special visuals are needed (country, state, plain enum values)Long lists needing search, options needing rich visuals, or multi-select with visible tags

The pragmatic default is to reach for the native <select> first and only build a custom Dropdown when a real requirement demands it (search, icons, multi-select) — and when that happens, the component must reimplement the ARIA combobox/listbox pattern faithfully, including keyboard support, rather than being a styled <div> that only works with a mouse.

Forms as composition

A form is not a component in itself so much as a layout and orchestration problem built on top of the primitives above. Two decisions recur in nearly every form:

Label placement

PatternProsConsTypical use
Label above fieldScans well, works at any field width, most accessible-by-default reading orderUses more vertical spaceDefault choice for most forms
Label beside field (left-aligned)Compact vertically, familiar in dense admin UIsRequires careful alignment across varying label lengths; harder to fit on narrow/mobile viewportsSettings pages, data-dense admin panels
Floating / inline labelVery compact, trendyAmbiguous once filled in, awkward with pre-filled or long values, extra motion can affect users with vestibular sensitivityMarketing forms, spatially constrained UI (use sparingly)

Error presentation: summary vs. inline

Most mature systems do both: an error summary at the top on submit (announced via role="alert" or moved focus into an aria-live region), plus inline errors on each affected field for context once the user reaches it.

Validation timing

TimingBehaviorTradeoff
On submit onlyErrors appear only after the user tries to submitLeast intrusive while typing, but can surprise the user with a wall of errors at the end
On blurField validates when the user leaves itGood middle ground — feedback per-field without interrupting typing
Live / on changeField validates on every keystrokeBest for immediate feedback like password strength or username availability, but can feel harsh if it shows “error” before the user has finished typing (e.g., flagging an email as invalid before they’ve typed the @)

A common, well-tested compromise: validate on blur for the first pass (so a field isn’t flagged while the user is still mid-typing), then switch to live validation for that specific field once it has already shown an error once — so the user gets instant confirmation that their correction fixed it, without being harassed before their first attempt is even finished.

Best Practices

Consistent prop naming across components

The single highest-leverage investment in a component library’s API is naming the same concept the same way in every component. If Button uses size="sm" | "md" | "lg", Input should use exactly that same set of values — not small/medium/large in one and sm/md/lg in the other. The same goes for:

PropMeaning everywhere it appearsAnti-pattern to avoid
sizeSame scale (sm/md/lg, or a numeric scale) on every sizeable componentButton using size, Input using inputSize
variantVisual/semantic style axisMixing variant and type for the same concept across components
disabledComponent cannot be interacted with, excluded from tab orderA component using isDisabled while siblings use disabled
errorValidation failure, usually accepts the error message itself or a booleanOne component taking a string, another taking only a boolean with no way to show message text
labelThe visible, associated text for the controlSome components requiring a separate aria-label with no visible label prop at all
onChangeFires with the new value (or event) whenever the value changesSome components firing onChange only on blur, others on every keystroke, with no way to tell from the API

A short, published table like this — sometimes literally called a “prop glossary” — belongs in the design system’s documentation (see Component Guidelines & Documentation Patterns if present in this knowledge base) so every contributor building a new component reuses the existing vocabulary instead of inventing a new one.

Controlled vs. uncontrolled components

Every input-like component in a design system needs a clear stance on state ownership:

// Controlled — parent drives the value
<Input value={email} onChange={(e) => setEmail(e.target.value)} />

// Uncontrolled — component owns its own state, parent reads it on submit
<Input defaultValue="" name="email" ref={emailRef} />
// later: emailRef.current.value, or via FormData(formEl) on submit

Most design systems support both for flexibility, exactly mirroring how native HTML form elements behave (<input value={x} onChange={...}> vs. <input defaultValue={x}>), and treat a component as “controlled” purely based on whether value is passed — the same convention React itself uses. What a design system should avoid is a component that’s controlled for reading (accepts value) but silently ignores updates to it (doesn’t call onChange, or doesn’t re-render when value changes from outside) — that’s the most common controlled-component bug and it makes the component impossible to reason about.

Other practical guidelines

References