# UX State — Reference

> Kit-level (brand-agnostic) production-grade UX patterns for everything user sees when interacting with data: loading, missing, errored, succeeded. ARIA-compliant, VN voice mặc định, honors `prefers-reduced-motion`.
> Live: 🌐 https://ui.dang.pm/uxstate/

---

## 1 · TL;DR — kit vocabulary

| State | Component | Use when |
|---|---|---|
| **Waiting** (<100ms) | _nothing_ | App still feels instant. |
| **Waiting** (100–1000ms) | `spinner` / `dots` | Visual confirmation an action is happening. |
| **Waiting** (1–10s) | `skeleton` | Preserve layout, reduce cognitive load. |
| **Waiting** (>10s, measurable) | `progress` (determinate) | Show % + time remaining. |
| **Waiting** (>10s, unmeasurable) | `progress[indeterminate]` + cancel CTA | Avoid hopeless waits — give user agency. |
| **Empty (no data ever)** | `empty-state empty--no-data` | First view, list is blank because no records exist. |
| **Empty (filtered to 0)** | `empty-state empty--no-results` | User filtered/searched, nothing matched. |
| **Empty (new account)** | `empty-state empty--first-use` | Onboarding, setup pending. |
| **Empty (network)** | `empty-state empty--offline` | `navigator.onLine === false`. |
| **Errored (form field)** | `field-error` + `aria-invalid` + `role="alert"` | Single input validation. |
| **Errored (1 widget)** | `error-block` (`role="alert"`) | Page section fail, rest of page works. |
| **Errored (route)** | `error-page` | Fatal — render shell, hide app chrome. |
| **Succeeded / informed** | `toast` (4 severity) | Transient feedback to action. |
| **Persistent warning** | `banner` (NOT in this kit yet — use sticky inline `error-block`) | Cross-page conditions. |
| **Blocking decision** | `modal` (consume `focus-trap.js`) | User must confirm/decide before continuing. |

---

## 2 · Component matrix

| Component | File | ARIA pattern | Live demo |
|---|---|---|---|
| Skeleton | 📄 [atoms/loading-skeleton.html](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/atoms/loading-skeleton.html) | parent `role="status"` + `aria-label="Đang tải…"` | 🌐 https://ui.dang.pm/atoms/loading-skeleton.html |
| Spinner | 📄 [atoms/loading-spinner.html](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/atoms/loading-spinner.html) | `role="status"` + `aria-label`; inside button → `aria-busy` on button + `aria-hidden` on spinner | 🌐 https://ui.dang.pm/atoms/loading-spinner.html |
| Progress | 📄 [atoms/loading-progress.html](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/atoms/loading-progress.html) | `role="progressbar"` + `aria-valuenow`/`min`/`max` (determinate); omit `valuenow` for indeterminate (WAI-ARIA 1.2) | 🌐 https://ui.dang.pm/atoms/loading-progress.html |
| Dots | 📄 [atoms/loading-dots.html](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/atoms/loading-dots.html) | `role="status"` if announcement needed, else `aria-hidden` if paired with `aria-live` text | 🌐 https://ui.dang.pm/atoms/loading-dots.html |
| Empty state | 📄 [molecules/empty-state.html](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/molecules/empty-state.html) | `role="status"` (polite — empty is not an error) | 🌐 https://ui.dang.pm/molecules/empty-state.html |
| Error state | 📄 [molecules/error-state.html](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/molecules/error-state.html) | `role="alert"` (assertive); inline form: `aria-invalid="true"` on input + `aria-describedby` → `<p role="alert">` | 🌐 https://ui.dang.pm/molecules/error-state.html |
| Toast | 📄 [organisms/toast.html](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/organisms/toast.html) | Container `role="region"` + `aria-label="Thông báo"`; per toast `role="status"`/`alert` + `aria-atomic="true"` + `aria-live="polite"`/`assertive` | 🌐 https://ui.dang.pm/organisms/toast.html |
| Focus trap | 🔧 [focus-trap.js](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/_assets/focus-trap.js) | Stores `document.activeElement` → cycles Tab/Shift+Tab inside modal → restores on deactivate | — |
| Keyboard | 🔧 [keyboard.js](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/_assets/keyboard.js) | Escape → `ux:escape` CustomEvent; skip-link auto-inject; chord registry | — |
| Showcase | 📄 [uxstate/index.html](file:///C:/Users/DANG/AI-Cowork/00-templates/ui-kit/uxstate/index.html) | — | 🌐 https://ui.dang.pm/uxstate/ |

---

## 3 · Decision tree — Loading

```
User triggers async action…
│
├─ Response <100ms?
│  └─ Show NOTHING. App still feels instant. (NN/g)
│
├─ 100ms – 1s?
│  └─ spinner (action) or dots (subtle inline)
│     └─ Inline button: aria-busy="true" on button
│
├─ 1s – 10s?
│  └─ skeleton (preserve layout, reduce cognitive load)
│     └─ Parent role="status" aria-label="Đang tải…"
│
├─ >10s, measurable (upload, batch)?
│  └─ progress (determinate) + ETA label
│     └─ role="progressbar" aria-valuenow=N
│
└─ >10s, unmeasurable?
   └─ progress[indeterminate] + CANCEL CTA
      └─ Avoid hopeless waits — give user agency.
```

**Why thresholds matter:** Nielsen Norman Group research (skeleton-screens article): "If a page takes less than 1 second to load, skeleton screens or spinners aren't necessary." Use spinners 2-10s for single modules, skeletons for full-page, progress bars over 10s for time estimates.

---

## 4 · Empty vs Error vs No-results — semantic difference

| Scenario | Component | Why |
|---|---|---|
| Database has 0 rows AND user has not searched | `empty--no-data` | Not an error. Help user create first row. |
| Database has rows BUT current filter returns 0 | `empty--no-results` | Not an error. Help user broaden filter. |
| API call failed (5xx / timeout / network) | `error-block` (widget) or `error-page` (route) | Real failure. Show retry. |
| Form input failed validation | `field-error` (inline) | Local error. Don't block other fields. |
| Account just created, nothing configured | `empty--first-use` | Onboarding moment. Welcoming tone. |
| `navigator.onLine === false` | `empty--offline` | Network state. NOT a server error. |

**Tone of voice (VN):** Empty = positive/inviting (`Bắt đầu mục đầu tiên`). Error = honest + actionable (`Lỗi rồi, thử lại?`). Never blame the user.

---

## 5 · Toast vs Banner vs Modal — WAI-ARIA mapping

| Pattern | Persistence | Focus | ARIA role | Use when |
|---|---|---|---|---|
| **Toast** | Transient (auto-dismiss 5s) | Never steals focus | `role="status"` (polite) or `role="alert"` (assertive) | Confirm completed action, warn about background event |
| **Banner** | Persistent until dismissed | Doesn't steal focus | `role="status"` or `role="region"` | Page-level condition user must know (token expiring, system status) |
| **Modal dialog** | Until user acts | **Traps focus** (focus-trap.js) | `role="dialog"` `aria-modal="true"` | User must decide before continuing |

**Polite vs assertive (WCAG 2.2 SC 4.1.3):**
- `aria-live="polite"` / `role="status"` → SR announces at next natural pause. Use for success/info ("5 results returned", "Đã lưu thành công").
- `aria-live="assertive"` / `role="alert"` → SR interrupts immediately. Use sparingly for warnings/errors that need immediate attention.
- `aria-atomic="true"` ensures full title+body announced together, not just the diff.

**Important:** Per WAI-ARIA APG, alerts should NOT auto-dismiss (WCAG 2.0 2.2.3 minimum). Our toast policy: severity `info`/`success` auto-dismiss 5s (low stakes); `warn`/`error` AUTO-DISMISS DISABLED in critical contexts via `timeout: 0` (callers responsible — see UXSTATE.md examples).

---

## 6 · Focus management contract

### When to trap focus
- Modal (`role="dialog"` `aria-modal="true"`)
- Drawer / sheet (off-canvas with overlay)
- Command palette
- NEVER trap for: dropdown menus, popovers, tooltips, toasts

### When to restore focus
- On overlay close → focus returns to the element that opened it
- `focus-trap.js` stores `document.activeElement` at `activate()` time → restores at `deactivate()`

### Escape semantics
- `keyboard.js` dispatches a `ux:escape` CustomEvent on `document` when user hits Esc
- Consumers (modal, drawer, toast, command palette) listen and self-dismiss
- Order: topmost overlay should dismiss first (LIFO). Our pattern: each consumer checks `if (this.isOpen) { this.close(); event.stopPropagation(); }`

### Skip link
- `keyboard.js` auto-injects `<a class="skip-link" href="#main">Bỏ qua tới nội dung</a>` if page has `<main id="main">` and no existing `.skip-link`
- Hidden until focused via Tab — visible at top-left

### `:focus-visible`
- Global rule already in `tokens/semantic.css` — DO NOT remove outline. Custom focus ring uses `--focus-ring` (brand-tinted) at 0.65 alpha (warm) / 0.85 alpha (dark)

---

## 7 · References (cited)

1. [WAI-ARIA Authoring Practices — Alert Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alert/) — role="alert" requirements, auto-dismiss caveat (WCAG 2.2.3)
2. [WAI-ARIA 1.2 — progressbar role](https://www.w3.org/TR/wai-aria-1.2/#progressbar) — aria-valuenow/min/max, indeterminate state handling
3. [WCAG 2.2 SC 4.1.3 — Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) — polite vs assertive, role=status vs role=alert distinctions
4. [Nielsen Norman Group — Skeleton Screens vs Spinners](https://www.nngroup.com/articles/skeleton-screens/) — 1s / 10s thresholds, cognitive load argument for skeletons
5. [Radix UI Toast — Accessibility patterns](https://www.radix-ui.com/primitives/docs/components/toast) — F8 viewport focus, Esc dismiss, hover-pause, swipe gesture
6. [Shopify Polaris — Banner component](https://polaris-react.shopify.com/components/feedback-indicators/banner) — severity levels (info/warning/critical/success), banner vs toast vs modal positioning
7. [Atlassian Design System — Empty State](https://atlassian.design/components/empty-state/examples) — anatomy: image + title + body + action
8. [Material Design 3 — Progress indicators](https://m3.material.io/components/progress-indicators/guidelines) — linear vs circular use-case mapping
9. [Apple Human Interface Guidelines — Progress indicators](https://developer.apple.com/design/human-interface-guidelines/progress-indicators) — bar (determinate) vs activity indicator (indeterminate)
10. [Carbon Design System — Loading patterns](https://carbondesignsystem.com/components/loading/usage/) — inline loading + skeleton placement
11. [MDN — `prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) — vestibular safety, fallback strategy

---

## 8 · Implementation notes (Cowork-specific)

- **No new hex.** All colors via `tokens/semantic.css` (`--primary`, `--danger`, `--info`, etc.). Status colors auto-track brand HSL.
- **VN voice.** Strings: `Đang tải…`, `Chưa có dữ liệu`, `Lỗi rồi, thử lại?`, `Đã lưu thành công`, `Bỏ qua tới nội dung`. Override per brand if needed.
- **`prefers-reduced-motion`.** All four loading atoms swap shimmer/rotation/pulse for static opacity dim. Toast slide-in becomes opacity fade.
- **`forced-colors`.** `tokens/semantic.css` already maps to `Canvas` / `CanvasText` — components inherit. Spinners may become invisible (border CanvasText doesn't auto-spin border-top) — accept fallback: focus on text label, animation is decorative.
- **Min 12px font** everywhere — verified in `_a11y/` audit. 13px is default for `.toast__desc`, `.field-error`.

---

## 9 · Open questions / next iteration

- **Banner component** missing — currently use sticky `error-block`. Roadmap: add `molecules/banner.html` with `info`/`warning`/`critical`/`success` per Polaris.
- **Toast keyboard nav** — Radix uses F8 to focus viewport. Not yet implemented (single-user installs are mouse-heavy). Add if usage data shows keyboard-only users.
- **Inline-loading button pattern** — current spinner-in-button is OK but doesn't disable click during loading. Wrap in component when migrating to React layer.
- **Empty state illustration** — currently emoji. Could swap to SVG with `currentColor` for token-tinted custom art per brand. Out of scope this iteration.
