Component cốt lõi: Nội dung & FeedbackCore Components: Content & Feedback
Thuộc bộ kiến thức Design System Roadmap.
Tổng quan
Nếu các component input và action (button, field, select) là cách người dùng nói cho hệ thống biết họ muốn gì, thì các component nội dung và feedback là cách hệ thống “nói lại”: hiển thị thông tin, gom nhóm thông tin, và cho người dùng biết điều gì vừa xảy ra sau một hành động. Bài này bao quát tám component xuất hiện ở hầu như mọi giao diện sản phẩm — Card, Modal, Tabs, Tooltip, Toast, Banner, Badge, Avatar, Carousel — cùng với List và Loading indicator như các pattern hỗ trợ.
Chủ đề xuyên suốt của tất cả các component này là sự tiết chế (restraint). Mỗi component đều rất dễ bị lạm dụng: card bọc lấy nội dung không cần container, modal ngắt ngang những luồng thao tác lẽ ra không cần ngắt, tabs che giấu nội dung mà người dùng thực sự cần thấy, tooltip chôn vùi thông tin lẽ ra phải hiển thị mặc định, và carousel xoay vòng nội dung mà chẳng ai cuộn lại để xem. Nhiệm vụ của một design system không chỉ là cung cấp các component này mà còn phải ghi rõ, khi nào không nên dùng chúng. Bài viết này ghép API của từng component với các quyết định phán đoán (judgment call) xoay quanh nó.
Kiến thức nền tảng
Sự phân chia nội dung vs. feedback
Việc tách tám component thành hai nhóm rất hữu ích, vì chúng giải quyết các vấn đề khác nhau và có các kiểu thất bại khác nhau.
| Nhóm | Component | Mục đích | Rủi ro chính |
|---|---|---|---|
| Tổ chức nội dung | Card, Tabs, List, Avatar, Carousel | Cấu trúc và gom nhóm thông tin để duyệt/so sánh | Che giấu hoặc phân mảnh nội dung người dùng cần |
| Feedback & disclosure | Modal, Tooltip, Toast, Banner, Badge, Loading indicator | Truyền đạt trạng thái, tình trạng, hoặc thông tin bổ sung | Ngắt ngang không cần thiết, hoặc truyền đạt thiếu tin cậy |
Các component nội dung trả lời câu hỏi “thông tin này được tổ chức thế nào?” Các component feedback trả lời câu hỏi “người dùng có biết điều gì đang đúng, hoặc điều gì vừa xảy ra không?” Cả hai nhóm đều chia sẻ một kỷ luật về API component: prop nên tối giản, composition (slot/children) nên gánh phần việc phức tạp, và hành vi (quản lý focus, dismiss, canh thời gian tự động) nên do chính component đảm nhiệm, chứ không phải để mỗi nơi sử dụng tự viết lại.
Slot có thể ghép (composable) vs. prop cấu hình
Một quyết định thiết kế lặp lại trong nhóm component này là có nên phơi bày các vùng của component dưới dạng children/slot (<Card><Card.Header/>...</Card>) hay dưới dạng prop (<Card title="..." footer={...}>). API dựa trên slot mở rộng tốt hơn khi nội dung phức tạp dần lên (một footer có ba nút và một checkbox thì khó nhét gọn vào một prop duy nhất), và cho phép nơi dùng bỏ qua hoặc sắp xếp lại các phần một cách tự nhiên. API dựa trên prop dễ khám phá và bị ràng buộc hơn, điều này hữu ích cho một component muốn được dùng nhất quán, ví dụ một stat card đơn giản. Hầu hết các design system hỗ trợ cả hai: một dạng prop rút gọn ít nghi thức cho trường hợp thông thường, và một composition dựa trên slot cho bất cứ thứ gì phức tạp hơn.
Khái niệm chính
Card: container có thể ghép (composable)
Card là một container có ranh giới, tách biệt về mặt thị giác, dùng để gom nhóm một tập hợp các mảnh nội dung liên quan — thường là tiêu đề, nội dung chính, media tùy chọn, và hành động — thành một đơn vị dễ quét mắt, thường là một trong nhiều đơn vị tương tự trong một lưới hoặc danh sách.
Cấu trúc giải phẫu kinh điển là composition header/body/footer:
<Card>
<Card.Header>
<Avatar src={user.avatarUrl} name={user.name} size="sm" />
<Card.Title>{project.name}</Card.Title>
<Badge tone="success">Active</Badge>
</Card.Header>
<Card.Body>
<p>{project.description}</p>
</Card.Body>
<Card.Footer>
<Button variant="tertiary">View details</Button>
<Button variant="primary">Open project</Button>
</Card.Footer>
</Card>
Mỗi slot là một component độc lập, tùy chọn: một card không có footer thì chỉ render header và body. Bên dưới, Card thường chỉ cung cấp bề mặt bên ngoài (border, radius, shadow, nhịp padding) và để Card.Header, Card.Body, Card.Footer xử lý khoảng cách nội bộ và đường phân chia (border-divider), để nơi dùng không phải tự nghĩ lại quyết định padding 16px-hay-24px ở mỗi màn hình.
Khi nào Card là container đúng, và khi nào không. Card xứng đáng có border và shadow khi nó đại diện cho một mục trong nhiều đơn vị lặp lại, có thể so sánh — một sản phẩm trong lưới, một thành viên trong danh bạ nhóm, một gói trong bảng giá. Ranh giới thị giác nói cho người dùng biết “cụm nội dung này là một thứ, và có nhiều thứ tương tự ở gần đó.” Card là lựa chọn sai khi nó được dùng như một wrapper chung cho một khối nội dung duy nhất trên một trang bình thường khác — một section cài đặt, một form đơn lẻ, khối nội dung duy nhất của một trang. Trong tình huống đó, một <section> đơn giản với tiêu đề và nhịp dọc thông thường sẽ đọc như một phần của trang, trong khi một card không cần thiết tạo ra hiệu ứng “hộp trong hộp” thị giác, cạnh tranh sự chú ý với phần khung trang và thêm lồng ghép mà không có lợi ích so sánh nào. Một heuristic hữu ích: nếu bạn không thể tưởng tượng ra một card thứ hai, tương tự, đặt cạnh card này, thì đây có lẽ không phải card — mà là một section.
Modal (dialog): chặn người dùng một cách có chủ đích
Modal (hay dialog) là một lớp phủ (overlay) chiếm toàn bộ focus của người dùng và chặn tương tác với phần còn lại của trang cho đến khi nó được xử lý xong hoặc đóng lại. Modal phù hợp khi tác vụ ngắn, tự khép kín, đòi hỏi toàn bộ sự chú ý của người dùng, và đưa họ trở lại đúng chỗ họ đang dừng — xác nhận một hành động phá hủy (destructive), sửa nhanh một bản ghi ngay tại chỗ, hoặc thu thập một mẩu thông tin bắt buộc nhỏ trước khi tiếp tục.
Modal chia thành hai nhóm hành vi mà một design system cần phân biệt rõ ràng:
| Loại | Chặn tương tác nền | Đóng bằng click ra ngoài / Esc | Trường hợp dùng điển hình |
|---|---|---|---|
| Dialog chặn (modal) | Có | Có thể cấu hình, thường bị tắt với xác nhận phá hủy | Xác nhận xóa, bước thiết lập bắt buộc, cảnh báo nghiêm trọng |
| Overlay không chặn (popover, drawer, panel không modal) | Không | Có, tự do | Menu ngữ cảnh, panel lọc, panel chi tiết bổ sung |
Một modal thực sự phải trap focus: Tab và Shift+Tab chỉ xoay vòng giữa các phần tử có thể focus bên trong dialog, focus chuyển vào dialog (thường là tiêu đề hoặc control có thể focus đầu tiên) khi mở, và trả lại phần tử đã kích hoạt (trigger) khi đóng. Đây không phải là sự trau chuốt tùy chọn — nếu thiếu focus trap, người dùng bàn phím và trình đọc màn hình có thể Tab “xuyên qua” modal vào một trang nền mà họ không nhìn thấy, gây rối và, ở mức tệ nhất, là vi phạm khả năng tiếp cận nghiêm trọng (xem 07 — Khả năng tiếp cận và Thiết kế bao trùm). Trang bên dưới cũng nên được đặt aria-hidden (hoặc inert), và cuộn trang nên bị khóa.
Một API component đại diện:
<Modal
open={isOpen}
onClose={() => setIsOpen(false)}
size="md" // sm | md | lg | fullscreen
closeOnOverlayClick={false} // thường false với xác nhận phá hủy
closeOnEsc={true}
initialFocusRef={confirmButtonRef}
labelledBy="delete-project-title"
>
<Modal.Header>
<Modal.Title id="delete-project-title">Delete project?</Modal.Title>
<Modal.CloseButton aria-label="Close dialog" />
</Modal.Header>
<Modal.Body>
<p>
This will permanently delete <strong>{project.name}</strong> and all of
its data. This action cannot be undone.
</p>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button
variant="danger"
ref={confirmButtonRef}
onClick={handleDelete}
>
Delete project
</Button>
</Modal.Footer>
</Modal>
Hãy chú ý bề mặt API: open/onClose biến nó thành controlled component (component cha sở hữu state, modal sở hữu cơ chế DOM/portal/focus), labelledBy đấu nối aria-labelledby cho trình đọc màn hình, và initialFocusRef cho phép nơi dùng chọn phần tử nhận focus đầu tiên (thường không phải nút phá hủy, để tránh việc lỡ tay Enter hai lần dẫn tới xóa nhầm).
Khi nào Modal bị lạm dụng. Modal là một trong những component bị chỉ định quá mức trong thiết kế phần mềm, vì nó rất dễ dùng: bất kỳ team nào cũng có thể gắn một luồng mới vào một trang có sẵn bằng cách bọc nó trong modal, mà không cần đụng đến navigation hay kiến trúc thông tin. Cái giá phải trả là người dùng gánh chịu. Các dấu hiệu cảnh báo cho thấy modal là lựa chọn sai:
- Nội dung bên trong thực sự cần một URL riêng — một view chi tiết, một wizard nhiều bước, hoặc bất cứ thứ gì người dùng có thể muốn bookmark, chia sẻ, hoặc quay lại bằng nút back/forward — là dấu hiệu mạnh cho thấy nó nên là một trang riêng thay vì modal.
- “Modal” đang được dùng để hiện chi tiết bổ sung song song với nội dung khác đang hiển thị (ví dụ “xem chi tiết” mà lẽ ra có thể mở rộng ngay tại chỗ hoặc mở một panel bên) — mở rộng inline hoặc drawer giữ được ngữ cảnh hiển thị và không chặn tương tác.
- Modal lồng trong modal, hoặc một modal được mở từ hộp xác nhận của một modal khác, gần như luôn là dấu hiệu luồng thao tác cần được tái cấu trúc, chứ không phải cần thêm lớp overlay.
- Modal được dùng thuần túy để “khiến người dùng để ý điều gì đó” — đó là việc của Banner hoặc Toast, không phải một overlay chặn tương tác.
Một quy tắc ngón tay cái hữu ích cho trang hướng dẫn của design system: mặc định dùng inline hoặc trang riêng; phải “kiếm được” quyền dùng modal. Modal nên được biện minh bởi nhu cầu thực sự cần chặn, chứ không phải vì tiện lợi khi triển khai.
Tabs: gom nhóm để so sánh, không phải để che giấu
Tabs cho phép người dùng chuyển đổi giữa nhiều view chiếm cùng một vùng màn hình, hiển thị một panel tại một thời điểm dưới một hàng nhãn trigger. Tabs hoạt động tốt khi các panel đại diện cho các lát cắt song song, có thể so sánh của cùng một chủ đề, mà người dùng duyệt có chọn lọc và không cần xem đồng thời — “Overview / Specifications / Reviews” trên trang sản phẩm, hoặc “Day / Week / Month” cho view lịch. Mô hình tinh thần của người dùng là “đây là các view thay thế cho nhau của một thứ; tôi sẽ xem cái tôi cần.”
Tabs chủ động gây hại cho khả năng sử dụng khi chúng được dùng để che giấu nội dung mà người dùng cần thấy mặc định, thay vì cung cấp các view thay thế tùy chọn. Hai kiểu thất bại phổ biến:
- Chôn vùi một bước quan trọng, chỉ cần làm một lần. Một trang cấu hình với “Basic Settings” và “Advanced Settings” dưới dạng tabs thường che giấu các trường bắt buộc mà người dùng cần ở lần truy cập đầu tiên, chỉ vì team muốn view mặc định ngắn hơn. Nếu hầu hết người dùng đều phải vào mọi tab, thì tabs đang cộng thêm một cú click và chi phí khám phá, chứ không tiết kiệm không gian — một trang có thể cuộn duy nhất với disclosure inline tùy chọn “Show advanced options” thường phục vụ tốt hơn.
- Tách rời nội dung cần được xem cùng nhau. Nếu người dùng thường xuyên cần đối chiếu thông tin giữa hai “tabs” (ví dụ so sánh giá cả trong khi đọc chi tiết tính năng), việc tách chúng ra bằng tabs buộc phải ghi nhớ hoặc chuyển đổi lặp đi lặp lại mà một layout song song hoặc một trang duy nhất sẽ tránh được.
Một phép thử hợp lý trước khi mặc định dùng tabs: hầu hết người dùng, hầu hết thời gian, có cần xem nhiều hơn một panel để hoàn thành tác vụ không? Nếu có, tabs đang che giấu thứ họ cần — hãy dùng section, accordion, hoặc một trang duy nhất thay thế. Tabs dành cho các view song song, không phải để nén một đống cài đặt không liên quan vào ít pixel hiển thị hơn.
Tooltip: chỉ bổ sung, không bao giờ quan trọng
Tooltip là một nhãn nổi nhỏ xuất hiện khi hover hoặc focus bàn phím, cung cấp thông tin bổ sung ngắn gọn về phần tử mà nó gắn vào — làm rõ một nút chỉ có icon, hiện toàn bộ văn bản của một giá trị bị cắt ngắn, hoặc đưa ra một gợi ý một dòng. Tooltip được kích hoạt bởi :hover và :focus-visible, không bao giờ chỉ bằng click (một “tooltip” kích hoạt bằng click thực chất là popover), và nó phải biến mất khi blur/rời chuột hoặc nhấn Escape.
Quy tắc cứng mà design system phải áp đặt: tooltip không bao giờ được là nơi duy nhất chứa thông tin bắt buộc hoặc quan trọng. Tooltip thất bại về khả năng khám phá theo nhiều cách cụ thể:
- Chúng đòi hỏi một cú hover chính xác hoặc một điểm dừng Tab có chủ đích để hiện ra — không có gì thúc đẩy người dùng chủ động tìm kiếm chúng, nên thông tin “giấu” trong tooltip thường đơn giản là không bao giờ được nhìn thấy.
- Trên thiết bị cảm ứng, hoàn toàn không có trạng thái hover; nhiều giao diện cảm ứng không bao giờ hiện nội dung tooltip, hoặc chỉ hiện sau một thao tác nhấn giữ (long-press) mà người dùng không trực giác thử.
- Hành vi của trình đọc màn hình với tooltip không nhất quán giữa các công nghệ hỗ trợ trừ khi tooltip được đấu nối đúng với
aria-describedby, và ngay cả khi đó, nội dung tooltip vẫn dễ bị bỏ sót trong một lượt đọc tuyến tính. - Tooltip biến mất ngay khi focus di chuyển, nên không thể tham chiếu lại trong lúc điền chính trường mà nó đang giải thích.
Về mặt thực tiễn, điều này có nghĩa là: yêu cầu của trường form (“phải có ít nhất 8 ký tự”), trạng thái lỗi, và bất cứ thứ gì người dùng bắt buộc phải hành động phải thuộc về help text hiển thị hoặc thông báo validation inline — không bao giờ chỉ nằm trong tooltip. Tooltip dành cho câu bổ sung thực sự tùy chọn: một từ viết tắt là gì, một nút bị vô hiệu hóa sẽ làm gì nếu được kích hoạt, giá trị số chính xác đằng sau một hiển thị đã định dạng. Xem thêm 04 — Component cốt lõi: Input & Action để biết cách help text inline và thông báo validation bao phủ những trường hợp mà tooltip không nên đảm nhận.
Toast và Banner: feedback tạm thời vs. dai dẳng
Toast và Banner đều được dùng để cho người dùng biết điều gì đã xảy ra hoặc đang đúng, nhưng chúng khác nhau ở trục quan trọng nhất: nó có tự biến mất không, và nó có nên tự biến mất không?
Toast là một thông báo tạm thời, không chặn, báo cáo điều vừa xảy ra — một lần lưu hoàn tất, một file được tải lên, một mục bị xóa — và tự động biến mất sau vài giây, thường từ một góc màn hình, mà không yêu cầu người dùng phải hành động. Đó là feedback về một sự kiện đã hoàn tất, không phải một tình trạng đang diễn ra.
// Toast API mệnh lệnh (imperative), thường được hậu thuẫn bởi một toast manager/provider
toast.success("Project saved");
toast.error("Failed to save project. Try again.");
toast.warning("Your session will expire in 5 minutes.");
toast.info("New comments were added while you were away.");
// Đầy đủ tùy chọn
toast.show({
variant: "success", // success | error | warning | info
title: "Project saved",
description: "All changes were saved to the cloud.",
duration: 4000, // ms; error/warning thường dùng thời lượng dài hơn hoặc 0 (dính lại)
action: {
label: "Undo",
onClick: handleUndo,
},
onDismiss: () => trackEvent("toast_dismissed"),
});
Các variant của toast thường khác nhau về icon, màu sắc, và thời lượng mặc định — error và warning thường được đặt thời lượng dài hơn hoặc duration: 0 (yêu cầu đóng thủ công) vì chúng mang hệ quả nặng hơn một xác nhận thành công thông thường. Bên dưới, một hệ thống toast cần vài thứ mà một component đơn lẻ không thể tự cung cấp: một portal/stacking manager để nhiều toast xếp hàng chờ thay vì chồng lên nhau, một vùng aria-live="polite" (hoặc assertive cho lỗi) để trình đọc màn hình thông báo toast mới mà không cướp focus, và tạm dừng khi hover để toast không biến mất trong khi người dùng vẫn đang đọc.
Banner là một thông báo dai dẳng, thường chiếm toàn bộ chiều rộng, truyền đạt một tình trạng đang diễn ra mà người dùng nên biết trong suốt thời gian nó còn đúng — một vấn đề thanh toán, một cửa sổ bảo trì đã lên lịch, một dịch vụ đang suy giảm chất lượng, một cảnh báo có thay đổi chưa lưu. Banner không tự đóng theo hẹn giờ; nó tồn tại cho đến khi tình trạng nền được giải quyết (thanh toán đã được sửa, bảo trì đã kết thúc) hoặc người dùng chủ động đóng nó. Banner thường được đặt inline ở đầu một trang hoặc section, không phải overlay, và không chặn tương tác với phần còn lại của trang.
<Banner
variant="warning" // info | success | warning | critical
dismissible
onDismiss={() => setBannerDismissed(true)}
action={{ label: "Update payment method", onClick: goToBilling }}
>
Your payment method expired. Update it to avoid a service interruption.
</Banner>
Sự phân biệt này quan trọng vì dùng sai loại sẽ tạo ra vấn đề thực sự: một toast cho một vấn đề thanh toán đang diễn ra sẽ biến mất sau bốn giây và người dùng có thể không bao giờ thấy lại, trong khi một banner cho một sự kiện “đã lưu thành công” chỉ xảy ra một lần sẽ nằm lại vô ích và làm rối trang sau khi thông tin đó không còn mới nữa.
Badge: một dấu hiệu inline nhỏ, không phải một thông điệp
Badge là một chỉ báo thị giác nhỏ gọn — thường là một chấm màu, một nhãn ngắn, hoặc một con số — gắn inline vào một phần tử khác để hiển thị trạng thái hoặc số lượng: một badge trạng thái “Active” bên cạnh tên, một số lượng thông báo trên icon chuông, một nhãn “Beta” bên cạnh tên tính năng.
<Badge tone="success">Active</Badge>
<Badge tone="neutral">Draft</Badge>
<Badge tone="critical" count={3} />
<Badge tone="info" variant="outline">Beta</Badge>
Sự phân biệt giữa Badge và Banner thực chất là sự phân biệt về quy mô và vai trò: badge là một dấu hiệu inline nhỏ gắn vào một phần UI cụ thể (một tên, một icon, một hàng) và không mang thông điệp độc lập của riêng nó — nó là metadata về thứ nó gắn vào. Banner là một thông điệp độc lập, chiếm toàn bộ chiều rộng, về trang hoặc tài khoản nói chung, với văn bản riêng và thường có lời kêu gọi hành động riêng. Nhầm lẫn hai thứ này tạo ra những sai lầm UI dễ đoán: nhồi nhét một câu giải thích vào thứ lẽ ra chỉ nên là một badge một từ, hoặc xây dựng cả một banner để hiển thị thứ đơn giản như một con số mà một badge nhỏ trên icon sẽ truyền đạt hiệu quả hơn.
Avatar: đại diện cho người dùng hoặc thực thể
Avatar hiển thị một đại diện thị giác gọn gàng cho một người hoặc thực thể — thường là ảnh, nhưng phải rơi về (fall back) một cách đáng tin cậy khi không có ảnh. Chuỗi fallback quan trọng hơn cả trường hợp lý tưởng, vì avatar được render hàng loạt (danh sách thành viên, luồng bình luận, bộ chọn người phụ trách) nơi ảnh hỏng hoặc dữ liệu thiếu là chuyện thường gặp:
- Ảnh — ảnh/logo thực tế của người dùng hoặc thực thể, nếu có và tải thành công.
- Chữ cái đầu (initials) — một hoặc hai chữ cái rút ra từ tên (ví dụ “Nam Nguyen” → “NN”), hiển thị trên một màu nền xác định (deterministic) rút ra từ tên/ID, để cùng một người luôn có một màu ổn định, dễ nhận diện qua các phiên.
- Icon chung — hình bóng người/tòa nhà giữ chỗ, dùng khi thậm chí không có tên để rút ra chữ cái đầu (một thực thể ẩn danh hoặc chưa tải xong).
<Avatar src={user.avatarUrl} name={user.name} size="md" />
// Render: ảnh nếu `src` tải được, nếu không thì chữ cái đầu từ `name`, nếu không nữa thì icon chung
<Avatar name="Nam Nguyen" size="sm" /> {/* → "NN" trên một màu ổn định */}
<Avatar size="lg" /> {/* → icon chung */}
<AvatarGroup max={4}>
{teamMembers.map((m) => (
<Avatar key={m.id} src={m.avatarUrl} name={m.name} />
))}
</AvatarGroup>
Sự nhất quán về kích thước là trách nhiệm khác của design system ở đây: một avatar render ở 24px trong danh sách bình luận và 96px trên trang profile phải dùng cùng một thang kích thước cố định (ví dụ xs/sm/md/lg/xl ánh xạ tới các giá trị pixel cố định) để cỡ chữ initials, border radius, và tỷ lệ icon fallback cùng co giãn theo nhau thay vì bị tính toán lại tùy tiện ở mỗi màn hình. Không có thang kích thước chung, các team thường có xu hướng phát minh ra các kích thước avatar hơi khác nhau xuyên suốt sản phẩm, đó là một hình thức thiếu nhất quán nhỏ nhưng rất dễ nhận thấy.
Carousel: một trường hợp sử dụng thực sự nhưng hẹp
Carousel (hay slider) xoay vòng qua một tập hợp các mục — thường là ảnh hoặc thẻ quảng cáo — một hoặc vài mục mỗi lần, hoặc tự động hoặc thông qua control next/previous do người dùng điều khiển. Carousel là một trong những component gây tranh cãi nhất trong thiết kế UI, và tranh cãi đó không phải không có căn cứ: các nghiên cứu từ hơn một thập kỷ trước (dữ liệu click carousel của Erik Runyon, các bài viết của Jakob Nielsen về chủ đề này) liên tục cho thấy carousel tự động xoay nhận phần lớn áp đảo lượt click ở panel đầu tiên, với mức độ tương tác giảm mạnh ở panel thứ hai trở đi — người dùng hiếm khi chờ đợi hoặc chủ động chuyển sang các slide sau.
Tuy nhiên, dữ liệu đó ủng hộ một kết luận hẹp hơn là “không bao giờ dùng carousel.” Các vấn đề thực sự cụ thể và có thể khắc phục:
- Tự động chuyển slide là thủ phạm chính. Một carousel thay đổi nội dung ngay dưới người dùng đang đọc dở — đặc biệt trước khi họ kịp đọc xong panel đầu tiên — gây hại thực sự cho khả năng hiểu và là một vấn đề khả năng tiếp cận đã biết (WCAG 2.2.2 yêu cầu phải có cách để tạm dừng, dừng, hoặc ẩn nội dung tự cập nhật).
- Dùng carousel cho nội dung có tầm quan trọng không đồng đều — chôn một thông báo quan trọng ở vị trí “slide 3 trong 5” — gần như đảm bảo hầu hết người dùng không bao giờ thấy nó, vì hầu hết không bao giờ chuyển qua khỏi panel đầu tiên.
- Triển khai khả năng tiếp cận kém — không có control bàn phím cho next/previous, không có nút tạm dừng hiển thị, không có tên có thể tiếp cận cho slide hiện tại, focus không được quản lý — là những vấn đề phổ biến biến một pattern vốn đã yếu thành một thứ thực sự hỏng đối với người dùng bàn phím và trình đọc màn hình.
Nơi carousel thực sự hiệu quả: một thư viện ảnh sản phẩm mà người mua đã có động lực sẵn để duyệt (nhiều góc chụp của một sản phẩm họ đang cân nhắc mua), một tập hợp lời chứng thực hoặc case study được điều khiển thủ công và trình bày như các mục ngang hàng về tầm quan trọng (không phải một thông điệp quan trọng duy nhất bị chôn giữa các mục phụ), hoặc bất kỳ trường hợp nào người dùng chủ động chọn duyệt thay vì bị bắt xem nội dung họ cần chú ý. Trong các trường hợp này, carousel nên là thủ công hoặc có thể tạm dừng, điều hướng được bằng bàn phím, và phơi bày vị trí hiện tại (ví dụ “3 trong 6”) cho công nghệ hỗ trợ.
Hướng dẫn ở cấp độ design system đáng để ghi lại là: không bao giờ tự động chuyển slide mà không có control tạm dừng có thể tiếp cận, không bao giờ đặt thông điệp quan trọng nhất duy nhất của bạn ở một vị trí xoay vòng, và ưu tiên một carousel được điều khiển thủ công hoặc một lưới/danh sách đơn giản hơn một carousel tự động xoay bất cứ khi nào nội dung không phải là thứ người dùng đã có sẵn động lực để khám phá.
List và Loading indicator
List render một tập hợp các mục đồng nhất — các hàng kết quả tìm kiếm, một danh sách cài đặt, một luồng thông báo — và giải phẫu của một item thường là một cấu trúc slot có thể đoán trước: một hình ảnh dẫn đầu tùy chọn (icon, avatar, checkbox), một nhãn chính, một dòng phụ/meta tùy chọn, và một phần tử theo sau tùy chọn (một giá trị, một mũi tên chevron, một nút hành động).
<List>
<List.Item
leading={<Avatar name={member.name} src={member.avatarUrl} size="sm" />}
title={member.name}
subtitle={member.role}
trailing={<Badge tone="success">Active</Badge>}
onClick={() => openMember(member.id)}
/>
</List>
Loading indicator truyền đạt rằng nội dung đang trên đường tới, và lựa chọn giữa spinner và skeleton screen là một quyết định UX thực sự, không chỉ là chuyện phong cách. Spinner truyền đạt “chờ đi” mà không có thông tin gì về thứ sắp tới, và gây ra một cú giật layout (layout jump) ngay khi nội dung thật thay thế nó. Skeleton screen — các hình khối placeholder màu xám khớp với layout cuối cùng (một hình chữ nhật ở nơi avatar sẽ xuất hiện, các thanh ở nơi văn bản sẽ xuất hiện) — giảm thời gian chờ cảm nhận được bằng cách cho người dùng thứ gì đó có cấu trúc thông tin để nhìn ngay lập tức, và loại bỏ layout shift vì nội dung thật được gắn vào đúng không gian mà skeleton đã chiếm sẵn. Quy tắc ngón tay cái: dùng skeleton screen cho nội dung có cấu trúc, dự đoán được (một lưới card, một danh sách, một trang profile) nơi layout cuối cùng đã biết trước, và dành spinner cho các khoảng chờ ngắn, không có cấu trúc (trạng thái đang xử lý của một nút, một hành động inline nhỏ) nơi xây dựng skeleton không đáng công sức.
Bảng so sánh: Toast vs. Banner vs. Modal
| Khía cạnh | Toast | Banner | Modal |
|---|---|---|---|
| Mức độ khẩn cấp | Thấp–trung bình (xác nhận thông thường, lỗi nhỏ) | Trung bình–cao (tình trạng đang diễn ra đáng được chú ý liên tục) | Cao (đòi hỏi một quyết định hoặc xác nhận trước khi tiếp tục) |
| Độ dai dẳng | Tạm thời, tự đóng (thường 3–6 giây) | Dai dẳng cho đến khi được giải quyết hoặc đóng thủ công | Dai dẳng cho đến khi người dùng hoàn thành hoặc hủy tương tác |
| Chặn tương tác | Không bao giờ chặn trang | Không bao giờ chặn trang | Chặn mọi tương tác với phần còn lại của trang |
| Vị trí | Overlay ở góc, xếp chồng với các toast khác | Inline, chiếm toàn bộ chiều rộng, đầu trang/section | Overlay căn giữa với lớp scrim |
| Trường hợp dùng đúng | ”Your changes were saved" | "Your trial ends in 3 days" | "Are you sure you want to delete this?” |
Best Practices
- Coi mỗi component trong nhóm này là sự leo thang tùy chọn (opt-in escalation): mặc định chọn phương án ít gây gián đoạn nhất (văn bản inline, một section, một banner) và chỉ leo thang lên phương án gây gián đoạn hơn (một modal, một carousel tự động chuyển) khi nội dung thực sự đòi hỏi toàn bộ sự chú ý, chặn tương tác của người dùng.
- Để các component Modal, Toast, và Banner tự sở hữu cơ chế khả năng tiếp cận của riêng chúng — focus trap và khôi phục, vùng
aria-live,role="alertdialog"so vớirole="dialog"— để từng team không bao giờ phải tự triển khai lại đúng (hoặc sai) cho mỗi tính năng. Xem 07 — Khả năng tiếp cận và Thiết kế bao trùm. - Chuẩn hóa một thang kích thước cố định cho Avatar (và tái sử dụng nó cho kích thước chấm Badge và icon dẫn đầu của List) để mật độ thị giác giữ nhất quán xuyên suốt các tính năng do các team khác nhau xây dựng.
- Ghi lại từ vựng tone/variant (
success | error | warning | info, hoặcinfo | success | warning | critical) một lần duy nhất, tập trung, và tái sử dụng cùng từ vựng đó xuyên suốt Toast, Banner, và Badge — một trang hướng dẫn component nên ánh xạ mỗi tone tới một ý nghĩa cụ thể để “warning” có cùng nghĩa ở mọi nơi. Xem 06 — Hướng dẫn Component và các mẫu tài liệu. - Giới hạn số lượng toast đồng thời (ví dụ tối đa 3 toast xếp chồng, với các toast cũ hơn bị đẩy ra hoặc xếp hàng chờ) để một loạt sự kiện bất đồng bộ không tạo ra một chồng toast không thể đọc được.
- Với bất kỳ component nào che giấu nội dung theo mặc định (Tabs, Accordion, Tooltip), hãy chủ động hỏi trong buổi review thiết kế: “người dùng có cần thấy điều này mà không phải thực hiện thêm một hành động không?” Nếu có, nó không thuộc về phía sau pattern disclosure đó.
- Đừng bao giờ để một carousel hoặc toast là cơ chế truyền tải duy nhất cho thông tin mà người dùng bắt buộc phải hành động — hãy đi kèm các thông điệp quan trọng với một banner dai dẳng hoặc một phần tử ở cấp độ trang.
- Cung cấp cả bề mặt API dựa trên slot và dựa trên prop cho Card khi khả thi, để các trường hợp đơn giản vẫn ngắn gọn trong khi các trường hợp phức tạp vẫn có thể ghép được.
Tài liệu tham khảo
- roadmap.sh — Design System
- Nielsen Norman Group — Modal & Nonmodal Dialogs
- Nielsen Norman Group — Auto-Forwarding Carousels and Accordions Are Bad for Usability
- W3C ARIA Authoring Practices Guide — Dialog (Modal) Pattern
- W3C ARIA Authoring Practices Guide — Tabs Pattern
- W3C WCAG 2.2 — Success Criterion 2.2.2 Pause, Stop, Hide
- Shopify Polaris — Toast, Banner, and Modal components
- Atlassian Design System — Tooltip
Part of the Design System Roadmap knowledge base.
Overview
If input and action components (buttons, fields, selects) are how users tell the system what they want, content and feedback components are how the system talks back: they display information, group it, and tell the user what happened as a result of an action. This topic covers eight components that show up in almost every product interface — Card, Modal, Tabs, Tooltip, Toast, Banner, Badge, Avatar, Carousel — plus List and Loading indicators as supporting patterns.
The unifying theme across all of them is restraint. Every one of these components is easy to overuse: cards wrap content that didn’t need a container, modals interrupt flows that didn’t need interrupting, tabs hide content the user actually needed to see, tooltips bury information that should have been visible by default, and carousels rotate content nobody scrolls back to see. A design system’s job is not just to provide these components but to document, clearly, when not to reach for them. This note pairs each component’s API with the judgment calls around it.
Fundamentals
The content vs. feedback split
It helps to separate the eight components into two families, because they solve different problems and have different failure modes.
| Family | Components | Purpose | Primary risk |
|---|---|---|---|
| Content organization | Card, Tabs, List, Avatar, Carousel | Structure and group information for browsing/comparison | Hiding or fragmenting content the user needed |
| Feedback & disclosure | Modal, Tooltip, Toast, Banner, Badge, Loading indicator | Communicate state, status, or supplementary info | Interrupting unnecessarily, or communicating unreliably |
Content components answer “how is this information organized?” Feedback components answer “does the user know what’s currently true, or what just happened?” Both families share a component-API discipline: props should be minimal, composition (slots/children) should do the heavy lifting, and behavior (focus management, dismissal, auto-timing) should be owned by the component, not reimplemented by every consumer.
Composable slots vs. configuration props
A recurring design decision in this component set is whether to expose a component’s regions as children/slots (<Card><Card.Header/>...</Card>) or as props (<Card title="..." footer={...}>). Slot-based APIs scale better as content grows more complex (a footer with three buttons and a checkbox doesn’t fit comfortably in a single prop) and they let consumers omit or reorder sections naturally. Prop-based APIs are more discoverable and constrained, which is useful for a component you want used consistently, like a simple stat card. Most design systems support both: a low-ceremony prop shorthand for the common case, and a slot-based composition for anything more complex.
Key Concepts
Card: the composable container
A Card is a bounded, visually distinct container used to group a set of related pieces of content — typically a heading, body content, optional media, and actions — as a single scannable unit, usually one of several similar units in a grid or list.
The canonical anatomy is a header/body/footer composition:
<Card>
<Card.Header>
<Avatar src={user.avatarUrl} name={user.name} size="sm" />
<Card.Title>{project.name}</Card.Title>
<Badge tone="success">Active</Badge>
</Card.Header>
<Card.Body>
<p>{project.description}</p>
</Card.Body>
<Card.Footer>
<Button variant="tertiary">View details</Button>
<Button variant="primary">Open project</Button>
</Card.Footer>
</Card>
Each slot is an independent, optional component: a card with no footer just renders header and body. Under the hood, Card typically supplies only the outer surface (border, radius, shadow, padding rhythm) and lets Card.Header, Card.Body, and Card.Footer handle internal spacing and border-dividers, so consumers don’t have to reinvent the 16px-vs-24px padding decision on every screen.
When a card is the right container, and when it isn’t. A card earns its border and shadow when it represents one item among several repeated, comparable units — a product in a grid, a team member in a directory, a plan in a pricing table. The visual boundary tells the user “this cluster of content is one thing, and there are more like it nearby.” A card is the wrong choice when it’s used as a generic wrapper for a single block of content on an otherwise plain page — a settings section, a single form, a page’s only content block. In that situation a plain <section> with a heading and normal vertical rhythm reads as part of the page, while an unnecessary card adds a visual “box within a box” that competes with the page chrome for attention and adds nesting with no comparative benefit. A useful heuristic: if you can’t imagine a second, similar card next to this one, it’s probably not a card — it’s a section.
Modal (dialog): blocking the user on purpose
A Modal (or dialog) is an overlay that captures full user focus and blocks interaction with the rest of the page until it is resolved or dismissed. Modals are appropriate when the task is short, self-contained, requires the user’s full attention, and returns them to exactly where they left off — confirming a destructive action, editing a single record inline, or capturing a small piece of required information before continuing.
Modals fall into two behavioral categories that a design system must clearly distinguish:
| Type | Blocks background interaction | Dismiss via overlay click / Esc | Typical use |
|---|---|---|---|
| Blocking (modal) dialog | Yes | Configurable, often disabled for destructive confirmations | Confirm delete, required setup step, critical alert |
| Non-blocking overlay (popover, drawer, non-modal panel) | No | Yes, freely | Contextual menu, filter panel, supplementary details panel |
A true modal must trap focus: Tab and Shift+Tab cycle only among focusable elements inside the dialog, focus moves to the dialog (typically its heading or first focusable control) on open, and returns to the trigger element on close. This is not optional polish — without a focus trap, keyboard and screen-reader users can tab “through” the modal into a background page they can’t see, which is disorienting at best and a hard accessibility violation at worst (see 07 — Accessibility and Inclusive Design). The underlying page should also get aria-hidden (or be inert) and scroll should be locked.
A representative component API:
<Modal
open={isOpen}
onClose={() => setIsOpen(false)}
size="md" // sm | md | lg | fullscreen
closeOnOverlayClick={false} // often false for destructive confirmations
closeOnEsc={true}
initialFocusRef={confirmButtonRef}
labelledBy="delete-project-title"
>
<Modal.Header>
<Modal.Title id="delete-project-title">Delete project?</Modal.Title>
<Modal.CloseButton aria-label="Close dialog" />
</Modal.Header>
<Modal.Body>
<p>
This will permanently delete <strong>{project.name}</strong> and all of
its data. This action cannot be undone.
</p>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button
variant="danger"
ref={confirmButtonRef}
onClick={handleDelete}
>
Delete project
</Button>
</Modal.Footer>
</Modal>
Notice the API surface: open/onClose make it a controlled component (the parent owns the state, the modal owns the DOM/portal/focus mechanics), labelledBy wires up aria-labelledby for screen readers, and initialFocusRef lets the consumer choose what receives focus first (often not the destructive button, to avoid an accidental double-Enter delete).
When a modal is overused. Modals are one of the most over-prescribed components in software design because they are easy to reach for: any team can bolt a new flow onto an existing page by wrapping it in a modal, without touching navigation or information architecture. The cost is paid by the user. Warning signs that a modal is the wrong choice:
- The content inside genuinely needs its own URL — a detail view, a multi-step wizard, or anything a user might want to bookmark, share, or reach via back/forward navigation — is a strong sign it should be a dedicated page instead.
- The “modal” is being used to show supplementary detail alongside other visible content (e.g., “view details” that could instead expand inline or open a side panel) — an inline expansion or a drawer keeps context visible and is non-blocking.
- Modals nested inside modals, or a modal opened from another modal’s confirmation, is almost always a sign the flow needs restructuring, not more overlay layers.
- The modal is used purely to “make the user notice something” — that’s the job of a Banner or Toast, not a blocking overlay.
A helpful rule of thumb for a design system’s guidelines page: default to inline or a dedicated page; earn the modal. A modal should be justified by a real need to block, not by implementation convenience.
Tabs: grouping for comparison, not for hiding
Tabs let a user switch between several views that occupy the same region of the screen, showing one panel at a time under a row of labeled triggers. Tabs work well when the panels represent parallel, comparable slices of the same subject that a user browses selectively and doesn’t need simultaneously — “Overview / Specifications / Reviews” on a product page, or “Day / Week / Month” for a calendar view. The user’s mental model is “these are alternate views of one thing; I’ll look at the one I need.”
Tabs actively hurt usability when they’re used to hide content the user needed to see by default, rather than to offer optional alternate views. Two common failure patterns:
- Burying a critical, one-time step. A configuration page with “Basic Settings” and “Advanced Settings” as tabs often hides required fields the user needs on first visit, purely because the team wanted a shorter default view. If most users must visit every tab anyway, tabs are adding a click and a discovery cost, not saving space — a single scrollable page with an optional inline disclosure “Show advanced options” often serves better.
- Splitting content that needs to be seen together. If users regularly need to cross-reference information across two “tabs” (e.g., compare pricing while reading feature details), tabbing them apart forces memory load or repeated switching that a side-by-side or single-page layout would avoid.
A reasonable test before defaulting to tabs: would most users, most of the time, need to see more than one panel to complete their task? If yes, tabs are hiding something they need — use sections, an accordion, or a single page instead. Tabs are for parallel views, not for compressing an unrelated pile of settings into fewer visible pixels.
Tooltip: supplementary only, never critical
A Tooltip is a small floating label that appears on hover or keyboard focus to supply brief, supplementary information about the element it’s attached to — clarifying an icon-only button, showing a truncated value’s full text, or giving a one-line hint. Tooltips are triggered by :hover and :focus-visible, never by click alone (a click-triggered “tooltip” is really a popover), and they must disappear on blur/mouse-leave or Escape.
The hard rule design systems must enforce: a tooltip must never be the only place required or critical information lives. Tooltips fail discoverability in several concrete ways:
- They require a precise hover or a deliberate Tab stop to reveal — nothing prompts a user to seek them out, so information “hidden” in a tooltip is often simply never seen.
- On touch devices, there is no hover state at all; many touch UIs never surface a tooltip’s content, or only after a long-press users don’t intuitively try.
- Screen reader behavior for tooltips is inconsistent across assistive technology unless the tooltip is properly wired with
aria-describedby, and even then, tooltip content is easy to miss in a linear reading pass. - Tooltips disappear as soon as focus moves, so they cannot be referenced while filling out the very field they’re explaining.
Practically, this means: form field requirements (“must be at least 8 characters”), error states, and anything the user is required to act on belong in visible help text or inline validation messaging — never tooltip-only. Tooltips are for the genuinely optional extra sentence: what an abbreviation stands for, what a disabled button would do if enabled, the exact numeric value behind a formatted display. See also 04 — Core Components: Inputs & Actions for how inline help text and validation messaging cover the cases tooltips shouldn’t.
Toast and Banner: transient vs. persistent feedback
Toast and Banner are both used to tell the user something happened or is true, but they differ on the single most important axis: does it go away on its own, and should it?
A Toast is a transient, non-blocking notification that reports something that just happened — a save completed, a file uploaded, an item was removed — and disappears automatically after a few seconds, usually from a corner of the screen, without requiring the user to act. It is feedback about a completed event, not an ongoing condition.
// Imperative toast API, typically backed by a toast manager/provider
toast.success("Project saved");
toast.error("Failed to save project. Try again.");
toast.warning("Your session will expire in 5 minutes.");
toast.info("New comments were added while you were away.");
// Full options
toast.show({
variant: "success", // success | error | warning | info
title: "Project saved",
description: "All changes were saved to the cloud.",
duration: 4000, // ms; error/warning often use a longer or 0 (sticky) duration
action: {
label: "Undo",
onClick: handleUndo,
},
onDismiss: () => trackEvent("toast_dismissed"),
});
Toast variants typically differ in icon, color, and default duration — errors and warnings are often given a longer duration or duration: 0 (requiring manual dismissal) because they carry more consequence than a routine success confirmation. Under the hood, a toast system needs a few things a single component can’t provide alone: a portal/stacking manager so multiple toasts queue instead of overlapping, an aria-live="polite" (or assertive for errors) region so screen readers announce new toasts without stealing focus, and pause-on-hover so a toast doesn’t vanish while the user is still reading it.
A Banner is a persistent, usually full-width message that communicates an ongoing condition the user should know about for as long as it’s true — a billing issue, a scheduled maintenance window, a degraded service status, an unsaved-changes warning. A banner does not auto-dismiss on a timer; it persists until the underlying condition is resolved (the payment is fixed, the maintenance ends) or the user explicitly dismisses it. Banners are typically placed inline at the top of a page or section, are not overlays, and do not block interaction with the rest of the page.
<Banner
variant="warning" // info | success | warning | critical
dismissible
onDismiss={() => setBannerDismissed(true)}
action={{ label: "Update payment method", onClick: goToBilling }}
>
Your payment method expired. Update it to avoid a service interruption.
</Banner>
The distinction matters because using the wrong one creates real problems: a toast for an ongoing billing issue disappears in four seconds and the user may never see it again, while a banner for a one-off “saved successfully” event lingers uselessly and clutters the page after the fact is no longer new information.
Badge: a small inline marker, not a message
A Badge is a small, compact visual indicator — typically a colored dot, a short label, or a number — attached inline to another element to show status or count: an “Active” status badge next to a name, a notification count on a bell icon, a “Beta” label next to a feature name.
<Badge tone="success">Active</Badge>
<Badge tone="neutral">Draft</Badge>
<Badge tone="critical" count={3} />
<Badge tone="info" variant="outline">Beta</Badge>
The distinction between Badge and Banner is really a distinction of scale and role: a badge is a small inline marker attached to a specific piece of UI (a name, an icon, a row) and carries no independent message of its own — it’s metadata about the thing it’s attached to. A banner is a full-width, standalone message about the page or the account as a whole, with its own text and often its own call to action. Confusing the two produces predictable UI mistakes: cramming a sentence of explanation into what should be a one-word badge, or building a full banner to show something as simple as a count that a small badge on an icon would communicate more efficiently.
Avatar: representing a user or entity
An Avatar displays a compact visual representation of a person or entity — typically a photo, but reliably falling back when no photo is available. The fallback chain matters more than the happy path, because avatars are rendered in bulk (member lists, comment threads, assignee pickers) where broken images or missing data are common:
- Image — the user’s or entity’s actual photo/logo, if available and loads successfully.
- Initials — one or two letters derived from the name (e.g., “Nam Nguyen” → “NN”), rendered on a deterministic background color derived from the name/ID so the same person gets a stable, recognizable color across sessions.
- Generic icon — a placeholder person/building silhouette, used when there isn’t even a name to derive initials from (an anonymous or not-yet-loaded entity).
<Avatar src={user.avatarUrl} name={user.name} size="md" />
// Renders: photo if `src` loads, else initials from `name`, else a generic icon
<Avatar name="Nam Nguyen" size="sm" /> {/* → "NN" on a stable color */}
<Avatar size="lg" /> {/* → generic icon */}
<AvatarGroup max={4}>
{teamMembers.map((m) => (
<Avatar key={m.id} src={m.avatarUrl} name={m.name} />
))}
</AvatarGroup>
Sizing consistency is the other design-system responsibility here: an avatar rendered at 24px in a comment list and 96px on a profile page must use the same fixed size scale (e.g., xs/sm/md/lg/xl mapped to fixed pixel values) so that initials font size, border radius, and fallback icon proportions scale together rather than being recalculated ad hoc per screen. Without a shared size scale, teams tend to invent slightly different avatar sizes across the product, which is a small but very visible form of inconsistency.
Carousel: a genuine but narrow use case
A Carousel (or slider) rotates through a set of items — usually images or promotional cards — one or a few at a time, either automatically or via user-driven next/previous controls. Carousels are one of the most debated components in UI design, and the debate is well-earned: research going back over a decade (Erik Runyon’s “carousel click data,” Jakob Nielsen’s writing on the topic) consistently shows that auto-rotating carousels get the overwhelming majority of their clicks on the first panel, with engagement dropping off sharply for panels two and beyond — users rarely wait for or manually advance to later slides.
That data supports a narrower conclusion than “never use carousels,” though. The real problems are specific and fixable:
- Auto-advance is the primary offender. A carousel that changes the content out from under a user who is still reading it — especially before they’ve had time to read even the first panel — actively harms comprehension and is a known accessibility problem (WCAG 2.2.2 requires a way to pause, stop, or hide auto-updating content).
- Using a carousel for content of unequal importance — burying an important announcement as “slide 3 of 5” — guarantees most users never see it, since most never advance past the first panel.
- Poor accessibility implementations — no keyboard controls for next/previous, no visible pause control, no accessible name for the current slide, focus not managed — are common and turn an already-weak pattern into an actively broken one for keyboard and screen-reader users.
Where carousels genuinely work: a product image gallery a shopper is already motivated to browse (multiple angles of a product they’re considering buying), a manually-controlled set of testimonials or case studies presented as equally-weighted parallel items (not a single important message buried among filler), or any case where the user is opting in to browse rather than being shown content they need to notice. In these cases the carousel should be manual or pausable, keyboard-navigable, and expose its position (e.g., “3 of 6”) to assistive technology.
The design-system-level guidance worth documenting is: never auto-advance without an accessible pause control, never put your single most important message in a rotating position, and prefer a manually-controlled carousel or a simple grid/list over an auto-rotating one whenever the content isn’t something users are already motivated to explore.
List and Loading indicator
A List renders a set of homogeneous items — rows of search results, a settings list, a notification feed — and its item anatomy is usually a predictable slot structure: an optional leading visual (icon, avatar, checkbox), a primary label, an optional secondary/meta line, and an optional trailing element (a value, a chevron, an action button).
<List>
<List.Item
leading={<Avatar name={member.name} src={member.avatarUrl} size="sm" />}
title={member.name}
subtitle={member.role}
trailing={<Badge tone="success">Active</Badge>}
onClick={() => openMember(member.id)}
/>
</List>
A Loading indicator communicates that content is on its way, and the choice between a spinner and a skeleton screen is a genuine UX decision, not just a stylistic one. A spinner communicates “wait” with no information about what’s coming and causes a layout jump the instant real content replaces it. A skeleton screen — gray placeholder shapes matching the eventual layout (a rectangle where an avatar will be, bars where text will be) — reduces perceived wait time by giving the user something structurally informative to look at immediately, and eliminates layout shift because the real content mounts into space the skeleton already occupied. As a rule of thumb: use skeleton screens for predictable, structured content (a card grid, a list, a profile page) where the eventual layout is known in advance, and reserve spinners for short, unstructured waits (a button’s in-flight state, a small inline action) where building a skeleton isn’t worth the cost.
Comparative summary: Toast vs. Banner vs. Modal
| Aspect | Toast | Banner | Modal |
|---|---|---|---|
| Urgency | Low–medium (routine confirmations, minor errors) | Medium–high (ongoing conditions worth sustained attention) | High (requires a decision or acknowledgment before continuing) |
| Persistence | Transient, auto-dismisses (typically 3–6s) | Persistent until resolved or manually dismissed | Persistent until the user completes or cancels the interaction |
| Blocking | Never blocks the page | Never blocks the page | Blocks all interaction with the rest of the page |
| Placement | Corner overlay, stacks with other toasts | Inline, full-width, top of page/section | Centered overlay with a scrim |
| Right use case | ”Your changes were saved" | "Your trial ends in 3 days" | "Are you sure you want to delete this?” |
Best Practices
- Treat every component in this set as opt-in escalation: default to the least intrusive option (inline text, a section, a banner) and move up to a more intrusive one (a modal, an auto-advancing carousel) only when the content genuinely requires the user’s full, blocking attention.
- Give Modal, Toast, and Banner components ownership of their own accessibility mechanics — focus trap and restore,
aria-liveregions,role="alertdialog"vsrole="dialog"— so individual teams never have to reimplement them correctly (or incorrectly) per feature. See 07 — Accessibility and Inclusive Design. - Standardize a fixed size scale for Avatar (and reuse it for Badge dot sizes and List leading icons) so visual density stays consistent across features built by different teams.
- Document tone/variant vocabularies (
success | error | warning | info, orinfo | success | warning | critical) once, centrally, and reuse the same vocabulary across Toast, Banner, and Badge — a component-guidelines page should map each tone to a specific meaning so “warning” means the same thing everywhere. See 06 — Component Guidelines and Documentation Patterns. - Cap simultaneous toasts (e.g., a maximum of 3 stacked, with older ones pushed out or queued) so a burst of async events doesn’t produce an unreadable stack.
- For any component that hides content by default (Tabs, Accordion, Tooltip), explicitly ask during design review: “does the user need to see this without taking an extra action?” If yes, it doesn’t belong behind that disclosure pattern.
- Never let a carousel or toast be the sole delivery mechanism for information a user is required to act on — pair critical messages with a persistent banner or a page-level element instead.
- Provide both a slot-based and a prop-based API surface for Card where practical, so simple use cases stay terse while complex ones remain composable.
References
- roadmap.sh — Design System
- Nielsen Norman Group — Modal & Nonmodal Dialogs
- Nielsen Norman Group — Auto-Forwarding Carousels and Accordions Are Bad for Usability
- W3C ARIA Authoring Practices Guide — Dialog (Modal) Pattern
- W3C ARIA Authoring Practices Guide — Tabs Pattern
- W3C WCAG 2.2 — Success Criterion 2.2.2 Pause, Stop, Hide
- Shopify Polaris — Toast, Banner, and Modal components
- Atlassian Design System — Tooltip