Components, Variants & States
A component is a contract: a stable anatomy (named parts), an API (props/slots), and a complete state matrix. Ship all three or you ship a liability. The #1 source of "feels broken" UI is missing states — not missing features.
1. Component anatomy — the parts model
Every component decomposes into named parts. Name them; style each part independently; expose them as slots. Headless libraries (Radix, ARK, React Aria) formalize this as Root, Trigger, Content, Item, etc.
┌─ Container (Root) ──────────────────────────────┐
│ [leading slot] Label / Value [trailing slot] │
│ Helper / Description text │
└─────────────────────────────────────────────────┘
| Part |
Role |
Example |
| Container / Root |
Bounding box, owns padding/radius/border, carries data-state |
the <button>, the field wrapper |
| Leading |
Icon, avatar, prefix, adornment |
search glass in input |
| Label |
Primary text, the accessible name |
"Save changes" |
| Value / Content |
Live content |
input text, selected option |
| Trailing |
Action, chevron, clear, counter, status |
clear-X, dropdown caret |
| Helper / Description |
Secondary text below |
"We'll never share this" |
| Error / Validation |
Replaces helper when invalid |
"Email is required" |
Rule: A slot accepts arbitrary children (composition); a prop configures a fixed dimension (config). Prefer slots for content, props for behavior/appearance. See §9.
2. The interaction state matrix (define + treat EVERY one)
This is the heart of the skill. Each state needs a defined visual treatment and a11y signal. Use data-state attributes (set by headless libs or your own code) + native pseudo-classes.
| State |
When |
Visual treatment |
Signal |
| default / rest |
idle, interactive |
base bg/border/text; sufficient affordance to look clickable |
— |
| hover |
pointer over (pointer devices only) |
subtle bg/elevation/underline shift; never the only affordance |
:hover |
| focus |
element focused (any input) |
— (avoid showing for mouse) |
:focus |
| focus-visible |
focused via keyboard |
2px visible ring, 2px offset, ≥3:1 contrast |
:focus-visible |
| active / pressed |
pointer/key down |
darken/scale 0.97/inset shadow; ~instant |
:active, [data-state=active] |
| selected / checked |
chosen in a set |
filled/accent bg, check glyph, bold |
[aria-selected], [aria-checked], :checked |
| disabled |
not interactive and reason exists |
38–50% opacity OR muted token; cursor:not-allowed; no hover |
:disabled, [aria-disabled] |
| loading / busy |
async in flight |
spinner replaces/joins label, keep width stable, lock interaction |
[aria-busy=true], [data-state=loading] |
| error / invalid |
failed validation |
danger border + helper→error text + icon (not color alone) |
[aria-invalid=true] |
| read-only |
viewable, not editable, still focusable/copyable |
muted bg, normal text, no edit caret |
[readonly], aria-readonly |
| expanded / open |
disclosure open |
rotated chevron, revealed panel |
[aria-expanded=true], [data-state=open] |
| dragging |
being dragged |
raised elevation, 0.8 opacity, grabbing cursor, placeholder gap |
[data-dragging], [aria-grabbed] |
| indeterminate |
partial (tri-state checkbox) |
dash glyph |
:indeterminate |
| placeholder / empty |
no value yet |
muted placeholder text (not a label substitute) |
— |
Combined states are real: hover + selected, focus-visible + invalid, loading + disabled. Test them. Precedence (highest wins): disabled > loading > error > selected/active > focus-visible > hover > rest. A disabled element shows no hover and no error styling change on pointer.
/* Modern state-driven button — pseudo-classes + data-state + tokens */
.btn {
--btn-bg: var(--accent-9);
--btn-fg: white;
display: inline-flex; align-items: center; gap: var(--space-2);
block-size: var(--control-h-md); padding-inline: var(--space-4);
border-radius: var(--radius-2); border: 1px solid transparent;
background: var(--btn-bg); color: var(--btn-fg);
font: inherit; cursor: pointer;
transition: background .12s, box-shadow .12s, transform .04s;
}
.btn:where(:hover) { --btn-bg: var(--accent-10); }
.btn:active { transform: translateY(.5px) scale(.99); }
.btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
.btn:disabled,
.btn[aria-disabled="true"] { opacity: .5; cursor: not-allowed; }
.btn[aria-busy="true"] { pointer-events: none; }
.btn[data-state="loading"] .btn__label { visibility: hidden; }
/* :where() keeps specificity 0 so app overrides win without !important */
Use :is()/:where() to group selectors, and :has() for parent-aware styling (.field:has(:invalid) lights the whole field). Prefer :focus-visible over :focus — never strip the ring without replacing it.
3. Variants vs sizes vs states vs shape
Keep these axes orthogonal. A button is variant × size × state, not 30 hand-named classes.
- Semantic variants (intent/emphasis):
primary (one per view), secondary, tertiary, ghost (transparent until hover), outline, danger/destructive, link. Map to meaning, not color — themes reskin them.
- Sizes (one scale, shared across controls):
| Size |
Height |
Padding-X |
Font |
Icon |
Radius |
sm |
32px |
12px |
13–14px |
16px |
6px |
md |
40px |
16px |
14–16px |
20px |
8px |
lg |
48px |
20px |
16–18px |
24px |
10px |
- Shape:
rounded (default), pill (border-radius: 9999px), square (icon-only). Shape ≠ size.
- States: §2 — runtime, never a variant.
Props API design: variant, size, shape, plus boolean state-ish props (disabled, loading, selected). Booleans use is/has/can/should. Avoid boolean explosion (isPrimary isSecondary…) — use one variant union. Validate at the boundary (Zod / TS union). Slots (leadingIcon, children, trailingIcon) over a dozen content props.
4. Core components — anatomy · states · a11y
| Component |
Key parts |
States that MUST exist |
a11y essentials |
| Button |
container, leading, label, trailing |
rest/hover/focus-visible/active/disabled/loading |
<button type>; loading→aria-busy; icon-only→aria-label |
| Icon-button |
square container, icon |
+ tooltip on hover/focus |
aria-label required; min 40×40 hit area |
| Input / Textarea |
wrapper, label, leading, field, trailing, helper/error |
rest/hover/focus/filled/invalid/disabled/read-only |
real <label for>; aria-invalid+aria-describedby→error id; textarea: field-sizing:content |
| Select / Combobox |
trigger(value+chevron), listbox, option, group |
closed/open/option hover/selected/disabled/loading/empty |
role=combobox aria-expanded; typeahead; ↑↓/Home/End/Esc; selected→aria-selected |
| Checkbox / Radio |
control box, check/dot, label |
unchecked/checked/indeterminate(cb)/focus-visible/disabled/invalid |
native input + styled box; radio = one Tab stop, arrows move; label clickable |
| Switch / Toggle |
track, thumb, label |
off/on/focus-visible/disabled/loading |
role=switch aria-checked; instant + animated thumb |
| Slider |
track, range fill, thumb(s), ticks |
rest/hover/dragging/focus-visible/disabled |
role=slider aria-valuemin/max/now; arrows ±step, PageUp/Down ±10% |
| Card |
container, media, header, body, footer, actions |
rest; if interactive → hover/focus-visible/active/selected |
whole-card link → one focusable; don't nest interactives without care |
| Badge / Chip / Tag |
container, leading dot/icon, label, (removable) ✕ |
static; chip adds selected/disabled/removable |
removable ✕ → own aria-label; status by icon+text, not color alone |
| Avatar |
image, fallback initials/icon, status dot |
loaded/fallback(error)/loading |
alt = name or "" if decorative; status dot needs text equivalent |
| Tooltip |
trigger, content, arrow |
hidden/open |
hover and focus; aria-describedby; never the only place info lives; not for interactive content |
| Popover |
trigger, content, arrow |
closed/open |
focus moves in, Esc closes, returns focus; aria-expanded |
| Modal / Dialog |
overlay, container, header(title+close), body, footer |
closed/open/loading |
role=dialog aria-modal, labelled by title; focus trap; Esc; scroll-lock; restore focus |
| Drawer / Sheet |
scrim, panel, handle |
closed/open/dragging(swipe) |
same as dialog; edge-swipe on mobile; respects safe-area insets |
| Toast / Snackbar |
container, icon, message, action, dismiss |
enter/visible/exit; queued |
role=status(polite)/role=alert(assertive); auto-dismiss ≥5s + pause-on-hover; never errors-only-here |
| Tabs |
tablist, tab, indicator, tabpanel |
rest/hover/selected/focus-visible/disabled |
role=tablist/tab/tabpanel; arrows move, aria-selected; manual vs automatic activation |
| Accordion |
item, header(button), content |
collapsed/expanded/focus-visible/disabled |
header is a <button aria-expanded>; data-state=open/closed; animate grid-template-rows:0fr→1fr |
| Menu / Dropdown |
trigger, content, item, separator, sub-trigger |
closed/open/item hover/focus/disabled/checked |
role=menu/menuitem; ↑↓ wrap, typeahead, Esc, roving tabindex |
| Breadcrumb |
<nav aria-label>, ol, item, separator, current |
links; current(not a link) |
aria-current="page" on last; separators decorative/aria-hidden |
| Pagination |
prev, page numbers, ellipsis, next |
rest/hover/current/disabled(ends) |
<nav aria-label="Pagination">; current → aria-current="page" |
| Table |
table, thead, th(sortable), tbody, row, cell |
row hover/selected; col sort asc/desc; loading(skeleton rows)/empty |
<th scope>; sortable th → button + aria-sort; caption; sticky header |
| List |
list, item, leading, content, trailing, dividers |
item rest/hover/selected/disabled; loading/empty |
<ul>/<ol>; selectable → role=listbox/option or aria-selected |
| Nav (top/side/bottom) |
nav, items, current, icon+label |
item rest/hover/current/active/focus-visible |
<nav aria-label>; current → aria-current="page"; bottom-nav ≥48dp targets |
| Search |
wrapper, leading glass, input, clear, results/suggest |
empty/typing/loading/results/no-results/clear |
role=searchbox or type=search; debounce; announce result count via live region |
| Segmented control |
track, segment, indicator |
rest/hover/selected/focus-visible/disabled |
radiogroup semantics; arrows move; one selected always |
| Progress / Spinner |
track, fill OR rotating arc |
determinate(0–100)/indeterminate |
role=progressbar aria-valuenow; spinner → aria-label="Loading"; respect reduced-motion |
| Skeleton |
shimmer blocks matching final layout |
loading only |
aria-hidden=true + parent aria-busy; match real content dimensions to avoid layout shift |
5. Sizing tokens
| Token group |
Values |
Notes |
| Control heights |
32 / 40 / 48 (px) |
sm/md/lg; some systems add 24 (xs) / 56 (xl) |
| Touch target min |
44×44px (iOS HIG), 48×48dp (Android Material) |
even if visual is smaller, pad the hit area |
| Padding scale |
4 / 8 / 12 / 16 / 20 / 24 |
--space-*; inline padding ≈ height × 0.4 |
| Icon sizes |
16 / 20 / 24 (px) |
pair with sm/md/lg control |
| Radius scale |
0 / 2 / 4 / 6 / 8 / 12 / 9999 |
9999 = pill; keep ≤3 in active use |
| Border |
1px (hairline), 2px (focus ring) |
ring offset 2px |
Touch targets are non-negotiable on mobile: a 24px icon-button needs invisible padding to reach 44/48. Spacing between adjacent targets ≥8px.
6. States in code — attributes & theming
Drive styling off semantic attributes, not class soup:
<button data-state="loading" aria-busy="true" disabled>…</button>
<div role="tab" aria-selected="true" data-state="active" tabindex="0">…</div>
<div class="field" data-invalid>
<input aria-invalid="true" aria-describedby="email-err" />
<p id="email-err" role="alert">Enter a valid email.</p>
</div>
/* Per-component theming via CSS custom properties — override anywhere up the tree */
.field {
--field-border: var(--gray-7);
--field-ring: var(--accent-8);
}
.field:has(:focus-visible) { box-shadow: 0 0 0 2px var(--field-ring); }
.field:has([aria-invalid="true"]),
.field[data-invalid] { --field-border: var(--red-8); }
.field input { border: 1px solid var(--field-border); }
@media (prefers-reduced-motion: reduce) {
* { animation-duration: .01ms !important; transition-duration: .01ms !important; }
}
data-state (open/closed/active/loading) reads cleanly, is what Radix/ARK emit, and survives SSR. Reserve ARIA for semantics the AT needs (aria-busy, aria-invalid, aria-expanded), not for styling hooks you could express with data-*.
7. Density, RTL, theming, composition, control model
- Density modes: a single
--density knob scaling heights/padding (comfortable 40 / compact 32 / spacious 48). Drive via data-density on root → recompute token sizes. Don't shrink touch targets below platform minimums in compact mode on touch devices.
- RTL: use logical properties everywhere (
margin-inline-start, padding-block, inset-inline-start, text-align: start). Set dir="rtl"; mirror directional icons (chevrons, back arrows) but not literal ones (clock, logos). :dir(rtl) selector for the rest.
- Theming hooks: token layers — primitive (
--blue-500) → semantic (--accent-9, --fg, --bg) → component (--btn-bg). Theme by reassigning semantic tokens ([data-theme=dark]). Support color-scheme + prefers-color-scheme. Never hardcode hex in a component.
- Composition over config: prefer
<Card><Card.Header/><Card.Body/></Card> slots over a 20-prop <Card title subtitle media footer …/>. Slots scale; config props don't (rule of 3 before adding a prop).
- Controlled vs uncontrolled: support both. Uncontrolled (internal state,
defaultValue) for simple cases; controlled (value + onChange) when the parent owns truth. Pattern: value ?? internalState. Document which props make it controlled. Never silently switch a component between the two.
8. Cross-platform component equivalences
| Concept |
Web |
iOS (UIKit / SwiftUI) |
Android (Material 3) |
Desktop |
| Button |
<button> |
UIButton / Button |
Button / FilledButton |
native button widget |
| Switch |
role=switch |
UISwitch / Toggle |
Switch |
toggle/checkbox |
| Select |
<select>/combobox |
UIPickerView / Picker |
ExposedDropdownMenu |
<select> / NSPopUpButton |
| Slider |
<input type=range> |
UISlider / Slider |
Slider |
native slider |
| Modal |
<dialog>/role=dialog |
sheet / .sheet |
Dialog / ModalBottomSheet |
window/dialog |
| Drawer/Sheet |
role=dialog sheet |
bottom sheet / .sheet |
ModalBottomSheet |
side panel |
| Toast |
role=status |
no native (build) |
Snackbar |
system notification |
| Tabs |
role=tablist |
UITabBar(bottom) / TabView |
TabRow (top) |
tab strip |
| Segmented |
radiogroup |
UISegmentedControl / Picker(.segmented) |
SegmentedButton |
segmented control |
| Nav (primary) |
top/side <nav> |
bottom UITabBar |
bottom NavigationBar / rail |
side menu / titlebar |
Platform conventions differ: iOS puts primary nav at the bottom tab bar; Material uses bottom navigation (≤5 items) or a nav rail/drawer; web is flexible (top or side). Respect each platform's default control rather than porting web pixel-for-pixel. Map intent to native components; don't reskin a web button to look iOS-native — use the platform control.
9. Common mistakes (do / don't)
| Don't |
Do |
Strip outline / only style :focus |
Use :focus-visible with a 2px, 3:1-contrast ring + offset |
| Disable a button with no explanation |
Show why (inline error, tooltip on a wrapper) or keep enabled + validate on submit |
| Convey state by color alone |
Pair color with icon + text (error icon + message; status label) |
| Hover-only affordances (desktop-only reveal) |
Make actions reachable by keyboard + touch; hover is enhancement |
| Skip loading / empty / error states |
Design all four: rest, loading (skeleton/spinner), empty, error — every data view |
| Width jumps when label → spinner |
Reserve width; swap label visibility, keep box size |
<div onClick> everywhere (div soup) |
Semantic <button>/<a>/<input> — free a11y, focus, keyboard |
| Placeholder as the label |
Real <label>; placeholder is a hint, disappears on type |
| Per-component ad-hoc sizes |
One shared size scale (sm/md/lg) across all controls |
| Tooltip holding the only copy of info |
Tooltips supplement; critical info lives in the DOM |
!important to win specificity |
:where() for zero-specificity base, let app layers override |
Animate with no prefers-reduced-motion guard |
Wrap motion; provide reduced/instant fallback |
aria-* to fake a native control |
Use the native element; add ARIA only when HTML can't express it |
| One Tab stop per radio/menu item |
Roving tabindex: group is one stop, arrows move within |
Checklist before "component done": anatomy named · all §2 states defined incl. combined · focus-visible ring · disabled has a reason · loading/empty/error exist · touch target ≥44/48 · keyboard-operable · labelled for AT · sizes from the scale · RTL via logical props · reduced-motion honored · controlled+uncontrolled supported.
1---2name: components-and-states3description: Reference-grade guide to designing and building UI components — anatomy, the full interaction-state matrix, variants vs sizes vs states, sizing tokens, per-component anatomy/states/a11y, and modern headless/data-state/CSS patterns across web, iOS, Android, and desktop.4---5# Components, Variants & States67A component is a contract: a stable **anatomy** (named parts), an **API** (props/slots), and a complete **state matrix**. Ship all three or you ship a liability. The #1 source of "feels broken" UI is missing states — not missing features.89## 1. Component anatomy — the parts model1011Every component decomposes into named parts. Name them; style each part independently; expose them as slots. Headless libraries (Radix, ARK, React Aria) formalize this as `Root`, `Trigger`, `Content`, `Item`, etc.1213```14┌─ Container (Root) ──────────────────────────────┐15│ [leading slot] Label / Value [trailing slot] │16│ Helper / Description text │17└─────────────────────────────────────────────────┘18```1920| Part | Role | Example |21|---|---|---|22| **Container / Root** | Bounding box, owns padding/radius/border, carries `data-state` | the `<button>`, the field wrapper |23| **Leading** | Icon, avatar, prefix, adornment | search glass in input |24| **Label** | Primary text, the accessible name | "Save changes" |25| **Value / Content** | Live content | input text, selected option |26| **Trailing** | Action, chevron, clear, counter, status | clear-X, dropdown caret |27| **Helper / Description** | Secondary text below | "We'll never share this" |28| **Error / Validation** | Replaces helper when invalid | "Email is required" |2930**Rule:** A slot accepts arbitrary children (composition); a prop configures a fixed dimension (config). Prefer slots for content, props for behavior/appearance. See §9.3132## 2. The interaction state matrix (define + treat EVERY one)3334This is the heart of the skill. Each state needs a defined **visual treatment** and **a11y signal**. Use `data-state` attributes (set by headless libs or your own code) + native pseudo-classes.3536| State | When | Visual treatment | Signal |37|---|---|---|---|38| **default / rest** | idle, interactive | base bg/border/text; sufficient affordance to look clickable | — |39| **hover** | pointer over (pointer devices only) | subtle bg/elevation/underline shift; never the *only* affordance | `:hover` |40| **focus** | element focused (any input) | — (avoid showing for mouse) | `:focus` |41| **focus-visible** | focused via keyboard | **2px visible ring, 2px offset, ≥3:1 contrast** | `:focus-visible` |42| **active / pressed** | pointer/key down | darken/scale 0.97/inset shadow; ~instant | `:active`, `[data-state=active]` |43| **selected / checked** | chosen in a set | filled/accent bg, check glyph, bold | `[aria-selected]`, `[aria-checked]`, `:checked` |44| **disabled** | not interactive *and reason exists* | 38–50% opacity OR muted token; `cursor:not-allowed`; **no hover** | `:disabled`, `[aria-disabled]` |45| **loading / busy** | async in flight | spinner replaces/joins label, keep width stable, lock interaction | `[aria-busy=true]`, `[data-state=loading]` |46| **error / invalid** | failed validation | danger border + helper→error text + icon (not color alone) | `[aria-invalid=true]` |47| **read-only** | viewable, not editable, still focusable/copyable | muted bg, normal text, no edit caret | `[readonly]`, `aria-readonly` |48| **expanded / open** | disclosure open | rotated chevron, revealed panel | `[aria-expanded=true]`, `[data-state=open]` |49| **dragging** | being dragged | raised elevation, 0.8 opacity, grabbing cursor, placeholder gap | `[data-dragging]`, `[aria-grabbed]` |50| **indeterminate** | partial (tri-state checkbox) | dash glyph | `:indeterminate` |51| **placeholder / empty** | no value yet | muted placeholder text (not a label substitute) | — |5253**Combined states** are real: `hover + selected`, `focus-visible + invalid`, `loading + disabled`. Test them. **Precedence** (highest wins): `disabled` > `loading` > `error` > `selected/active` > `focus-visible` > `hover` > `rest`. A disabled element shows no hover and no error styling change on pointer.5455```css56/* Modern state-driven button — pseudo-classes + data-state + tokens */57.btn {58 --btn-bg: var(--accent-9);59 --btn-fg: white;60 display: inline-flex; align-items: center; gap: var(--space-2);61 block-size: var(--control-h-md); padding-inline: var(--space-4);62 border-radius: var(--radius-2); border: 1px solid transparent;63 background: var(--btn-bg); color: var(--btn-fg);64 font: inherit; cursor: pointer;65 transition: background .12s, box-shadow .12s, transform .04s;66}67.btn:where(:hover) { --btn-bg: var(--accent-10); }68.btn:active { transform: translateY(.5px) scale(.99); }69.btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }70.btn:disabled,71.btn[aria-disabled="true"] { opacity: .5; cursor: not-allowed; }72.btn[aria-busy="true"] { pointer-events: none; }73.btn[data-state="loading"] .btn__label { visibility: hidden; }74/* :where() keeps specificity 0 so app overrides win without !important */75```7677Use `:is()`/`:where()` to group selectors, and `:has()` for parent-aware styling (`.field:has(:invalid)` lights the whole field). Prefer `:focus-visible` over `:focus` — never strip the ring without replacing it.7879## 3. Variants vs sizes vs states vs shape8081Keep these axes **orthogonal**. A button is `variant × size × state`, not 30 hand-named classes.8283- **Semantic variants** (intent/emphasis): `primary` (one per view), `secondary`, `tertiary`, `ghost` (transparent until hover), `outline`, `danger`/destructive, `link`. Map to *meaning*, not color — themes reskin them.84- **Sizes** (one scale, shared across controls):8586| Size | Height | Padding-X | Font | Icon | Radius |87|---|---|---|---|---|---|88| `sm` | 32px | 12px | 13–14px | 16px | 6px |89| `md` | 40px | 16px | 14–16px | 20px | 8px |90| `lg` | 48px | 20px | 16–18px | 24px | 10px |9192- **Shape**: `rounded` (default), `pill` (`border-radius: 9999px`), `square` (icon-only). Shape ≠ size.93- **States**: §2 — runtime, never a variant.9495**Props API design:** `variant`, `size`, `shape`, plus boolean state-ish props (`disabled`, `loading`, `selected`). Booleans use `is/has/can/should`. Avoid boolean explosion (`isPrimary isSecondary…`) — use one `variant` union. Validate at the boundary (Zod / TS union). Slots (`leadingIcon`, `children`, `trailingIcon`) over a dozen content props.9697## 4. Core components — anatomy · states · a11y9899| Component | Key parts | States that MUST exist | a11y essentials |100|---|---|---|---|101| **Button** | container, leading, label, trailing | rest/hover/focus-visible/active/disabled/**loading** | `<button type>`; loading→`aria-busy`; icon-only→`aria-label` |102| **Icon-button** | square container, icon | + tooltip on hover/focus | `aria-label` required; min 40×40 hit area |103| **Input / Textarea** | wrapper, label, leading, field, trailing, helper/error | rest/hover/focus/filled/**invalid**/disabled/read-only | real `<label for>`; `aria-invalid`+`aria-describedby`→error id; textarea: `field-sizing:content` |104| **Select / Combobox** | trigger(value+chevron), listbox, option, group | closed/**open**/option hover/**selected**/disabled/loading/empty | `role=combobox` `aria-expanded`; typeahead; ↑↓/Home/End/Esc; selected→`aria-selected` |105| **Checkbox / Radio** | control box, check/dot, label | unchecked/checked/**indeterminate**(cb)/focus-visible/disabled/invalid | native input + styled box; radio = one Tab stop, arrows move; label clickable |106| **Switch / Toggle** | track, thumb, label | off/on/focus-visible/disabled/loading | `role=switch` `aria-checked`; instant + animated thumb |107| **Slider** | track, range fill, thumb(s), ticks | rest/hover/dragging/focus-visible/disabled | `role=slider` `aria-valuemin/max/now`; arrows ±step, PageUp/Down ±10% |108| **Card** | container, media, header, body, footer, actions | rest; if interactive → hover/focus-visible/active/selected | whole-card link → one focusable; don't nest interactives without care |109| **Badge / Chip / Tag** | container, leading dot/icon, label, (removable) ✕ | static; chip adds selected/disabled/removable | removable ✕ → own `aria-label`; status by icon+text, not color alone |110| **Avatar** | image, fallback initials/icon, status dot | loaded/**fallback(error)**/loading | `alt` = name or `""` if decorative; status dot needs text equivalent |111| **Tooltip** | trigger, content, arrow | hidden/**open** | hover *and* focus; `aria-describedby`; never the only place info lives; not for interactive content |112| **Popover** | trigger, content, arrow | closed/open | focus moves in, `Esc` closes, returns focus; `aria-expanded` |113| **Modal / Dialog** | overlay, container, header(title+close), body, footer | closed/open/loading | `role=dialog` `aria-modal`, labelled by title; **focus trap**; `Esc`; scroll-lock; restore focus |114| **Drawer / Sheet** | scrim, panel, handle | closed/open/dragging(swipe) | same as dialog; edge-swipe on mobile; respects safe-area insets |115| **Toast / Snackbar** | container, icon, message, action, dismiss | enter/visible/exit; queued | `role=status`(polite)/`role=alert`(assertive); auto-dismiss ≥5s + pause-on-hover; never errors-only-here |116| **Tabs** | tablist, tab, indicator, tabpanel | rest/hover/**selected**/focus-visible/disabled | `role=tablist/tab/tabpanel`; arrows move, `aria-selected`; manual vs automatic activation |117| **Accordion** | item, header(button), content | collapsed/**expanded**/focus-visible/disabled | header is a `<button aria-expanded>`; `data-state=open/closed`; animate `grid-template-rows:0fr→1fr` |118| **Menu / Dropdown** | trigger, content, item, separator, sub-trigger | closed/open/item hover/focus/disabled/checked | `role=menu/menuitem`; ↑↓ wrap, typeahead, `Esc`, roving tabindex |119| **Breadcrumb** | `<nav aria-label>`, ol, item, separator, current | links; **current**(not a link) | `aria-current="page"` on last; separators decorative/`aria-hidden` |120| **Pagination** | prev, page numbers, ellipsis, next | rest/hover/**current**/disabled(ends) | `<nav aria-label="Pagination">`; current → `aria-current="page"` |121| **Table** | table, thead, th(sortable), tbody, row, cell | row hover/selected; col sort asc/desc; loading(skeleton rows)/empty | `<th scope>`; sortable th → button + `aria-sort`; caption; sticky header |122| **List** | list, item, leading, content, trailing, dividers | item rest/hover/selected/disabled; loading/empty | `<ul>/<ol>`; selectable → `role=listbox/option` or `aria-selected` |123| **Nav (top/side/bottom)** | nav, items, current, icon+label | item rest/hover/**current/active**/focus-visible | `<nav aria-label>`; current → `aria-current="page"`; bottom-nav ≥48dp targets |124| **Search** | wrapper, leading glass, input, clear, results/suggest | empty/typing/loading/results/**no-results**/clear | `role=searchbox` or `type=search`; debounce; announce result count via live region |125| **Segmented control** | track, segment, indicator | rest/hover/**selected**/focus-visible/disabled | radiogroup semantics; arrows move; one selected always |126| **Progress / Spinner** | track, fill OR rotating arc | determinate(0–100)/indeterminate | `role=progressbar` `aria-valuenow`; spinner → `aria-label="Loading"`; respect reduced-motion |127| **Skeleton** | shimmer blocks matching final layout | loading only | `aria-hidden=true` + parent `aria-busy`; match real content dimensions to avoid layout shift |128129## 5. Sizing tokens130131| Token group | Values | Notes |132|---|---|---|133| Control heights | 32 / 40 / 48 (px) | sm/md/lg; some systems add 24 (xs) / 56 (xl) |134| **Touch target min** | **44×44px (iOS HIG)**, **48×48dp (Android Material)** | even if visual is smaller, pad the hit area |135| Padding scale | 4 / 8 / 12 / 16 / 20 / 24 | `--space-*`; inline padding ≈ height × 0.4 |136| Icon sizes | 16 / 20 / 24 (px) | pair with sm/md/lg control |137| Radius scale | 0 / 2 / 4 / 6 / 8 / 12 / 9999 | 9999 = pill; keep ≤3 in active use |138| Border | 1px (hairline), 2px (focus ring) | ring offset 2px |139140Touch targets are non-negotiable on mobile: a 24px icon-button needs invisible padding to reach 44/48. Spacing between adjacent targets ≥8px.141142## 6. States in code — attributes & theming143144Drive styling off semantic attributes, not class soup:145146```html147<button data-state="loading" aria-busy="true" disabled>…</button>148<div role="tab" aria-selected="true" data-state="active" tabindex="0">…</div>149<div class="field" data-invalid>150 <input aria-invalid="true" aria-describedby="email-err" />151 <p id="email-err" role="alert">Enter a valid email.</p>152</div>153```154155```css156/* Per-component theming via CSS custom properties — override anywhere up the tree */157.field {158 --field-border: var(--gray-7);159 --field-ring: var(--accent-8);160}161.field:has(:focus-visible) { box-shadow: 0 0 0 2px var(--field-ring); }162.field:has([aria-invalid="true"]),163.field[data-invalid] { --field-border: var(--red-8); }164.field input { border: 1px solid var(--field-border); }165@media (prefers-reduced-motion: reduce) {166 * { animation-duration: .01ms !important; transition-duration: .01ms !important; }167}168```169170`data-state` (open/closed/active/loading) reads cleanly, is what Radix/ARK emit, and survives SSR. Reserve ARIA for *semantics the AT needs* (`aria-busy`, `aria-invalid`, `aria-expanded`), not for styling hooks you could express with `data-*`.171172## 7. Density, RTL, theming, composition, control model173174- **Density modes:** a single `--density` knob scaling heights/padding (comfortable 40 / compact 32 / spacious 48). Drive via `data-density` on root → recompute token sizes. Don't shrink touch targets below platform minimums in compact mode on touch devices.175- **RTL:** use **logical properties** everywhere (`margin-inline-start`, `padding-block`, `inset-inline-start`, `text-align: start`). Set `dir="rtl"`; mirror directional icons (chevrons, back arrows) but not literal ones (clock, logos). `:dir(rtl)` selector for the rest.176- **Theming hooks:** token layers — primitive (`--blue-500`) → semantic (`--accent-9`, `--fg`, `--bg`) → component (`--btn-bg`). Theme by reassigning semantic tokens (`[data-theme=dark]`). Support `color-scheme` + `prefers-color-scheme`. Never hardcode hex in a component.177- **Composition over config:** prefer `<Card><Card.Header/><Card.Body/></Card>` slots over a 20-prop `<Card title subtitle media footer …/>`. Slots scale; config props don't (rule of 3 before adding a prop).178- **Controlled vs uncontrolled:** support both. Uncontrolled (internal state, `defaultValue`) for simple cases; controlled (`value` + `onChange`) when the parent owns truth. Pattern: `value ?? internalState`. Document which props make it controlled. Never silently switch a component between the two.179180## 8. Cross-platform component equivalences181182| Concept | Web | iOS (UIKit / SwiftUI) | Android (Material 3) | Desktop |183|---|---|---|---|---|184| Button | `<button>` | `UIButton` / `Button` | `Button` / `FilledButton` | native button widget |185| Switch | `role=switch` | `UISwitch` / `Toggle` | `Switch` | toggle/checkbox |186| Select | `<select>`/combobox | `UIPickerView` / `Picker` | `ExposedDropdownMenu` | `<select>` / NSPopUpButton |187| Slider | `<input type=range>` | `UISlider` / `Slider` | `Slider` | native slider |188| Modal | `<dialog>`/role=dialog | sheet / `.sheet` | `Dialog` / `ModalBottomSheet` | window/dialog |189| Drawer/Sheet | role=dialog sheet | bottom sheet / `.sheet` | `ModalBottomSheet` | side panel |190| Toast | role=status | no native (build) | `Snackbar` | system notification |191| Tabs | role=tablist | `UITabBar`(bottom) / `TabView` | `TabRow` (top) | tab strip |192| Segmented | radiogroup | `UISegmentedControl` / `Picker(.segmented)` | `SegmentedButton` | segmented control |193| Nav (primary) | top/side `<nav>` | bottom `UITabBar` | bottom `NavigationBar` / rail | side menu / titlebar |194195Platform conventions differ: iOS puts primary nav at the **bottom tab bar**; Material uses **bottom navigation** (≤5 items) or a **nav rail/drawer**; web is flexible (top or side). Respect each platform's default control rather than porting web pixel-for-pixel. Map *intent* to native components; don't reskin a web button to look iOS-native — use the platform control.196197## 9. Common mistakes (do / don't)198199| Don't | Do |200|---|---|201| Strip `outline` / only style `:focus` | Use `:focus-visible` with a 2px, 3:1-contrast ring + offset |202| Disable a button with no explanation | Show *why* (inline error, tooltip on a wrapper) or keep enabled + validate on submit |203| Convey state by **color alone** | Pair color with icon + text (error icon + message; status label) |204| Hover-only affordances (desktop-only reveal) | Make actions reachable by keyboard + touch; hover is enhancement |205| Skip loading / empty / error states | Design all four: rest, loading (skeleton/spinner), empty, error — every data view |206| Width jumps when label → spinner | Reserve width; swap label visibility, keep box size |207| `<div onClick>` everywhere (div soup) | Semantic `<button>/<a>/<input>` — free a11y, focus, keyboard |208| Placeholder as the label | Real `<label>`; placeholder is a hint, disappears on type |209| Per-component ad-hoc sizes | One shared size scale (sm/md/lg) across all controls |210| Tooltip holding the only copy of info | Tooltips supplement; critical info lives in the DOM |211| `!important` to win specificity | `:where()` for zero-specificity base, let app layers override |212| Animate with no `prefers-reduced-motion` guard | Wrap motion; provide reduced/instant fallback |213| `aria-*` to fake a native control | Use the native element; add ARIA only when HTML can't express it |214| One Tab stop per radio/menu item | Roving tabindex: group is one stop, arrows move within |215216**Checklist before "component done":** anatomy named · all §2 states defined incl. combined · focus-visible ring · disabled has a reason · loading/empty/error exist · touch target ≥44/48 · keyboard-operable · labelled for AT · sizes from the scale · RTL via logical props · reduced-motion honored · controlled+uncontrolled supported.