sota-frontend-design
State-of-the-art web design, UI/UX, and motion engineering standards (2026). Use when building OR auditing/reviewing user interfaces: components, pages, design systems, stylesheets, themes, forms, navigation, animations, or accessibility. Covers typography, color (OKLCH, dark mod
Install
npx skills add https://github.com/martinholovsky/SOTA-skills/tree/main/skills/sota-frontend-design
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install martinholovsky-sota-skills@llmmart
git clone https://github.com/martinholovsky/SOTA-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole martinholovsky/sota-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
SOTA Frontend Design
Purpose
Make every interface this agent builds — and every interface it reviews — meet 2026 state-of-the-art for visual craft, UX, accessibility, motion, and CSS architecture. The skill has two operating modes (BUILD and AUDIT) backed by seven rules files. WCAG 2.2 AA is the hard floor in both modes; distinctive, intentional design (not template/AI-generic output) is the quality bar. Rules are written for an expert reader: imperative, numeric, with good/bad pairs.
BUILD mode
When creating or modifying UI (components, pages, styles, animations):
- Lock the system before the screens. Establish or locate the project's tokens first: type
scale, color ramps (OKLCH, semantic layer), 4/8pt space scale, radius scale, depth language,
duration/easing tokens. If a design system exists, conform to it — extend tokens rather than
inlining values. Read
rules/01+rules/03before writing the first stylesheet. - Default stack: semantic HTML → CSS grid/flex with
gap→ container queries for component responsiveness → logical properties → cascade layers. Headless library or native element (<dialog>,popover,<details>) for any interactive widget before hand-rolling. - Build all states, not the happy path. Every interactive component ships the 9-state
contract (hover, focus-visible, active, disabled, loading, error, empty, skeleton + default);
every data view ships loading/error/empty/partial/ideal (
rules/03 §4,rules/04). - Accessibility is built in, not bolted on: labels, keyboard model per APG, focus management,
live regions, contrast verified in both themes,
prefers-reduced-motionhonored from the first animation (rules/05,rules/06 §6). - Motion is purposeful: every animation justifies itself as orientation, feedback,
continuity, or status; transform/opacity only; durations 200–300ms enter / faster exit
(
rules/06). - Be distinctive on purpose: make ≥ 3 deliberate global design decisions (typeface with
character, OKLCH accent, signature element) and apply the anti-generic kill list
(
rules/07 §5). Spend personality on marketing/empty surfaces; keep product surfaces calm. - Self-check before finishing: run the relevant rules-file audit checklists against your own output. Test mentally (or actually) at 320px, 200% zoom, keyboard-only, dark mode, RTL, and with worst-case content (long strings, 0 items, 10k items).
AUDIT mode
When reviewing existing UI code, designs, or rendered pages:
- Scope pass: identify surfaces (pages/components), the token/system layer (or its absence),
and the framework. Grep for systemic smells first — they multiply findings: hex/px literals in
components,
outline: none,transition: all,user-scalable=no, placeholder-as-label, click-handler<div>s,:focuswithout:focus-visible,will-changein static CSS, physical properties (margin-left) in new code. - Per-domain pass: walk each rules file's "Audit checklist" section against the code.
Prioritize
rules/05(accessibility) — it carries legal weight — thenrules/04(UX states), then visual/system/motion. - Runtime verification where possible (Playwright/browser): keyboard-only task completion, 320px reflow, 200% zoom, both themes, reduced-motion emulation, axe scan. Static review alone misses focus order, announcement, and jank.
- Rank, don't list. Findings ordered by severity then by fix leverage (token-level fixes beat per-screen fixes).
Severity conventions
Accessibility findings rank by WCAG level × user/task impact — never by fix effort:
| Severity | Definition |
|---|---|
| Blocker | WCAG Level A failure preventing task completion for keyboard/AT users (trap, unlabeled required field, SR-silent error, invisible focus on core flow); data-loss UX (wiped form input, no-undo hard delete); broken Back button |
| Critical | Level A/AA failure with workaround (contrast < 4.5:1 body text, dialog without focus return, missing landmarks); missing error/loading states on core flows; layout broken at 320px or 200% zoom |
| Major | AA failure of limited scope; incomplete state coverage (no empty state, color-only validation); motion without reduced-motion handling; systemic token violations breaking theming; touch targets < 24px |
| Minor | AAA/best-practice gaps (24–43px targets, terse alt text); visual drift (rogue radii/shadows/font sizes); generic-template aesthetics; missing polish states |
A small diff never downgrades severity; a locked-out user never rates "Minor".
Finding format
[SEVERITY] <domain>: <one-line problem>
Where: <file:line or component/page>
Rule: <rules/NN §section> (+ WCAG SC number if a11y)
Impact: <who is affected and how>
Fix: <concrete change, ideally token/pattern-level>
Effort: trivial | small | medium | large
End every audit with: counts per severity, the top 3 highest-leverage fixes, and which checklists were NOT verified (e.g., no screen-reader pass performed) so coverage is honest.
Rules index
| File | Read this when... |
|---|---|
rules/01-typography-and-color.md |
Choosing/reviewing type scales, fluid type with clamp(), line length/height, variable fonts and loading, OKLCH palettes and ramps, contrast math (4.5:1 / 3:1), semantic color tokens, dark mode architecture |
rules/02-layout-spacing-responsive.md |
Spacing systems (4/8pt, gap-over-margin), CSS grid/subgrid layouts, container queries, breakpoints, logical properties/RTL, :has(), nesting, cascade layers, anchor positioning/popover, scrolling and overflow |
rules/03-design-systems-components.md |
Design tokens (W3C DTCG format, three tiers), theming, component API design (composition, controlled/uncontrolled, asChild), the 9-state completeness contract, headless UI layering, Storybook isolation, versioning |
rules/04-ux-patterns.md |
Forms (validation timing, autocomplete/inputmode, error recovery), loading strategy (skeletons vs spinners, optimistic UI), empty/error states, destructive actions (undo > confirm), navigation, touch targets/gestures, feedback timing |
rules/05-accessibility.md |
Anything a11y: semantic HTML vs ARIA, keyboard/focus management, focus-visible styling, dialogs/focus traps, live regions, screen-reader test matrix, WCAG 2.2 specifics, top-10 audit failures ranked, severity mapping |
rules/06-motion-design.md |
Any animation/transition: purpose test, duration/easing numbers, springs, stagger/choreography, CSS vs WAAPI vs FLIP vs Motion, View Transitions, scroll-driven animation, transform/opacity-only perf, will-change, prefers-reduced-motion |
rules/07-visual-craft-distinctiveness.md |
Visual hierarchy and scan paths, Gestalt/alignment, depth languages (borders/shadows/surfaces), icon and detail craft, avoiding generic "AI slop" aesthetics, density calibration, microcopy, visual-drift auditing |
Multiple domains usually apply — e.g., a new dialog touches 03 (API), 04 (confirm/undo), 05 (focus trap), 06 (enter/exit motion). Read every file whose trigger matches.
Top 10 non-negotiables
- WCAG 2.2 AA always: contrast 4.5:1 text / 3:1 UI (both themes), keyboard-complete, visible
:focus-visiblering, nooutline: nonewithout replacement, nouser-scalable=no. - Semantic HTML before ARIA; ARIA only per complete APG pattern (role + props + keyboard).
No click-handler
<div>s, ever. - All states or it isn't done: hover/focus-visible/active/disabled/loading/error/empty/ skeleton per component; loading/error/empty/partial/ideal per view.
- Tokens, not literals: components consume semantic tokens only — no hex, no magic px, no ad-hoc durations. Dark mode is a token swap and a redesign, never an inversion.
- Spacing from the 4/8 scale, parents own it via
gap— components carry no outer margins. - Focus is managed: dialogs trap and restore, SPA route changes move focus and set title, deletions relocate focus, errors get focused.
- Forms: visible labels (never placeholder-only), full
autocomplete/inputmodestack, validate on blur → re-validate on input, specific adjacent errors, user input never wiped. - Motion: purpose-driven, transform/opacity only, enter 200–300ms ease-out, exit faster,
prefers-reduced-motionhonored in CSS and JS — replace movement with fades, don't just delete feedback. - Undo over confirm for destructive actions; when confirming, name the object and consequence, verb-label the button, focus cancel.
- Distinctive but disciplined: ≥ 3 deliberate brand decisions, no generic-template kill-list items; usability outranks personality everywhere they conflict.
Files (sota-skills)
-
rules
-
01-typography-and-color.md 14.5 KB
# 01 — Typography & Color Typography carries ~90% of UI information; color encodes state, brand, and hierarchy. Both must be built as **systems** (scales, tokens), never as ad-hoc per-element values. ## 1. Type scale: use a ratio, cap the steps - Define a modular scale (ratio 1.2–1.333 for product UI, 1.333–1.5 for marketing/editorial). 6–8 steps max. - Name steps semantically (`--text-sm`, `--text-body`, `--text-h2`), not by pixel value. Renaming `--text-14` later is a migration; renaming what `--text-sm` resolves to is a token edit. - Body text: **16px minimum** on web (never below 14px for any persistent reading text). Legal/footnote 12px floor, sparingly. ```css /* GOOD: fluid scale, semantic names, clamp() between viewport bounds */ :root { --text-sm: clamp(0.833rem, 0.80rem + 0.15vw, 0.9rem); --text-body: clamp(1rem, 0.95rem + 0.25vw, 1.125rem); --text-lg: clamp(1.25rem, 1.15rem + 0.5vw, 1.5rem); --text-h2: clamp(1.563rem, 1.35rem + 1vw, 2.25rem); --text-h1: clamp(1.953rem, 1.55rem + 2vw, 3.25rem); } ``` ```css /* BAD: magic numbers per component, no system, vw-only (fails zoom + tiny phones) */ .card-title { font-size: 19px; } .hero h1 { font-size: 5vw; } /* unbounded; breaks WCAG 1.4.4 text resize */ ``` - **clamp() rule**: always include a `rem` term in the middle expression so user font-size preferences and browser zoom still scale the text. Pure-`vw` fluid type fails WCAG 1.4.4 (resize to 200%). - Verify: at 320px and 1920px viewports, headings don't collide or orphan; body stays 16–19px equivalent. ## 2. Line length, line height, alignment Concrete numbers — apply, don't debate: | Property | Standard | |---|---| | Line length (measure) | 45–75ch body; 60–66ch ideal. Enforce with `max-width: 65ch` on prose containers | | Line height, body | 1.5–1.7 (WCAG 1.4.12 requires content survives 1.5) | | Line height, headings | 1.1–1.25 (tighten as size grows) | | Line height, UI labels/buttons | 1.2–1.4, or `1` with padding controlling height | | Letter spacing | Headings ≥ 32px: −0.01em to −0.025em. ALL-CAPS labels: +0.04 to +0.08em. Body: 0 | | Paragraph spacing | 0.75–1.25em; never both indent and spacing | - Use **unitless** `line-height` (`1.5`, not `24px`) so it scales with font size. - Never justify text on the web (rivers, no decent hyphenation control). `text-align: left` (or `start` for i18n). - `text-wrap: balance` on headings, `text-wrap: pretty` on paragraphs — both are free wins, progressive enhancement. - Long-word overflow: `overflow-wrap: break-word` on prose containers; never let user content blow out layout. ## 3. Font loading & variable fonts - Prefer **one variable font** over 4 static weights: smaller total payload, animatable weight, optical sizing. - Self-host with `font-display: swap` (or `optional` for non-brand-critical text); `preload` only the primary text face (the one above the fold), as woff2. - Subset aggressively (`unicode-range`); a Latin subset of a variable font should be 30–80KB. - Define fallback metrics to kill CLS: `size-adjust`, `ascent-override`, `descent-override` on a local fallback `@font-face` (tools: Fontaine, Capsize). Layout shift from font swap is a real CWV regression. ```css @font-face { font-family: "Inter"; src: url("/fonts/inter-var.woff2") format("woff2"); font-weight: 100 900; /* variable axis range */ font-display: swap; } /* Metric-matched fallback prevents reflow on swap */ @font-face { font-family: "Inter Fallback"; src: local("Arial"); size-adjust: 107%; ascent-override: 90%; } ``` - `font-variation-settings` overrides ALL axes and disables high-level properties — use `font-weight` / `font-optical-sizing` first; reach for `font-variation-settings` only for custom axes (e.g. `"GRAD"`). - Numeric data (tables, timers, prices): `font-variant-numeric: tabular-nums` so digits don't jitter. ## 3b. Pairing, stacks & weight discipline - **Two families maximum** (display + text), three with a mono for code/data. Pair by contrast of *role*, not similarity: serif display + grotesque body, or one superfamily with optical sizes. If unsure, one variable family with strong weight range beats a mediocre pairing. - Weight palette: pick 3–4 stops and tokenize (`--font-regular: 400; --font-medium: 500; --font-semibold: 600; --font-display: 650`). UI emphasis = medium/semibold, not bold-700 (too heavy at small sizes in most modern faces). Never fake weights — if 500 isn't loaded or in the variable range, browsers synthesize garbage; same for `font-synthesis: none` to block faux bold/italic on fallbacks. - Full fallback stacks always, metric-compatible where possible: ```css --font-sans: "YourSans", "YourSans Fallback", system-ui, sans-serif; --font-mono: "YourMono", ui-monospace, "SF Mono", monospace; ``` - `system-ui` alone is a legitimate, fast, zero-CLS choice for product UI — but then identity must come from color/spacing/motion (rules/07 §6), and you still tokenize sizes/weights. - Hyphenation: `hyphens: auto` (with correct `lang` attribute — it's lang-dependent) only on narrow justified-adjacent columns; never on headings or UI labels. - Microtypography that signals craft: real quotes and apostrophes (' ' " "), en dash for ranges (12–16), em dash for breaks, ` ` between number and unit (16 GB), `text-decoration- thickness`/`underline-offset` tuned on links (`underline-offset: 0.15em`), `font-feature- settings: "ss01"` etc. only via tokens. - Min target for `letter-spacing` adjustments: apply via the scale tokens (e.g. `--text-h1` pairs with `--tracking-h1: -0.02em`), never sprinkled per component. ## 4. Color: author in OKLCH OKLCH is the SOTA authoring space (perceptually uniform lightness, predictable chroma, wide-gamut capable). HSL lies about lightness — HSL `yellow` and `blue` at the same L are wildly different perceived brightness. ```css /* GOOD: one hue, vary L/C for a predictable ramp; P3 where supported */ :root { --blue-600: oklch(0.55 0.18 255); --blue-700: oklch(0.48 0.17 255); --accent: oklch(0.72 0.21 25); /* gamut-maps gracefully on sRGB screens */ } .btn:hover { background: oklch(from var(--blue-600) calc(l - 0.06) c h); } /* relative color syntax */ ``` ```css /* BAD: hand-picked hexes with inconsistent perceived steps; hover via opacity hacks */ --blue-600: #2563eb; --blue-700: #1d4ed8; /* fine values, but no system to derive states */ .btn:hover { filter: brightness(0.9); } /* desaturates, unpredictable contrast */ ``` - Build palettes as **ramps**: fixed hue, stepped lightness (e.g. L 0.97 → 0.20 across 11 steps), chroma peaking mid-ramp. Steps should be perceptually even — OKLCH gives you that for free. - Hue shift across a ramp (±5–15° toward warm in lights, cool in darks) reads as more natural than a locked hue. - Keep a fallback story: OKLCH is supported everywhere modern (2023+); for legacy targets use `@supports (color: oklch(0% 0 0))` or build-time conversion. ## 4b. Deriving state colors & transparency tokens Interactive state colors are **derived, not invented** — one rule produces hover/active/selected for every accent and keeps ramps consistent: ```css /* Relative color syntax: hover = darker in light mode, LIGHTER in dark mode */ :root { --accent-bg-hover: oklch(from var(--accent-bg) calc(l - 0.05) c h); --accent-bg-active: oklch(from var(--accent-bg) calc(l - 0.09) c h); } [data-theme="dark"] { --accent-bg-hover: oklch(from var(--accent-bg) calc(l + 0.05) c h); --accent-bg-active: oklch(from var(--accent-bg) calc(l + 0.09) c h); } ``` - `color-mix(in oklch, var(--accent) 12%, var(--surface))` for tints (selected rows, badges, subtle backgrounds) — mixes stay on-theme automatically when the surface changes. Prefer `in oklch` / `in oklab`; sRGB mixing desaturates through gray. - **Alpha tokens for overlays only** (`--overlay-scrim: oklch(0 0 0 / 0.5)`, hairline borders over imagery). Don't build text colors from alpha-on-unknown-background — contrast becomes unverifiable; text tokens are opaque, computed per theme. - Selection and focus are brand surfaces too: `::selection { background: var(--accent-bg); color: var(--accent-fg); }` (check 4.5:1!) and `--focus-ring` as its own token (usually accent at full chroma, 2px, offset 2px) — one token so rules/05's ring is consistent everywhere. - Wide gamut: define P3-capable OKLCH values; browsers gamut-map for sRGB screens. For maximum control wrap vivid brand moments in `@media (color-gamut: p3)`. Never let a P3-only chroma be the sole carrier of a state difference — verify on sRGB. ## 5. Contrast math — verify, never eyeball WCAG 2.2 AA minimums (hard floor): | Element | Ratio | |---|---| | Body text (< 24px / < 18.66px bold) | **4.5:1** | | Large text (≥ 24px, or ≥ 18.66px bold) | 3:1 | | UI component boundaries & states (1.4.11) | 3:1 against adjacent colors | | Focus indicators | 3:1 against both component and background | - Non-text contrast (1.4.11) is the most-missed rule: input borders, icon-only buttons, toggle states, chart series, slider tracks — all need 3:1. - Placeholder text is text: 4.5:1 — which is why placeholder-as-label is banned (see rules/04). - Don't rely on contrast alone for state: pair color with icon/weight/underline (links in body text get underlines; color-only links fail 1.4.1). - APCA (the WCAG 3 draft model) is better math — use it to *choose* colors, but **ship against WCAG 2.x ratios** because that's what's legally testable in 2026. - Disabled controls are exempt from contrast, but if users must *read* the value of a disabled field, render it as read-only text instead. ## 6. Semantic color tokens & dark mode Two-layer (minimum) token architecture: **primitive → semantic**. Components reference semantic only. ```css /* Layer 1: primitives (raw ramps) */ :root { --gray-50: oklch(0.98 0.005 260); --gray-900: oklch(0.22 0.02 260); /* … */ } /* Layer 2: semantic — the ONLY layer components touch */ :root { --surface: var(--gray-50); --surface-raised: oklch(1 0 0); --text-primary: var(--gray-900); --text-secondary: var(--gray-600); --border-subtle: var(--gray-200); --accent-bg: var(--blue-600); --accent-fg: oklch(0.99 0.01 255); color-scheme: light dark; } [data-theme="dark"] { --surface: var(--gray-950); --surface-raised: var(--gray-900); /* raised = LIGHTER in dark mode (closer to light source) */ --text-primary: var(--gray-50); --text-secondary: var(--gray-400); --accent-bg: var(--blue-400); /* desaturate + lighten accents on dark */ } ``` Dark mode is a redesign, not an inversion: - **Never pure black** `#000` surfaces or pure white text — use near-black (`oklch(0.18–0.22)`) and off-white (`oklch(0.92–0.96)`); pure-on-pure causes halation for astigmatic users. - Elevation flips: in light mode, raised = shadow; in dark mode, raised = **lighter surface** (shadows are invisible on dark). Define `--surface`, `--surface-raised`, `--surface-overlay` as a ladder. - Reduce chroma of saturated brand colors on dark backgrounds (vibrating edges); lighten accents 1–2 ramp steps so they keep 4.5:1 *as text* / 3:1 *as UI*. - Re-verify every contrast pair in dark mode separately — passing light mode proves nothing. - Set `color-scheme: light dark` so form controls, scrollbars, and UA defaults match the theme. - Respect three states: light / dark / **system** (default). Persist explicit choice; apply before first paint (inline script or server hint) to avoid theme flash. - Images: provide dark variants where needed (`<picture>` + `prefers-color-scheme`), or temper with `filter: brightness(.9)` on glaring photos. - `light-dark()` function is fine for simple sites; token-swap via attribute scales better for systems (supports user override, >2 themes). ## 6b. Verification workflow (color) - Automate contrast over the **token matrix**, not screenshots: a script iterates every documented fg/bg token pair × every theme and fails CI under threshold. Screenshot-based checkers miss states (hover, selected) that token-level checks catch by construction. - Manual spot-checks where automation is blind: text over images/gradients (test the *lightest* region under the text — or add a scrim/`text-shadow` and stop gambling), charts, third-party embeds. - Simulate, don't guess: DevTools rendering emulation for `prefers-color-scheme`, `prefers-contrast`, `forced-colors`, and the three common color-vision deficiencies (deuteranopia ~5% of males — your largest a11y cohort after low vision). - Real-device check: OLED black smearing on dark-theme scrolling, P3 vividness vs sRGB office monitors, and auto-brightness at 30% — a 4.6:1 pair that's "fine" at full brightness is the field-failure mode. ## 7. Color semantics & restraint - One accent hue does 90% of the work. Success/warning/danger/info get their own semantic ramps, used **only** for status — never decoratively (a red marketing banner makes real errors invisible). - Gray is not neutral by default: tint grays slightly toward the brand hue (C 0.005–0.02) for cohesion; pure desaturated gray reads cheap next to chromatic accents. - 60-30-10 as sanity check: ~60% surface/neutral, ~30% secondary, ~10% accent. If a screen is >15% accent color, hierarchy has collapsed. ## Audit checklist - [ ] Type sizes come from a named scale (≤ 8 steps); no rogue `font-size` literals in components - [ ] Body text ≥ 16px, line-height 1.5–1.7 unitless, measure ≤ 75ch - [ ] Fluid type uses `clamp()` with a rem term; page is readable and un-clipped at 200% zoom (WCAG 1.4.4) and 320px width (1.4.10 reflow) - [ ] `text-spacing` override survives (1.4.12): bump line-height 1.5 / letter 0.12em / word 0.16em — nothing clips - [ ] Variable font self-hosted, subset, `font-display` set, metric-matched fallback (no font-swap CLS) - [ ] ≤ 2 text families (+mono); 3–4 tokenized weights; `font-synthesis` controlled; full fallback stacks - [ ] Tabular figures on numeric columns/timers; real quotes/dashes; tracking via scale tokens only - [ ] State colors derived via relative-color/`color-mix` rules (one definition per theme), not hand-picked per component; text tokens opaque; `::selection` and `--focus-ring` defined and contrast-checked - [ ] All text 4.5:1 (3:1 large); all UI boundaries, icons, focus rings, states 3:1 (1.4.11) — verified in BOTH themes - [ ] Links in prose distinguishable without color; status never encoded by color alone - [ ] Tokens are two-layer; components never reference primitive ramps or hex literals - [ ] Dark mode: no pure black/white, elevation = lighter surface, accents desaturated, `color-scheme` declared, no flash of wrong theme, system preference honored with manual override -
02-layout-spacing-responsive.md 14.9 KB
# 02 — Layout, Spacing & Responsive (Modern CSS) Layout in 2026 is grid-first, container-query-driven, and fluid by default. Media queries are the fallback, not the strategy. Components must be **context-independent**: they adapt to the space they get, not to the viewport. ## 1. Spacing system: 4/8pt grid, tokenized - All spacing comes from a geometric-ish scale on a 4px base: `4, 8, 12, 16, 24, 32, 48, 64, 96`. No `13px`, no `margin: 18px 22px`. - Name tokens by step (`--space-1` … `--space-9`) or t-shirt (`--space-sm`). Components reference tokens only. - **Gap over margin**: parents own spacing between children via `gap`; children never carry outer margins (margin makes components non-composable — the "margin considered harmful" rule). ```css /* GOOD: parent controls rhythm; component is margin-free */ .stack { display: flex; flex-direction: column; gap: var(--space-4); } .cluster { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; } /* BAD: child dictates external spacing; breaks in any other context */ .card { margin-bottom: 24px; } .card:last-child { margin-bottom: 0; } /* the smell that proves it */ ``` - Related items sit closer than unrelated (proximity = grouping): within-group gap ≤ ½ between-group gap. If section gap is 32, intra-card gap is ≤ 16. - Spacing communicates hierarchy more cheaply than lines/boxes. Before adding a divider or border, try doubling the gap. - Fluid spacing for page-level rhythm: `--space-section: clamp(3rem, 2rem + 4vw, 7rem);` — section padding should breathe with viewport; component-internal padding stays fixed-step. - Optical alignment beats mathematical: icons next to text need baseline/cap-height nudges (often 1–2px); text in buttons sits 1px high due to descender space — fix with asymmetric padding when it shows. ## 2. Grid is the default; flexbox is for one axis - Page/section/two-dimensional layout: `display: grid`. One-dimensional content flow (toolbars, tag rows, nav): flexbox. - Stop writing breakpointed column counts; let content decide: ```css /* GOOD: auto-responsive card grid, zero media queries */ .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(18rem, 100%), 1fr)); gap: var(--space-5); } ``` ```css /* BAD: media-query ladder restating the same intent four times */ .cards { display: grid; grid-template-columns: 1fr; } @media (min-width: 640px) { .cards { grid-template-columns: 1fr 1fr; } } @media (min-width: 1024px) { .cards { grid-template-columns: repeat(3, 1fr); } } ``` - `min(18rem, 100%)` prevents overflow below 18rem — the classic `minmax` footgun. - `auto-fill` keeps column tracks when sparse (cards stay card-sized); `auto-fit` stretches survivors. Choose deliberately. - **Subgrid** for aligning card internals across siblings (title/body/footer rows line up regardless of content length): ```css .cards > .card { display: grid; grid-row: span 3; grid-template-rows: subgrid; } ``` - Full-bleed page shell — named grid lines instead of nested max-width wrappers: ```css .page { display: grid; grid-template-columns: [full-start] minmax(var(--space-4), 1fr) [content-start] min(72rem, 100%) [content-end] minmax(var(--space-4), 1fr) [full-end]; } .page > * { grid-column: content; } .page > .bleed { grid-column: full; } ``` - Never fix heights on text-bearing containers (`height: 200px` → clipped translations/zoom). Use `min-height` and let content size the box. Aspect ratios via `aspect-ratio`, not padding hacks. ## 3. Container queries: components respond to their container Components ship with their own responsive behavior — the page doesn't micromanage them. ```css /* GOOD: card adapts to the column it's placed in, anywhere */ .card-wrap { container: card / inline-size; } .card { display: grid; gap: var(--space-3); } @container card (min-width: 28rem) { .card { grid-template-columns: 10rem 1fr; } } /* Container query units for intrinsic fluidity */ .card h3 { font-size: clamp(1.1rem, 4cqi, 1.5rem); } ``` - Rule of thumb: **media queries for page chrome** (nav collapse, sidebar visibility, density); **container queries for everything inside the layout**. - The container element can't size itself from its contents on the queried axis — wrap components in a dedicated container element. - `container-type: inline-size` is the default tool; `size` only when you must query height (rare, requires fixed height anyway). - Style queries (`@container style(--variant: compact)`) for theming/density flags passed down via custom properties — Baseline newly available since Firefox 151 (May 2026); custom-property queries only (standard-property style queries remain experimental). Feature-test for the long-tail; an Interop 2026 focus area. ## 3b. Intrinsic sizing & layout primitives Prefer content-aware keywords over hardcoded dimensions: - `min-content` / `max-content` / `fit-content()` size boxes from content; `width: fit-content` + `margin-inline: auto` centers a box at its natural width — no width guessing. - `flex: 1 1 20rem` (grow, shrink, content-informed basis) over `width: 33%`; the sidebar pattern: ```css /* Sidebar that wraps to stacked below a content floor — no media query */ .with-sidebar { display: flex; flex-wrap: wrap; gap: var(--space-5); } .with-sidebar > aside { flex: 1 1 16rem; } .with-sidebar > main { flex: 999 1 28rem; } /* huge grow factor claims the row when both fit */ ``` - Standardize a small set of layout primitives (Every-Layout style) instead of bespoke CSS per screen: **Stack** (vertical gap), **Cluster** (wrapping row), **Sidebar**, **Switcher** (row→column under a width), **Center** (measure-capped column), **Cover** (min-height hero), **Frame** (aspect-ratio media). ~7 primitives compose 90% of screens and make spacing/rhythm consistent by construction. - `aspect-ratio: 16/9` on media frames (with `object-fit: cover` on the child); never padding-top percentage hacks; never width+height pairs that fight responsive images. ## 3c. Viewport units & mobile chrome - Use **`dvh`** for full-height app shells (`min-height: 100dvh`) — `100vh` overflows under mobile browser chrome (the classic iOS bottom-bar bug). `svh` for "never jumps" conservative sizing; `lvh` rarely. - Don't build whole layouts in viewport units; they ignore container context and zoom oddly — page shell only. - Account for notches/home indicators on full-bleed fixed elements: `padding-bottom: max(var(--space-4), env(safe-area-inset-bottom))` (requires `viewport-fit=cover`). - On-screen keyboard: prefer `interactive-widget=resizes-content` (meta viewport) or the VirtualKeyboard API for chat-style inputs pinned to the bottom; test that focused inputs aren't hidden behind the keyboard. ## 4. Media queries that remain When you do write them: - **Mobile-first** (`min-width`), always. Desktop-first `max-width` overrides accumulate into specificity soup. - Breakpoints in `rem`/`em` (zoom-friendly): typically `40em / 64em / 90em`. Breakpoints follow *content breaking*, not device names. - Preference queries are mandatory plumbing: `prefers-reduced-motion` (see rules/06), `prefers-color-scheme`, `prefers-contrast: more` (strengthen borders/text), `forced-colors: active` (respect system palette: use `currentColor`, `CanvasText`, never `forced-color-adjust: none` without reason). - Pointer queries for touch affordances: `@media (pointer: coarse) { /* ≥44px targets, larger hit areas */ }` — better signal than viewport width for touch. - Avoid `@media (hover: none)` to *remove* functionality; provide alternatives instead. ## 5. Logical properties & i18n-proof layout Physical properties (`left/right/top/bottom` variants) break RTL. Default to logical: | Physical (avoid) | Logical (use) | |---|---| | `margin-left` | `margin-inline-start` | | `padding-right` | `padding-inline-end` | | `width / height` | `inline-size / block-size` (when flow-relative) | | `border-radius: 8px 0 0 8px` | `border-start-start-radius` etc. | | `text-align: left` | `text-align: start` | | `top/left` (positioned) | `inset-block-start / inset-inline-start` | - Shorthands: `margin-inline: auto`, `padding-block: var(--space-4)`, `inset: 0`. - Test with `dir="rtl"` on `<html>` once per layout; icons indicating direction (arrows, chevrons) must mirror — `[dir="rtl"] .icon-next { scale: -1 1; }`. ## 6. Modern selectors & architecture - **`:has()`** — style parents/siblings by state; removes a whole class of JS classname toggling: ```css .field:has(input:user-invalid) { --field-border: var(--danger-border); } .form:has(input:focus-visible) .hint { opacity: 1; } label:has(+ input:disabled) { color: var(--text-disabled); } ``` Keep `:has()` arguments cheap (no deep descendant scans in hot paths); it invalidates upward. - **`:user-invalid` / `:user-valid`** over `:invalid` — they wait for user interaction, so forms don't load pre-screaming red. - **Native nesting**: nest one, max two levels (states, media/container queries inside the component block). Deeper nesting recreates the SCSS specificity mess. - **Cascade layers** — declare order once, end specificity wars: ```css @layer reset, base, tokens, components, utilities, overrides; @layer components { .btn { /* loses to any utility regardless of specificity */ } } ``` Put third-party CSS into a low layer: `@import url(vendor.css) layer(vendor);`. Unlayered styles beat all layers — keep app code layered so escape hatches stay available, and audit any unlayered rule as a smell. - `:focus-visible` not `:focus` for rings (see rules/05); `:where()` to zero out specificity in resets/utilities. ## 7. Anchor positioning, popover & view transitions (progressive enhancement) - **Popover API** (`popover` attribute) for menus/tooltips/toasts: free top-layer rendering, light-dismiss, focus handling — before reaching for a positioning library. Pair with **CSS anchor positioning**: ```css .trigger { anchor-name: --menu-anchor; } [popover].menu { position: absolute; position-anchor: --menu-anchor; position-area: block-end span-inline-end; /* `inset-area` is the deprecated pre-rename alias */ position-try-fallbacks: flip-block, flip-inline; } ``` - Anchor positioning is **Baseline newly available** since January 2026 (Chromium 125+, Safari 26+, Firefox 147+) and an Interop 2026 focus area. Newly ≠ widely: keep the **feature-detect** (`@supports (anchor-name: --a)`) with a Floating UI fallback while pre-2026 browsers are in your support matrix. - View Transitions API for page/state morphs — covered in rules/06; treat as enhancement, never a functional dependency. - General rule: detect features, not browsers; build the working baseline first, layer the modern API on top. A user on the fallback path gets a *plainer* experience, never a *broken* one. ## 8. Z-index & stacking-context discipline Z-index wars are an architecture failure, not a numbers game. - Tokenize the entire z scale — ≤ 7 values, semantic names: ```css :root { --z-dropdown: 100; --z-sticky: 200; --z-drawer: 300; --z-modal: 400; --z-toast: 500; --z-tooltip: 600; } ``` A literal `z-index: 9999` in component code is an audit finding; it means someone lost a war. - Prefer the **top layer** (native `<dialog>.showModal()`, `popover`) — it renders above all z-indexes by spec, ending the problem for modals/menus/toasts entirely. - Know what creates stacking contexts (`transform`, `filter`, `opacity < 1`, `position: fixed`, `will-change`, `contain`) — the usual "z-index doesn't work" cause is a parent context, not the number. `isolation: isolate` to deliberately scope a context. - Portals are for escaping `overflow: hidden`/stacking ancestors, not a default; top-layer APIs remove most portal needs. ## 9. Responsive images & media - `<img>` always has `width`/`height` (or `aspect-ratio`) — no CLS; `loading="lazy"` + `decoding="async"` below the fold, `fetchpriority="high"` on the LCP image only. - Resolution switching via `srcset`/`sizes`; **`sizes` must reflect the rendered layout** (`sizes="(min-width: 64em) 33vw, 100vw"`) — a wrong `sizes` silently downloads 3× the bytes. - Art direction (different crops per breakpoint) via `<picture>` + `media`; format negotiation via `<picture>` + `type` (AVIF → WebP → fallback). - Background images that carry meaning belong in `<img>` (alt text, lazy-load, priority); CSS backgrounds are for decoration only. - Video: `preload="none"`/`metadata`, poster image, never autoplay with sound; autoplaying ambient video gets a pause control (WCAG 2.2.2) and is suppressed under reduced motion + `prefers-reduced-data`. ## 10. Scrolling & overflow discipline - `scrollbar-gutter: stable` on the root prevents layout shift when scrollbars appear. - Custom scroll areas: `overscroll-behavior: contain` so nested scrollers don't chain to the page; visible focus + keyboard scrollability (`tabindex="0"` + `role="region"` + `aria-label` on scrollable regions that can trap keyboard users out of content). - Snap points for carousels: `scroll-snap-type: x mandatory` + `scroll-snap-align`; never JS-hijack wheel events. - `position: sticky` over JS scroll listeners for pinned headers; check it against `overflow: hidden` ancestors (the #1 reason sticky "doesn't work"). - Sticky headers must not eat anchor targets: `scroll-margin-top: var(--header-height)` on sections; `scroll-padding-top` on the scroller — also fixes focus jumps for keyboard users. ## Audit checklist - [ ] All spacing values resolve to the 4/8 scale tokens; zero magic-number margins/paddings in component code - [ ] Components are margin-free externally; parents use `gap` (grep for `margin-bottom` + `:last-child` resets) - [ ] Card/listing grids use `auto-fill/minmax`, not media-query column ladders; `min(…, 100%)` guards present - [ ] Cross-card alignment uses subgrid where internals must line up - [ ] Reusable components respond via container queries, not viewport queries - [ ] No fixed heights on text containers; no overflow clipping at 320px width or 200% zoom (WCAG 1.4.10) - [ ] Logical properties throughout; layout verified under `dir="rtl"`; directional icons mirror - [ ] Breakpoints in em/rem, mobile-first; `pointer: coarse` honored for hit areas - [ ] Cascade layers declared; vendor CSS layered; no `!important` outside an explicit overrides layer - [ ] `prefers-contrast` and `forced-colors` don't break the UI (Windows High Contrast pass) - [ ] Popovers/menus use Popover API or equivalent top-layer + light-dismiss + Esc semantics; anchored positioning has a fallback - [ ] App shells use `dvh` not `vh`; safe-area insets handled on fixed bottom elements; keyboard doesn't cover focused inputs - [ ] Z-index values come from the ≤ 7-token scale (grep for `z-index: 9{3,}`); overlays prefer top-layer APIs; no portal sprawl - [ ] Images: dimensions/aspect-ratio set (zero CLS), correct `sizes`, modern formats via `<picture>`, lazy below fold, `fetchpriority` on LCP only; meaningful images are `<img>` with alt - [ ] `scrollbar-gutter: stable`, `overscroll-behavior` on nested scrollers, `scroll-margin` under sticky headers -
03-design-systems-components.md 17.9 KB
# 03 — Design Systems, Tokens & Component APIs A design system is an API contract, not a Figma file. Quality is measured by: can a product team build a correct, accessible, on-brand screen *without* talking to the design-system team? ## 1. Design tokens: three tiers, W3C format Token architecture (each tier references only the tier below): 1. **Primitive / global**: raw values — `color.blue.600`, `space.4`, `font.size.300`. No opinions. 2. **Semantic / alias**: meaning — `color.bg.accent`, `color.text.danger`, `radius.interactive`. *This is the public API.* 3. **Component** (optional, sparingly): `button.bg`, `card.padding` — only when a component must be themed independently. - Author in the **W3C Design Tokens format** (`*.tokens.json`, `$value`/`$type`/`$description`, aliases via `{color.blue.600}`) — the DTCG spec reached its first stable release (2025.10, Oct 2025): theming, modern color spaces, and cross-tool interop are now standardized, and Figma imports/exports variables natively in this format. Compile with Style Dictionary v5 (v5.3+ handles the 2025.10 structured color/dimension value formats; v4 supports only the pre-stable DTCG draft) to CSS custom properties, TS constants, and native platform outputs. One source of truth, generated everywhere. ```jsonc // GOOD: tokens.json (DTCG) — typed, aliased, themable { "color": { "blue": { "600": { "$type": "color", "$value": "oklch(0.55 0.18 255)" } }, "bg": { "accent": { "$type": "color", "$value": "{color.blue.600}" } } }, "duration": { "fast": { "$type": "duration", "$value": "150ms" } } } ``` ```css /* BAD: values invented inline; "system" exists only in a wiki page */ .button-primary { background: #2463eb; border-radius: 6px; padding: 9px 14px; } ``` - Components consume **semantic tokens only**. Grep test: a hex/px literal or a primitive token (`--blue-600`) inside a component file is a defect. - Theming = swapping the semantic layer (per `[data-theme]`, brand, density). If a theme needs to touch components, the semantic vocabulary is too thin — add tokens (`--surface-overlay`, `--text-on-accent`), don't fork CSS. - Token count discipline: every token must answer "when do I use this instead of its neighbor?" If `--gray-450` exists because one screen wanted it, delete it. ## 1b. Token naming & governance Name = `[domain].[concept].[variant].[state]`, read left-to-right from general to specific: `color.bg.accent.hover`, `color.text.danger`, `space.4`, `radius.interactive`, `shadow.overlay`, `duration.fast`, `font.size.300`. - Use **role words**, not appearance words, at the semantic tier: `bg.accent` not `bg.blue`; `text.danger` not `text.red-600`. Appearance names break the first time the brand color changes or a second theme lands. - Reserve a small, closed vocabulary and reuse it everywhere: `bg / fg|text / border / icon` × `default / muted / subtle / accent / danger / warning / success / info` × `hover / active / selected / disabled`. New words require review — vocabulary sprawl is how systems rot. - Pair tokens that must travel together: every `*.bg` has a matching `*.fg` (e.g. `color.bg.accent` + `color.fg.on-accent`) so contrast is preserved by construction; components always use the pair, never mix-and-match. - Governance: tokens change via PR to the tokens package (not inline app overrides), with visual regression run against all themes; additions need a usage justification; telemetry/grep before deletion. ## 2. Component API: composition over configuration Props multiply; children compose. When a component grows boolean/enum props that reorder or inject content, switch to compound components. ```tsx // BAD: configuration explosion — every new need = new prop, all consumers rebuild <Card title="Plan" subtitle="Pro" icon={<Zap/>} actionLabel="Upgrade" onAction={fn} footerAlign="right" hideDivider compact /> // GOOD: composition — layout/content decided by the consumer, slots are explicit <Card> <Card.Header icon={<Zap/>}>Plan <Card.Eyebrow>Pro</Card.Eyebrow></Card.Header> <Card.Body>…</Card.Body> <Card.Footer><Button onClick={fn}>Upgrade</Button></Card.Footer> </Card> ``` API rules: - **Spread the rest**: forward `...rest` to the underlying DOM node, merge (don't clobber) `className`/`style`, and forward `ref`. A component that swallows `data-testid`, `aria-*`, or event handlers is broken. **Exception, and it is not optional: `...rest` is the caller's leftover *props*, never a bag of untrusted data.** The moment a call site spreads something attacker-influenced — `<Card {...JSON.parse(body)}>`, an API object, parsed query params — this same forwarding hands `dangerouslySetInnerHTML` and `on*` handlers straight to the DOM node. Forward a rest object that reached you from a trusted call site; where it did not, allowlist the keys at that boundary (`sota-web-frameworks` rules/02 §5, *"never spread attacker-influenced objects onto DOM elements"* — the narrower security rule wins in its domain, and this line used to state no exception at all). - Variants as a closed set (`variant="primary" | "secondary" | "ghost"`, `size="sm" | "md" | "lg"`), styled via data attributes (`data-variant="primary"`) or a variant utility (CVA-style) — not className string math. - No boolean prop pairs that can contradict (`primary` + `secondary`); one enum. - An `asChild`/`render` escape hatch (Radix pattern) so a Button can render as `<a>` or a router Link without prop forwarding gymnastics. - Naming: events `onVerb` (`onOpenChange`, not `onToggled`); state props are nouns (`open`, `value`); past-tense booleans banned. - Polymorphism only where semantics demand (`as="h2"` on Heading); don't make everything polymorphic — it wrecks type inference and invites `<div as="button">`-grade misuse. ## 3. Controlled / uncontrolled: support both Every stateful component (input, dialog, accordion, tabs, combobox) implements the trio: `value` (controlled) / `defaultValue` (uncontrolled) / `onValueChange` (always fired). ```tsx // GOOD: useControllableState pattern function Tabs({ value: valueProp, defaultValue, onValueChange }) { const [internal, setInternal] = useState(defaultValue); const isControlled = valueProp !== undefined; const value = isControlled ? valueProp : internal; const setValue = (next) => { if (!isControlled) setInternal(next); onValueChange?.(next); }; … } ``` - Never flip between modes mid-life (React warns for a reason): `undefined` means uncontrolled forever; controlled components must always receive a value. - Same contract for open state: `open` / `defaultOpen` / `onOpenChange` — this is the de-facto standard signature (Radix, Base UI, Ark); deviating costs adopters real confusion. ## 4. State completeness: the 9-state contract A component is not done when the happy path renders. Every interactive component ships ALL of: | State | Requirement | |---|---| | Default | — | | Hover | `@media (hover: hover)` guarded; never the only affordance | | **Focus-visible** | 2px+ ring, 3:1 contrast, ≥2px offset; NEVER `outline: none` without replacement | | Active/pressed | Distinct from hover (translate/darken); `aria-pressed` where toggle | | Disabled | Visually muted AND `disabled`/`aria-disabled`; prefer aria-disabled + blocked handler when the control should stay focusable/explainable (tooltip "why") | | Loading | In-place spinner/progress, width-stable (don't collapse the button), `aria-busy`, repeat-click guarded | | Error/invalid | Border + icon + message (not color alone), `aria-invalid` + `aria-describedby` | | Empty | Real designed state (see rules/04), never blank or raw "No data" | | Skeleton/placeholder | Matches final layout dimensions (no CLS on arrival) | Data-bearing views additionally: loading / error-with-retry / empty / partial / ideal — the five UI states. An audit that finds only "ideal" implemented files a finding per missing state. - Async buttons: keep label visible or swap to spinner of identical box size; disable re-submit; restore focus context after resolution. - States stack: a focused+hovered+invalid input must look coherent — test combinations, not just singles. ## 5. Headless + styled layers Separate **behavior** (state machine, ARIA, keyboard, focus) from **skin** (tokens, CSS). - Default choice: build on a maintained headless library — **Radix UI / Base UI, React Aria, Ark UI (Zag)**, or native elements (`<dialog>`, `popover`, `<details>`) — and style with your tokens. Hand-rolling combobox/dialog/menu keyboard+ARIA behavior is weeks of work and the top source of a11y audit findings. - If you must own behavior, isolate it in hooks (`useDialog`, `useListNavigation`) with zero styling imports, so skins are swappable and behavior is testable headlessly. - Expose state as **data attributes** for styling: `data-state="open"`, `data-disabled`, `data-invalid` — CSS keys off these, not class permutations: ```css .accordion-content[data-state="open"] { /* … */ } .input[data-invalid] { border-color: var(--border-danger); } ``` - Web-component contexts: same split — logic in the element, themable surface via `::part()` + custom properties. ## 5b. Styling strategy & encapsulation Pick one styling approach per system and enforce it; mixing three is the real problem: - **Tailwind v4+**: fastest iteration; map the config to the token source (CSS-first config consuming the generated custom properties) so utilities ARE tokens; extract repeated utility strings into components, not `@apply` soup. Arbitrary values (`p-[13px]`) are the token-violation grep target. - **CSS Modules / vanilla-extract / plain CSS + layers**: best for long-lived systems; component styles key off data attributes; tokens via custom properties; zero runtime. - **Runtime CSS-in-JS** (styled-components/emotion): avoid for new systems — runtime cost, RSC/streaming friction. Zero-runtime compiled variants (vanilla-extract, Panda) are the acceptable successors. Regardless of approach: - Component owns its internals; **public theming surface is custom properties** (and `::part()` for web components): `--button-bg`, documented, stable. Consumers never reach into internal class names — that's the encapsulation contract that makes refactors safe. - Specificity stays flat (single class / `:where()` wrappers); overrides happen via the documented variables, layers handle the rest (rules/02 §6). - TypeScript: variant props derived from a single source (`VariantProps<typeof button>` with CVA or equivalent) so types, styles, and docs can't drift; discriminated unions for mutually exclusive prop sets (`{ href } | { onClick }`). ## 6. Isolation-first development (Storybook discipline) - Every component is built and reviewed **in isolation** (Storybook/Ladle/Histoire) before it lands in a page. If it only works inside one page's context, it isn't a component. - One story per meaningful state (the 9-state contract above), plus a "kitchen sink" story with overflowing text, 0 items, 10k items, RTL, dark theme, and `prefers-reduced-motion`. - Stories are test fixtures: run axe (a11y addon) and visual regression (Chromatic/Playwright screenshots) against them in CI. A state without a story is a state without a test. - Long-content stories are mandatory: German strings (+35% length), CJK, a 40-character unbroken word, emoji. Truncation policy (`text-overflow`, `line-clamp`) is a design decision, made explicit per component. ## 6b. Accessibility is a system deliverable A design system either makes products accessible by default or institutionalizes their failures at scale — there is no neutral. - Every primitive ships its APG keyboard model, name/role/state wiring, and focus behavior *inside* the component; consumers should have to work to make it inaccessible. - Required-prop enforcement: `IconButton` makes `aria-label` a required TS prop; `Input` requires `label` (with an explicit, ugly `unsafe_labelHidden` escape so omission is always a decision). - Contrast is guaranteed at the token layer (paired bg/fg tokens, §1b) and re-verified per theme in CI (automated checks over the token matrix). - The docs' keyboard table and screen-reader behavior notes are part of the public API; a behavior change there is semver-major even if no TS type changed. - System-level a11y testing: axe on every story (CI gate), keyboard tests in component integration tests (Tab/arrow/Esc flows), and one manual SR pass per new primitive before stable (rules/05 §6). ## 6c. Component API smells (audit greps) - **Prop count > ~10** on a non-primitive: configuration creep — decompose to compound parts. - **`Props` with `headerLeftIcon` / `footerButtonText`-style names**: slots being faked through config; convert to children/compound components. - **Boolean modifiers that combine ambiguously** (`small` + `large` both settable): enum. - **`onClick` on a non-interactive component** (Card, Row): wrap content in a real `<button>`/ `<a>` child instead — clickable-div at the API level reproduces the div-button a11y bug for every consumer (rules/05 §1). - **`style`/`className` accepted but not merged**, or `ref` not forwarded: integration paper cut that forces forks. - **Copy-paste siblings** (`UserCard`, `ProjectCard`, `TeamCard` at 90% overlap): extract the generic compound `Card` and compose. - **Render-prop/child-function APIs where children would do**: reserve render props for genuinely parameterized output (virtualized rows, downshift-style state exposure). - **Internal state reached via `document.querySelector` from outside**: the component is missing a controlled mode or an imperative handle (`useImperativeHandle` sparingly, for focus/scroll commands only). ## 6d. Density & multi-brand theming - Density is its own theme axis, orthogonal to color: `[data-density="compact"]` remaps the *control-height and space tokens* (`--control-h: 32px→28px`, `--space-row: 12px→8px`, `--text-control: 14px→13px`); components are written against those tokens so density is free. Touch devices force comfortable density regardless of setting (pointer: coarse, rules/02 §4). - Multi-brand: brands swap the **primitive ramp assignments** feeding the same semantic tokens (brand A maps `accent` to its blue ramp, brand B to its green ramp) plus the few brand-shape tokens (radius stance, font family). If brands diverge structurally (different component anatomy), that's two products sharing a base layer — model it that way instead of prop-flagging every component. ## 7. Versioning & change discipline - The system is a product: semver it. Renaming a prop or token is a breaking change — ship codemods or deprecation aliases (`/** @deprecated use `tone` */`) for one minor cycle. - Deprecate tokens by aliasing old → new and logging in dev builds; delete only after usage telemetry/grep hits zero. - Document every component with: anatomy diagram (slot names), props table, keyboard interaction table, and do/don't usage examples. The keyboard table is not optional — it's the a11y contract. ## 8. Component anatomy: the file contract Per component, co-located (names illustrative — consistency matters, the exact convention doesn't): ``` button/ button.tsx # behavior + markup; consumes semantic tokens only button.css # (or .module.css / variants.ts) keyed off data-attributes button.stories.tsx # every state incl. worst-case content (§6) button.test.tsx # interaction + axe; keyboard flows for interactive widgets index.ts # public surface — ONLY what's exported here is API ``` - One component, one directory, one public export point; deep imports (`from "@ds/button/button.css"`) are private and break without notice — enforce via package `exports` map. - Cross-component reuse goes through shared primitives/hooks, never sibling deep-imports. - The system package ships: ESM, types, CSS as importable layer-wrapped files, tokens as both CSS custom properties and a typed TS object. Tree-shakeable — importing Button must not pull the DatePicker's dependency graph. ## Audit checklist - [ ] Tokens exist in a source format (DTCG JSON or equivalent) and compile to all platforms; no parallel hand-maintained palettes - [ ] Three-tier token hierarchy; components reference semantic tokens only (grep for hex/px literals and primitive-tier names in component code) - [ ] Theming works by swapping the semantic layer; dark/brand themes contain zero component-level CSS forks - [ ] Token names are role-based with a closed vocabulary; bg/fg tokens paired; changes flow through the tokens package with cross-theme visual regression - [ ] One styling strategy; theming surface is documented custom properties (no consumer reaching into internal classes); no arbitrary-value utilities or `@apply` sprawl - [ ] A11y enforced by the system: required label props on unlabeled-prone primitives, APG behavior baked in, contrast guaranteed at token layer per theme - [ ] Component APIs: rest props spread + className/style merged + ref forwarded; closed variant enums; `asChild`/render escape hatch on interactive primitives - [ ] No component spreads a rest object that originated in untrusted data (`{...JSON.parse(…)}`, an API response, parsed query params) onto a DOM node — grep the call sites, not the component: the spread that injects `dangerouslySetInnerHTML` or an `on*` handler is written where the props are *built* (`sota-web-frameworks` rules/02 §5) - [ ] Stateful components implement value/defaultValue/onChange (and open/defaultOpen/onOpenChange); no mode-switching warnings in console - [ ] 9-state contract complete per interactive component; data views implement loading/error/empty/partial/ideal - [ ] Buttons in async flows: width-stable loading, double-submit guarded, `aria-busy` - [ ] Behavior comes from a headless layer (library or isolated hooks); styling keyed off data attributes - [ ] Storybook (or equivalent) covers every state incl. overflow/RTL/dark/reduced-motion; axe + visual regression run in CI - [ ] Keyboard interaction documented per component and matches WAI-ARIA APG patterns - [ ] Breaking changes semver'd with deprecation path; no silent token renames -
04-ux-patterns.md 19.6 KB
# 04 — UX Patterns: Forms, States, Feedback & Flow UX quality is mostly the unglamorous states: the error, the wait, the empty list, the fat-fingered tap. Design those first; the happy path designs itself. ## 1. Forms: structure - Every input has a **visible `<label>`** (programmatically associated via `for`/`id` or wrapping). Placeholder-as-label is banned: it vanishes on input, fails contrast, breaks autofill review, and screen readers lose context mid-edit. - Labels **above** inputs (fastest scan, best for i18n and mobile); inline-left labels only in dense desktop settings panels. - One column. Multi-column forms cause skipped fields and broken tab order. Exceptions: tightly-bound groups (city/state/zip, expiry/CVC). - Group with `<fieldset>` + `<legend>` for radio/checkbox sets and address blocks. - Mark **optional** fields ("(optional)"), not required ones with asterisk soup — most fields should be required; question every field's existence first. Each removed field measurably raises completion. - Sane widths: input width signals expected content (ZIP ≈ 6ch, not full-width). ## 2. Forms: input mechanics Always set the full attribute stack — this is free conversion and accessibility: ```html <!-- GOOD --> <label for="email">Email</label> <input id="email" name="email" type="email" autocomplete="email" inputmode="email" autocapitalize="none" spellcheck="false" required aria-describedby="email-err email-hint" /> <label for="otp">Verification code</label> <input id="otp" name="otp" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" maxlength="6" /> ``` - `autocomplete` tokens per WCAG 1.3.5 (required at AA for user-data fields): `name`, `email`, `tel`, `street-address`, `postal-code`, `cc-number`, `new-password`/`current-password`, `one-time-code`, `bday`. Never `autocomplete="off"` on identity/payment fields — browsers ignore it and users hate it. - `inputmode` controls the mobile keyboard (`numeric`, `decimal`, `tel`, `email`, `url`, `search`); `type="number"` only for true quantities (it scroll-hijacks and strips leading zeros — wrong for ZIP/OTP/card numbers). - Password fields: show/hide toggle, no paste-blocking (paste-blocking fights password managers = security harm), `minlength` honest with policy, current vs new autocomplete distinction. - Never disable the submit button as the only validation mechanism: a disabled submit with no explanation is a dead end. Allow submit, then focus the first error. ## 2a. Internationalization-aware UX i18n breakage is a layout *and* logic class of bug — design for it up front: - Reserve expansion room: German/Finnish run ~+35% over English, Russian +20%; buttons, tabs, and nav labels must wrap or the container must grow — never `overflow: hidden` on a label. Worst-case strings belong in component stories (rules/03 §6). - Never concatenate translated fragments ("You have " + n + " items") — use ICU message formatting with proper plural rules (Slavic languages have 3–4 plural forms). - Locale-format everything via `Intl.*` (NumberFormat, DateTimeFormat, RelativeTimeFormat, ListFormat): decimal commas, date order, currency placement, first day of week. - Name fields: single "Full name" beats first/last (many cultures don't split that way); no alphabetical-only validation (rejects most of the world's names). - Phone/address: country-first, then format-specific fields; postal code is not numeric everywhere and not present everywhere. - RTL covered in rules/02 §5; remember content *direction* can differ from UI *locale* — user-generated text gets `dir="auto"`. ## 2b. Choosing the right control | Situation | Control | |---|---| | 2–5 mutually exclusive options, comparison matters | Radio group / segmented control (all visible) | | 6+ options, one choice | `<select>` (native on mobile is unbeatable) or combobox if searchable | | 30+ options or open vocabulary | Combobox with type-ahead, recent/frequent first | | Multiple selections, few options | Checkbox group; many options → multi-select combobox with token chips | | On/off taking effect immediately | Switch/toggle (label states the ON meaning) | | On/off applied on submit | Checkbox — a switch inside a form that needs "Save" is a lie | | Date known exactly (birthdate) | Segmented text inputs / one masked input — calendars are terrible for known dates | | Date being chosen (booking) | Calendar picker with keyboard grid support + free-text input | | Quantity within small range | Stepper; large range → input with `inputmode="numeric"`; never sliders for precise values | | Slider use at all | Only for "feel" values (volume, brightness) with live preview; always pair with a numeric input | - Native controls first on mobile (`<select>`, `<input type="date">` where UX suffices): free platform UI, accessibility, and muscle memory. Custom replacements must clear the full APG bar (rules/05) — a styled-div select that breaks iOS scroll-wheel selection is a downgrade. - Defaults are decisions: prefill the most common/safest choice; never default-on for marketing consent (legal + trust); remember prior user choices. ## 3. Forms: validation timing & error recovery **Timing — "reward early, punish late":** - Validate a field on **blur** (first pass), not on every keystroke; once a field has erred, re-validate **on input** so the error clears the moment it's fixed. - Never validate-on-keystroke a field the user hasn't finished (flagging "invalid email" after one typed character is hostile). CSS side: `:user-invalid`, not `:invalid`. - On submit: validate all, move focus to the first invalid field, and (long forms) show a summary at top — links that focus each field. **Error message contract:** ```html <!-- GOOD: adjacent, specific, actionable, programmatically linked --> <input id="card" aria-invalid="true" aria-describedby="card-err" … /> <p id="card-err" class="field-error"> <svg aria-hidden="true">…</svg> Card number must be 16 digits — you entered 15. </p> ``` - Message says **what's wrong and how to fix it** — never "Invalid input". Adjacent to the field (not toast, not only top-of-form). Icon + color + text (not color alone). - Preserve user input on error — wiping a form on failed submit is a top-3 UX crime. Same for full-page errors: persist drafts (localStorage) for anything over ~3 fields. - Server errors map back to fields where possible; un-mappable errors render in a focused, `role="alert"` summary with a retry path. - Success feedback exists too: inline check on hard fields (username availability), clear post-submit confirmation. ## 4. Loading & perceived performance Choose by expected duration: | Wait | Pattern | |---|---| | < 300ms | Nothing — flashing indicators feel slower. Delay indicator ~300ms before showing | | 0.3–2s | **Skeleton** mirroring final layout (lists, cards, pages) or inline spinner (buttons) | | 2–10s | Skeleton/spinner + descriptive text; keep UI interactive elsewhere | | > 10s | Determinate progress bar + step labels; allow cancel/backgrounding | - **Skeletons over spinners** for content regions: must match real layout dimensions (zero shift on arrival), animate a gentle pulse/shimmer ≤ 1.5s cycle, and honor reduced motion (static blocks). A skeleton that doesn't match the loaded layout is worse than a spinner. - Avoid skeleton-ception: one skeleton pass per view; if data arrives staggered, render what's ready, placeholder only the remainder. - Show an indicator only after a ~300ms grace delay, and once shown keep it ≥ 300–500ms (min-display) — prevents the 50ms "blink" that reads as jank. - **Optimistic UI** for high-success, low-cost mutations (like, rename, toggle, reorder, add-to-list): apply instantly, sync in background, on failure roll back AND explain via toast with retry. Never optimistic for payments, sends, deletes, or anything not cheaply reversible. - Buttons: pending state in-place (spinner inside button, label preserved or swapped, width locked), `aria-busy="true"`, double-submit guarded at the handler not just visually. - Stale-while-revalidate beats blank-and-spin: show last-known data with a subtle refresh indicator. ## 5. Empty, error & zero states Empty states are onboarding surfaces, not absences. Anatomy: (1) visual (restrained), (2) one-line explanation of what would be here, (3) **primary action to fill it** — and they differ by cause: - **First use**: educate + CTA ("No projects yet — create your first project"). - **User-cleared** (filters/search → 0 results): say what was searched, offer "clear filters", suggest near-matches. Never the same copy as first-use. - **Error-empty**: distinct from genuinely empty — "Couldn't load projects" + Retry. Conflating "failed to load" with "you have none" actively misleads. Error screens/pages: plain-language what-happened, what-now (retry button, status link, support path), preserve user context/work. Error codes for support go in fine print, not headlines. ## 5b. Autosave, drafts & interruption tolerance - Long-form input (editors, multi-step forms, settings with many fields): autosave with visible status ("Saving… / Saved 12:03") or explicit save with dirty-state indicator + navigation guard. Pick one per surface; mixing both confuses ("did my toggle save?"). - Autosave cadence: debounce 1–2s after typing stops + on blur + on visibility change (`visibilitychange` → flush) — tab-close is the moment that matters. - Multi-step flows: persist per-step server-side or localStorage; returning users resume, not restart. Step navigation backward never destroys forward data until final submit. - Conflict handling for collaborative edits: last-write-wins is acceptable only with a visible "edited by X just now" warning; silent overwrites of someone else's work is a Blocker-grade trust failure. - Session expiry mid-form (WCAG 2.2.5/2.2.6 adjacent): warn before expiry, allow re-auth without losing entered data — re-auth-and-wipe is the worst version of this bug. ## 6. Destructive actions: undo > confirm - **Undo over confirmation** wherever feasible: perform the action, show a toast/snackbar "Archived — Undo" for 5–10s (soft-delete server-side, hard-delete after window). Confirmation dialogs get auto-clicked by muscle memory; undo actually prevents loss. - When confirmation is genuinely required (irreversible, shared, bulk, expensive): the dialog states the *specific* consequence ("Delete project 'Acme' and its 14 deployments?"), confirm button is verb-labeled (**"Delete project"**, never "OK/Yes"), styled destructive, and **not** the default-focused/Enter-bound action — focus the cancel. - High-cost irreversibles (delete org, drop database): type-to-confirm the resource name. Reserve it — friction inflation makes people sleepwalk through it. - Separate destructive items in menus (group at bottom, divider, danger color); never adjacent to a frequent action (delete next to edit = misclick by design — also a WCAG-flavored target-spacing problem). - Bulk destructive actions always report scope ("Delete 37 items?") and result ("37 deleted — Undo"). ## 7. Navigation & wayfinding - Communicate **where am I**: active nav state (`aria-current="page"`), document `<title>` per view, breadcrumbs for hierarchies ≥ 3 levels. - Max ~7 top-level destinations; beyond that, group or demote. Mobile: bottom tab bar for 3–5 core destinations (thumb zone); hamburger only for secondary overflow — it halves discoverability of whatever's in it. - URLs are UI: every meaningful state (selected tab, filters, search, pagination, opened record) is linkable and survives refresh/back. Back button must always behave — SPA navigation that breaks Back is a release blocker. - Search for content-heavy products: prominent, keyboard-reachable (`/` or Cmd+K), with recent queries and typo tolerance. - Progressive disclosure: defaults visible, power options behind "Advanced"; settings grouped by user task, not by internal architecture. Wizard flows for genuinely sequential tasks only — show step count and allow backtracking without data loss. ## 7b. Lists, tables & data sets - **Pagination vs infinite scroll vs load-more:** infinite scroll only for leisure feeds; it breaks footer reach, back-position, deep linking, and "where was I". Product data gets pagination (URL-addressable pages) or load-more (preserves flow AND footer). Virtualize any list > ~200 rows (with correct focus/AT behavior — virtualization that eats keyboard nav is a regression). - Restore scroll position on Back — losing position in a 500-item list is data loss UX. - Tables: sortable columns announce sort state (`aria-sort`), header sticky past one screen, row actions via trailing menu (not 6 icon buttons per row), bulk-select with header checkbox + "select all N matching" affordance, and a mobile strategy decided per table (priority columns / card collapse — never just horizontal scroll with no indicator). - Filters: applied filters visible as removable chips; result count live-announced (`role="status"`); zero-result state per rules/04 §5; filter state in the URL. - Search: debounce 200–300ms, show the query in the empty state, typo tolerance, recent searches; Esc clears; `/` or Cmd+K focuses (without hijacking when an input is already focused). ## 7c. Dialogs, drawers & disclosure surfaces Escalation ladder — use the *least* interruptive surface that fits: 1. **Inline expansion / accordion**: content belongs in the page flow. 2. **Popover/dropdown**: lightweight choice or glanceable info, anchored to trigger. 3. **Drawer/side panel**: secondary task keeping page context (detail preview, filters, forms that reference the page). 4. **Modal dialog**: response required before continuing, or focused short task (≤ ~5 fields). 5. **Full page/route**: anything longer, multi-step, or worth a URL. - Modal abuse is the most common escalation failure: editing a record in a modal-in-a-modal means the flow needed a page. **Never stack modals**; if a modal spawns a modal, redesign. - All overlays: Esc closes, scrim click closes (except mid-form with dirty state — then confirm discard), focus trapped + restored (rules/05 §3), body scroll locked without layout shift (`scrollbar-gutter`), state linkable where the content merits it (drawer with a record → URL). - Unsaved-changes guard on any dismissible surface containing user input: "Discard changes?" with Keep editing as the safe default. ## 8. Touch, gestures & ergonomics - Touch targets: **44×44px minimum** (Apple HIG; Android 48dp). WCAG 2.2 AA (2.5.8) legally requires only 24×24 *or spacing equivalent* — treat 24 as the audit floor, 44 as the build standard. Visual glyph can be smaller; pad the hit area. - ≥ 8px gap between adjacent targets; inline tap targets in text need generous `padding` + `margin` compensation. - Thumb zone: primary actions bottom-center/bottom-right on mobile; destructive away from natural rest position. Top corners are the most expensive reach. - Every gesture has a visible equivalent (WCAG 2.5.1): swipe-to-delete also exists in an overflow menu; pinch-zoom content has +/- buttons. Gestures are accelerators, never the only path. - Drag interactions need a single-pointer non-drag alternative (WCAG 2.5.7): reorder via menu ("Move up/down") or keyboard. - Pull-to-refresh and swipe gestures must not fight browser/system gestures (back-swipe edge zones). - Hover-revealed actions: also reachable by focus AND always-visible (or row-menu) on touch. `@media (hover: hover)` to gate hover-only affordances. - Disable double-tap-zoom delays the right way (`touch-action: manipulation`), never `user-scalable=no` / `maximum-scale=1` (WCAG 1.4.4 violation — users must be able to pinch-zoom). ## 8b. Keyboard shortcuts & power-user paths - Productivity tools earn shortcuts after the visible path exists: every shortcut has a menu/UI equivalent showing its binding (tooltip "Archive — E"); discoverability via a `?`-opened shortcut sheet or command palette. - Command palette (Cmd+K) is the 2026 default for power navigation in app-shaped products: fuzzy actions + navigation + recent items; it must be keyboard-complete and announce results (combobox APG pattern). - Don't bind single printable characters globally if any text input can have focus without your knowledge (embedded editors); check `event.target` and respect IME composition (`event.isComposing`). - Never override browser/system-critical bindings (Cmd+L, Cmd+W, Cmd+number tab switching); WCAG 2.1.4: single-key shortcuts must be remappable or disableable. - Sequences (G then I, Gmail-style) for navigation families; show a transient hint while the chord is pending. ## 9. Feedback & system status - Every user action gets a response within **100ms** (perceived-instant threshold) — even if it's just a pressed state while work continues. - Toasts: status updates only, auto-dismiss 4–8s, pause on hover/focus, never contain the *only* path to an action (they vanish; screen-reader users may miss them — pair with persistent UI), `role="status"` for info / `role="alert"` reserved for genuine errors. - Don't stack interrupting modals; one modal at a time, and never modal-on-load for marketing while a user has a task. - Long operations report progress honestly; fake progress bars that crawl to 90% and stall destroy trust — use indeterminate + step text if you can't estimate. ## Audit checklist - [ ] Every input: visible associated label, correct `type`, `autocomplete` (1.3.5), `inputmode`; no placeholder-only labels; no `type="number"` for codes/IDs - [ ] Validation on blur → re-validate on input after first error; `:user-invalid` not `:invalid`; submit focuses first error; errors specific, adjacent, linked via `aria-describedby` + `aria-invalid`, not color-only - [ ] Failed submit preserves all user input; server errors map to fields; multi-field forms draft-persist - [ ] No paste-blocking, no `user-scalable=no`, no dead-end disabled submit - [ ] Loading: ~300ms grace before indicators; skeletons match final layout (zero CLS); buttons width-stable + double-submit guarded; optimistic updates roll back with explanation - [ ] Empty states differentiated (first-use vs filtered-zero vs error) with action; error states always include a recovery path - [ ] Destructive: undo pattern where reversible; confirms name the object + consequence, verb-labeled button, cancel focused; type-to-confirm only for top-tier irreversibles; destructive separated from frequent actions - [ ] Right control for the job: radios ≤ 5 visible, switch only for immediate effect, no sliders for precise values, native pickers on mobile unless the custom one clears the APG bar - [ ] Lists: pagination/load-more for product data (no unforced infinite scroll), scroll restored on Back, virtualization keeps keyboard/AT working; tables have `aria-sort`, sticky headers, a deliberate mobile strategy; filters chip-visible + URL-persisted - [ ] Overlay escalation respected (inline → popover → drawer → modal → page); no stacked modals; dirty-state guards on dismiss; scroll lock without layout shift - [ ] Navigation: `aria-current`, per-view titles, working Back button, state in URL, ≤ 7 top-level items, mobile primary actions in thumb zone - [ ] Touch: 44px build standard (24px + spacing absolute floor), 8px gaps, gestures and drags have visible/keyboard alternatives, hover-only affordances gated and mirrored for touch/focus - [ ] Long-form input autosaves (with visible status) or guards navigation; tab-close flushes; session expiry preserves data; collaborative overwrites surfaced - [ ] Shortcuts: visible-path-first, discoverable bindings, single-key remappable (WCAG 2.1.4), IME/composition safe, no browser-binding theft - [ ] Feedback < 100ms on every interaction; toasts non-critical, pausable, role-correct; honest progress -
05-accessibility.md 17.5 KB
# 05 — Accessibility (WCAG 2.2 AA Floor) WCAG 2.2 AA is the legal and ethical floor (EAA in force since June 2025 — national enforcement and fines now active across EU member states, with EN 301 549 being updated to incorporate WCAG 2.2; ADA Title II deadlines extended to April 2027/2028 by DOJ interim final rule, Apr 2026 — its standard is WCAG 2.1 AA, so building to 2.2 AA exceeds it). WCAG 3.0 remains a Working Draft (March 2026 draft; Recommendation not expected before ~2028) — informative only, never the conformance target. Build to 2.2 AA by default; treat select AAA criteria (focus appearance, target size 44px) as the quality bar. Automated tools catch ~30–40% of issues — the rest is semantics, keyboard, and screen-reader verification. ## 1. Semantic HTML first; ARIA is a last resort The First Rule of ARIA: don't use ARIA if a native element does the job. Native elements ship keyboard handling, states, and AT mappings for free; ARIA only *claims* behavior — you must then implement every bit of it. ```html <!-- BAD: div soup — announces nothing, no keyboard, no states --> <div class="btn" onclick="save()">Save</div> <div class="checkbox" data-checked="false"></div> <!-- GOOD: free behavior, free semantics --> <button type="button" onclick="save()">Save</button> <input type="checkbox" id="t"><label for="t">Subscribe</label> ``` - Landmark structure on every page: one `<main>`, `<nav>` (labeled if multiple), `<header>`, `<footer>`; content lives inside landmarks. - Heading outline: exactly one `<h1>`, no skipped levels, headings describe sections (screen-reader users navigate by headings more than anything else). Style with classes, never pick heading level for its font size. - Lists are `<ul>/<ol>` (AT announces count/position); tables are `<table>` with `<th scope>` — never CSS-grid-as-table for data. - Buttons do actions, links go places: `<a href>` navigates, `<button>` mutates. A `<div role="button">` requires `tabindex="0"` + Enter + Space handlers + disabled semantics — i.e., a worse `<button>`. - "No ARIA is better than bad ARIA": incorrect ARIA actively breaks AT (WebAIM Million: pages **with** ARIA average more errors than pages without). ## 2. ARIA, when actually needed Legitimate uses: live regions, composite widgets with no native equivalent (tabs, combobox, tree), `aria-expanded`/`aria-controls`/`aria-current`/`aria-selected` state wiring, accessible names for icon-only controls. - Follow the **WAI-ARIA Authoring Practices (APG)** pattern verbatim — roles, properties, AND the full keyboard model. Half a pattern (role without keyboard) is worse than none. - Accessible-name rules: visible text > `aria-labelledby` > `aria-label`. Icon-only buttons MUST have one (`<button aria-label="Close">`). Name must contain the visible label text (2.5.3 Label in Name — voice-control users speak what they see). - `aria-hidden="true"` never on focusable elements or their ancestors; decorative SVGs get `aria-hidden="true" focusable="false"`, informative ones get `role="img"` + title/label. - `aria-expanded` lives on the trigger, not the panel. `aria-controls` is nice-to-have; `aria-expanded` is mandatory for disclosure widgets. - Don't override native semantics (`<button role="link">` smell); don't sprinkle `role="application"`/`aria-live` broadly — both hijack AT behavior. ## 2b. Recipe: the three widgets that cause 80% of ARIA bugs **Disclosure / accordion** — trigger is a real button inside the heading: ```html <h3><button aria-expanded="false" aria-controls="sect1">Shipping details</button></h3> <div id="sect1" hidden>…</div> <!-- JS: toggle hidden + aria-expanded. That's the whole pattern. --> ``` **Tabs** — roving tabindex, arrows switch, Tab leaves the tablist: ```html <div role="tablist" aria-label="Settings"> <button role="tab" aria-selected="true" id="t1" aria-controls="p1">General</button> <button role="tab" aria-selected="false" id="t2" aria-controls="p2" tabindex="-1">Billing</button> </div> <div role="tabpanel" id="p1" aria-labelledby="t1" tabindex="0">…</div> ``` Arrow Left/Right moves + selects (or moves + Enter activates — pick one model and document it); panel gets `tabindex="0"` when it has no focusable content. **Modal dialog** — use the platform: ```html <dialog aria-labelledby="dlg-title"> <h2 id="dlg-title">Rename project</h2> … </dialog> <script> trigger.onclick = () => dlg.showModal(); // traps focus, Esc works, top layer, ::backdrop dlg.onclose = () => trigger.focus(); // restore focus explicitly for older engines </script> ``` Custom (non-`<dialog>`) modals must implement: `role="dialog"` `aria-modal="true"`, labelled, focus-in on open, full trap, Esc, restore on close, background `inert`. If that list isn't fully implemented, it's a Blocker — use `<dialog>`. ## 3. Keyboard navigation & focus management Everything operable by mouse is operable by keyboard (2.1.1), with **no traps** (2.1.2) and visible focus (2.4.7 — now effectively strengthened by 2.4.11 Focus Not Obscured). Core model: - `Tab`/`Shift+Tab` between widgets; arrow keys **within** composite widgets (tabs, menus, radio groups, grids) using roving `tabindex` (active item `0`, rest `-1`) or `aria-activedescendant`. - `Enter`/`Space` activate; `Esc` closes/dismisses; `Home`/`End` jump within lists. Implement per APG table for the widget. - DOM order = visual order = tab order. Never reorder visually with CSS (`order`, absolute positioning) in ways that scramble tab flow (2.4.3 Focus Order). `tabindex` > 0 is banned. - Skip link first-focusable on every page (`<a href="#main" class="skip-link">Skip to content</a>`), visible on focus (2.4.1). **Focus visibility:** ```css /* GOOD: visible, contrasting, only for keyboard/modality-appropriate input */ :focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; } :focus:not(:focus-visible) { outline: none; } /* BAD: the single most common a11y crime */ *:focus { outline: none; } ``` Ring: ≥ 2px, 3:1 contrast against adjacent colors, offset so it's not swallowed by the component; must not be hidden under sticky headers/footers (2.4.11) — `scroll-padding` fixes this. **Focus management on state change:** - Opening a dialog → focus moves into it (first sensible element, or the dialog itself with `tabindex="-1"`); focus is **trapped** while open; closing → focus **returns to the trigger**. Native `<dialog>.showModal()` + the `inert` attribute on background give you trap + restore cheaply — prefer them. - Deleting an item → focus moves to the next item (or list container), never silently to `<body>`. - SPA route change → move focus to the new view's `<h1>` (tabindex="-1") or a skip target, and update `document.title`. Silent route swaps strand screen-reader users. - Content revealed by interaction (accordion, "show more") → focus stays on trigger with `aria-expanded` flipped; content inserted *before* current focus is a trap risk. ## 4. Forms, errors, and live regions (Forms UX in rules/04; here, the AT wiring.) - Label every control (1.3.1/3.3.2); group with fieldset/legend; required state via `required`/`aria-required`, communicated in the label too. - Errors: `aria-invalid="true"` + message linked by `aria-describedby`; identified in text (3.3.1), with suggestion (3.3.3). On submit, focus the first error. - Status messages that don't take focus use live regions (4.1.3): ```html <!-- Region must exist in DOM BEFORE content changes, or it won't announce --> <div role="status" aria-live="polite" class="sr-only" id="announcer"></div> <script>announcer.textContent = `${results.length} results found`;</script> ``` - `role="status"`/`aria-live="polite"` for results counts, autosave, toasts; `role="alert"` (assertive) strictly for errors needing immediate attention. Assertive-everything trains users to ignore announcements. - Loading regions: `aria-busy="true"` during fetch; announce completion ("Loaded 24 items") rather than relying on visual skeletons. - WCAG 2.2 specifics: **3.3.8** no cognitive-function tests for login (allow paste, support password managers, offer email/passkey over puzzles); **3.3.7** don't ask users to re-enter info already provided in the same flow. ## 5. Visual & motion criteria (cross-references) - Contrast: text 4.5:1, large 3:1, UI/graphics 3:1 — full math in rules/01 §5. Both themes. - Reflow at 320px / 400% zoom without 2-D scrolling (1.4.10); text spacing survives override (1.4.12) — rules/01–02. - `prefers-reduced-motion` honored everywhere; no flashing > 3/sec (2.3.1) — rules/06. - Target size 24px minimum + spacing (2.5.8 AA), 44px build standard — rules/04 §8. - Never `user-scalable=no`; orientation not locked (1.3.4); content works in portrait and landscape. - Autoplaying moving content > 5s gets pause/stop/hide (2.2.2); audio > 3s gets volume/stop (1.4.2). Carousels: pause control, no focus theft on rotate. - Media: captions for video (1.2.2), transcripts for audio; `alt` on every `<img>` — descriptive for informative, `alt=""` for decorative, never filename. Alt text conveys *purpose in context*, not pixel description. ## 5b. WCAG 2.2 delta — the criteria added since 2.1 These are the ones audits now fail on because checklists predate them: | SC | Level | Requirement | |---|---|---| | 2.4.11 Focus Not Obscured (Min) | AA | Focused element not *entirely* hidden by sticky headers/footers/cookie banners — `scroll-padding` + banner audit | | 2.5.7 Dragging Movements | AA | Every drag (reorder, slider, kanban, map pan) has a single-pointer non-drag alternative | | 2.5.8 Target Size (Minimum) | AA | 24×24px targets, or equivalent spacing; inline-text links exempt | | 3.2.6 Consistent Help | A | Help mechanism (chat, contact, FAQ) appears in the same relative place on every page | | 3.3.7 Redundant Entry | A | Don't ask for the same info twice in one flow — auto-populate or offer "same as above" | | 3.3.8 Accessible Authentication (Min) | AA | No cognitive test to log in: allow paste & password managers, offer OTP/passkey/magic-link instead of transcription puzzles; CAPTCHA needs alternatives | (2.4.13 Focus Appearance and 2.5.5 Target Size 44px are AAA — our build standards anyway.) ## 5c. Content accessibility: links, language, structure - Link text stands alone: "View billing settings", never "click here"/"learn more" ×7. SR users pull a links list out of context, so this is a **house rule worth keeping** — but cite it correctly: **2.4.4 (Level A) is satisfied by the link text *plus its programmatically determined context***, and the WCAG understanding doc gives a contextual "Read more" as a *passing* example. Standalone-by-itself is **2.4.9 Link Purpose (Link Only), Level AAA**. Report a contextual "learn more" as a deviation from this library's stricter preference, or against 2.4.9 — not as a 2.4.4 failure, and test the actual accessible context first ([W3C 2.4.4](https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html), verified 2026-09-16). Same visible text → same destination (3.2.4). - `lang` on `<html>` (3.1.1) and on inline foreign-language spans (3.1.2) — wrong lang makes SR pronounce gibberish and breaks hyphenation. - Data tables: `<caption>`, `<th scope="col|row">`; complex tables get `headers`/`id`; layout tables don't exist anymore — if it's not data, it's CSS. - `<title>` unique per page/view (2.4.2), most-specific-first ("Invoices — Acme Billing"). - Reading order in DOM = visual order (1.3.2); CSS `order`/grid placement must not contradict it. - Text in images banned for UI (1.4.5) — real text styles, scales, translates, and theme-switches. - Don't communicate by sensory characteristics alone ("click the green button on the right", 1.3.3); reference labels. - Autocomplete/identity inputs carry `autocomplete` tokens (1.3.5 — also a UX win, rules/04 §2). ## 6. Screen reader testing approach Automated (axe-core/Lighthouse/WAVE) in CI on every story/page — then manual, because automation can't judge name quality, focus logic, or announcement timing. Minimum manual matrix (covers ~90% of AT usage): | Screen reader | Browser | Platform | |---|---|---| | NVDA | Chrome/Firefox | Windows (largest desktop share) | | VoiceOver | Safari | macOS + iOS (test mobile!) | | TalkBack | Chrome | Android | | (Enterprise) JAWS | Chrome | Windows | Test script per flow: (1) navigate by headings (H key) and landmarks — does the page outline make sense? (2) Tab through — is every interactive element reachable, named, and state-announced ("Save, button", "Notifications, toggle, on")? (3) Complete the core task eyes-closed — forms, errors, dialogs, confirmation. (4) Trigger async states — do loads/results/errors announce? If you can't finish the task with the display off, ship is blocked. Also test: keyboard-only (no SR), 200% browser zoom, Windows High Contrast (`forced-colors`), and voice control (Label-in-Name check) on critical flows. **Tooling tiers (use all three):** 1. **CI/automated**: axe-core (jest-axe / @axe-core/playwright on stories and key pages), eslint-plugin-jsx-a11y (catches at author time), Lighthouse a11y budget in CI. Treat new violations as build failures; baseline existing debt explicitly. 2. **Assisted manual**: browser a11y tree inspector (verify computed name/role/state — the single fastest ARIA debugging tool), WAVE/Accessibility Insights guided walkthroughs, contrast pickers over the token matrix (rules/01 §6b). 3. **Human**: the SR matrix above, keyboard passes, and — for products with a11y as a real requirement — paid testing with disabled users; nothing substitutes. ## 7. Common audit failures, ranked By frequency × user impact (WebAIM Million + field experience) — use as triage order: 1. **Low-contrast text** (~84% of home pages, WebAIM Million 2026 — worsening year-over-year) — Level AA 1.4.3. High impact, trivial fix. 2. **Missing accessible names**: icon buttons, inputs without labels, links that say "click here"/"learn more" (1.1.1, 1.3.1, 2.4.4, 4.1.2). Blocks task completion outright. 3. **Keyboard inoperability / invisible focus**: div-buttons, `outline: none`, hover-only menus, focus traps in custom widgets (2.1.1, 2.4.7). Blocker-severity. 4. **No focus management** in SPAs/dialogs: focus lost on route change, not trapped/restored in modals (2.4.3). Blocker for SR/keyboard users. 5. **Broken ARIA**: states never updated (`aria-expanded` stuck), invalid role nesting, `aria-hidden` on focused content (4.1.2). 6. **Form errors not conveyed**: color-only invalid state, error text not linked, no focus move (3.3.1–3.3.3). 7. **Missing/wrong alt text** and unlabeled images of text (1.1.1, 1.4.5). 8. **Heading/landmark chaos**: no `<main>`, skipped levels, everything-is-a-div (1.3.1). 9. **Motion violations**: no reduced-motion handling, autoplaying carousels without pause (2.2.2, 2.3.3). 10. **Zoom/reflow breakage**: `user-scalable=no`, fixed-height clipping at 200%, 2-D scroll at 320px (1.4.4, 1.4.10). ## 8. Severity mapping for findings Rank accessibility findings by **WCAG level × task impact**, not by effort: - **Blocker**: Level A failure that prevents task completion for an AT/keyboard user (keyboard trap, unlabeled required input, focus-invisible checkout button, SR-silent error). Ship-stopping. - **Critical**: Level A/AA failure with major degradation but a workaround exists (missing landmarks but headings OK, contrast 3.8:1 body text, dialog without focus return). - **Major**: AA failure with moderate impact or limited scope (one icon button unnamed in a secondary flow, target 20px, reflow break on one page). - **Minor**: AAA/best-practice gap or polish (focus ring thin but present, alt text terse, 24px targets where 44 is the standard). A Level A violation on a core flow is never "Minor" because the fix is small — severity tracks the user locked out, not the diff size. ## Audit checklist - [ ] Native elements used (button/a/input/select/dialog/details); zero click-handler divs; landmarks + single h1 + unbroken heading levels - [ ] All ARIA matches APG patterns completely (role + properties + keyboard); states actually update; no aria-hidden on focusables; icon controls named; visible label ⊆ accessible name - [ ] Dialogs are `<dialog>`/showModal (or implement the full custom contract); disclosure and tabs follow the §2b recipes exactly - [ ] WCAG 2.2 delta verified: focus not obscured by sticky chrome, drag alternatives, 24px targets, consistent help placement, no redundant entry, paste-friendly auth - [ ] Link text self-describing; `lang` set (incl. inline switches); data tables have caption + scoped headers; unique titles; DOM order = reading order; no text-in-images - [ ] Full keyboard pass: everything reachable/operable, logical order, no traps, no `tabindex>0`, skip link present, Esc closes overlays - [ ] `:focus-visible` ring ≥ 2px at 3:1, never obscured by sticky chrome; `outline: none` only with replacement - [ ] Dialogs: focus moved in, trapped (inert/showModal), restored to trigger; SPA route changes move focus + update title; deletions relocate focus - [ ] Forms: labels, fieldsets, aria-invalid + describedby errors, focus-first-error, no cognitive-test logins, paste allowed - [ ] Live regions pre-mounted, polite by default, alert only for errors; async loads announce completion - [ ] Contrast verified both themes (text 4.5:1, UI 3:1); 320px/400% reflow clean; text-spacing survives; no user-scalable=no - [ ] Reduced motion honored; autoplay > 5s pausable; nothing flashes > 3/sec; captions/transcripts/alt complete - [ ] axe clean in CI AND manual pass: NVDA+Chrome, VoiceOver+Safari (incl. iOS), keyboard-only, 200% zoom, forced-colors — core task completable eyes-closed -
06-motion-design.md 20.7 KB
# 06 — Motion Design & Animation Engineering Motion is functional communication: it orients (where did this come from), gives feedback (did that work), and maintains continuity (these two views are the same object). If an animation does none of those, delete it. Decoration-only motion is cost without information. ## 1. Purpose test — every animation must answer one | Purpose | Examples | |---|---| | **Orientation** | Drawer slides from the edge it lives at; dialog scales from trigger; list item exits toward archive | | **Feedback** | Button press depression; toggle thumb travel; invalid-field shake (≤ 2 cycles, small amplitude); success check draw | | **Continuity** | Shared-element transitions (thumbnail → detail hero); reordering items glide, not teleport; tab indicator slides between tabs | | **Status** | Skeleton shimmer, progress, pull-to-refresh resistance | If the proposed animation is "the cards fade in one by one because it looks nice" on a *task* screen the user visits 50×/day — cut it. Frequency inversely bounds acceptable motion: first-run flows may celebrate; daily workflows must be near-instant. ## 2. Duration & easing: the numeric standards **Duration:** | Animation | Duration | |---|---| | Micro feedback (hover, press, toggle) | 100–150ms | | Small component (tooltip, dropdown, fade) | 150–250ms | | Standard enter (dialog, drawer, page section) | **200–300ms** | | Exit / dismiss | **~60–80% of enter** (150–200ms) — leaving needs less ceremony | | Large/full-screen transitions | 300–500ms (rarely more) | | Attention loops (skeleton pulse) | 1–1.5s cycle | Nothing interactive ever exceeds 500ms; > 700ms total reads as broken. Distance/size scales duration slightly (small element 150ms, full-screen 350ms) — never linearly. **Easing:** - **Enter: ease-out** (fast start, gentle landing) — `cubic-bezier(0, 0, 0.2, 1)` or `cubic-bezier(0.16, 1, 0.3, 1)` (expo-out, snappier). - **Exit: ease-in** or faster ease-out; users don't watch exits. - **Move/morph on screen: ease-in-out** — `cubic-bezier(0.4, 0, 0.2, 1)` (the Material "standard"). - **Never `linear`** for entrances/exits (mechanical), never default CSS `ease` for anything deliberate (too lazy at the end). `linear()` *function* is fine — it's how you encode springs in CSS. - Tokenize: `--ease-out`, `--ease-in-out`, `--duration-fast: 150ms`, `--duration-base: 250ms`. Grep-able, theme-able, and the reduced-motion override has one place to zero them. **Springs** (Motion/React Spring/WAAPI `linear()`): for gestural and interruptible motion — drag-release, sheet snapping, reorder. Springs are physics, not duration: tune `stiffness` (~170–300), `damping` (~20–30); slight overshoot ≤ 2–3% for playful contexts, critically damped for productivity UI. Springs handle mid-flight interruption gracefully — duration curves don't; if the user can interrupt it, prefer a spring. ## 3. Choreography - **Stagger** list/grid entrances 20–50ms apart, cap total ≤ 6–8 items or ~400ms overall (stagger item 30 by 50ms = 1.5s of waiting — animate the container instead, or only the items in viewport). - One hero per transition: choreograph a primary element; secondary content fades simply. Everything-animates = nothing communicates. - Enter and exit run together when swapping views (cross-fade + slight slide), exit slightly faster; incoming content may overlap the last 30–50% of the outgoing animation. - Direction encodes meaning consistently: forward navigation slides left (in LTR), back slides right; up = expand/open, down = dismiss. Don't mix metaphors per screen — mirror for RTL. - Origin matters: scale transitions grow from the trigger point (`transform-origin` set to trigger position), not from screen center — that's the orientation payload. ## 4. Technique selection: CSS vs WAAPI vs FLIP vs libraries | Use | Tool | |---|---| | State transitions, hover/focus, enter via class swap | **CSS transitions/animations** — declarative, compositor-friendly, zero JS cost | | Dynamic values, runtime control (pause/reverse/seek), sequences without a library | **WAAPI** (`element.animate()`) — runs off main thread for transform/opacity | | Layout-change animation (reorder, size change, list add/remove) | **FLIP** (First-Last-Invert-Play) — measure, invert with transform, play; or View Transitions; or Motion `layout` prop which does FLIP for you | | Page/view morphs, shared elements | **View Transitions API** (same-document; cross-document for MPA), `view-transition-name` per shared element; feature-detect, fall back to instant swap | | Springs, gestures, interruption, exit-before-unmount, complex orchestration in React | **Motion (Framer Motion)** / React Spring — don't hand-roll gesture physics | | Scroll-linked effects | CSS scroll-driven animations (`animation-timeline: scroll()/view()`) where supported; IntersectionObserver-triggered classes as the broad fallback. Never scroll-event listeners mutating style | ```css /* GOOD: enter/exit with modern CSS only — including display:none transitions */ [popover] { opacity: 0; translate: 0 8px; transition: opacity 200ms var(--ease-out), translate 200ms var(--ease-out), display 200ms allow-discrete, overlay 200ms allow-discrete; } [popover]:popover-open { opacity: 1; translate: 0; @starting-style { opacity: 0; translate: 0 8px; } } ``` ```js // GOOD: FLIP for a list reorder (or use ViewTransition / Motion layout) const first = el.getBoundingClientRect(); moveInDom(el); const last = el.getBoundingClientRect(); el.animate( [{ transform: `translate(${first.x - last.x}px, ${first.y - last.y}px)` }, { transform: "none" }], { duration: 250, easing: "cubic-bezier(0.4, 0, 0.2, 1)" } ); ``` - `@starting-style` + `transition-behavior: allow-discrete` finally make CSS-only enter/exit from `display: none` real — prefer this over mount-animation JS for simple cases. - View Transitions: name shared elements (`view-transition-name: product-image`), customize via `::view-transition-old/new(name)`; names must be unique per snapshot; long-lived names on huge lists cost memory — assign dynamically on click when lists are large. - Library budget: Motion is ~5–15KB modular (`m` + LazyMotion); a full animation library for two fades is malpractice — and hand-rolled spring math for a gesture-driven sheet is too. ## 4b. Micro-interaction catalog (the standard answers) | Interaction | Standard treatment | |---|---| | Button press | `scale(0.97)` or 1px translate-down, 100ms; release springs back 150ms | | Toggle/switch | Thumb translate 150–200ms ease-out; track color cross-fades in parallel | | Checkbox | Check path draw (SVG `stroke-dashoffset`) 200ms ease-out; box fill 100ms first | | Dropdown/menu | Fade + `scale(0.96→1)` from trigger origin + 4–8px translate, 150–200ms; exit 100–120ms fade | | Tooltip | 300–500ms hover *delay* (intent filter), then 100–150ms fade; instant for subsequent tooltips within ~500ms (warm state) | | Dialog | Scrim fade 200ms; panel fade + `scale(0.96→1)` or 8–16px rise, 250ms ease-out; exit 150–200ms | | Drawer/sheet | Slide from its edge 250–300ms expo-out; gesture-dismissible sheets use springs | | Toast | Slide + fade in from its screen edge 250ms; exit fade 150ms; stack pushes existing toasts via FLIP | | Accordion | Height via grid-template-rows `0fr→1fr` (or FLIP) 200–250ms + content fade; never `height: auto` transition hacks with magic max-height | | Tab indicator | Slide between tabs 200ms ease-in-out (FLIP or `view-transition-name`); panel cross-fade 150ms | | Reorder/drag | Lifted item: scale 1.02–1.05 + shadow raise 150ms; siblings FLIP around it (springy); drop settles with spring | | Invalid shake | translate ±4px, 2 cycles, ~250ms total — small and fast, not cartoon | | Count/number change | Old digit slides up-out, new slides up-in 200ms, `overflow: hidden`; or cross-fade; tabular-nums mandatory | ```css /* GOOD: the accordion height trick — animatable without max-height hacks */ .acc-panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 220ms var(--ease-out); } .acc-panel > div { overflow: hidden; } .acc-item[data-state="open"] .acc-panel { grid-template-rows: 1fr; } ``` ## 4c. View Transitions recipe ```js // SPA state/view swap with graceful degradation function navigate(updateDom) { const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduce || !document.startViewTransition) { updateDom(); return; } document.startViewTransition(updateDom); } ``` ```css ::view-transition-old(root) { animation: fade-out 150ms var(--ease-in) forwards; } ::view-transition-new(root) { animation: fade-in 250ms var(--ease-out); } /* Shared element: tag both states with the same name */ .product-card img { view-transition-name: var(--vt-name); } /* set per-item, on click, for long lists */ .product-hero img { view-transition-name: product-image; } ``` - Support status: same-document transitions are **Baseline newly available** (all three engines since Firefox 144, Oct 2025) — keep the `startViewTransition` gate anyway; Firefox's initial release lacks view-transition *types*. - Cross-document (MPA) transitions: `@view-transition { navigation: auto; }` in both pages — free page-morphs for server-rendered sites; same reduced-motion gate via media query wrapping. Chromium + Safari only (Firefox in progress; an Interop 2026 focus area) — enhancement, never a dependency. - Default crossfade is rarely enough; customize old/new animations or it reads as a laggy blink. Keep root transitions ≤ 250ms; shared-element morphs ≤ 350ms. - The DOM update callback runs synchronously and the page freezes during snapshot — keep the update fast; don't start transitions around slow async work (await data *first*, then transition). ## 5. Performance: the compositor contract - Animate **only `transform` and `opacity`** (plus `filter` cautiously, and `clip-path` on modern engines). These skip layout and paint. - **Never animate** `width/height/top/left/margin/padding` (layout thrash, jank on every frame) or `box-shadow`/`background` directly. Size change → FLIP or `scale`; position → `translate`; shadow → cross-fade a pseudo-element's opacity. ```css /* BAD: layout + paint per frame */ .card:hover { width: 320px; box-shadow: 0 8px 30px rgb(0 0 0 / .2); transition: all .3s; } /* GOOD: compositor-only; shadow pre-rendered on ::after, faded in */ .card { transition: translate 200ms var(--ease-out), scale 200ms var(--ease-out); } .card::after { content: ""; position: absolute; inset: 0; opacity: 0; box-shadow: var(--shadow-lg); transition: opacity 200ms var(--ease-out); } .card:hover { translate: 0 -2px; scale: 1.01; } .card:hover::after { opacity: 1; } ``` - `transition: all` is banned — it animates properties you didn't intend (including layout ones) and breaks the moment someone adds a property. - **`will-change` discipline**: apply just before animation, remove after (or via `:hover` ancestor); never blanket `will-change: transform` in stylesheets — each promoted layer costs memory; dozens of them OOM mobile tabs. CSS transforms/opacity animations get promoted automatically anyway in modern engines; explicit `will-change` is for *just-in-time* hints on known-heavy elements. - Target 60fps minimum (8ms budget on 120Hz displays); validate in DevTools Performance with 4–6× CPU throttle on a mid-tier Android profile, not your dev machine. - Animating blur (`filter: blur()`) and `backdrop-filter` is paint-expensive — keep areas small, durations short, and test on low-end hardware. - Don't animate during load/INP-critical windows; entrance animations must not block interactivity (CSS animations don't; JS rAF loops can). ## 6. Reduced motion: non-negotiable `prefers-reduced-motion: reduce` is set by users with vestibular disorders (motion can cause nausea, vertigo, migraine) — honoring it is a WCAG 2.3.3 requirement and table stakes. - Reduced ≠ none: replace movement with **opacity cross-fades**; keep feedback (a 100ms fade still confirms the click). Kill: parallax, scale/slide entrances, auto-playing carousels, scroll-jacking, background video, infinite loops. - Centralize the override so it can't be forgotten: ```css @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } } ``` …then re-introduce intentional cross-fades inside the media query where needed. (0.01ms, not 0, keeps `animationend`/`transitionend` firing so JS state machines don't hang.) - JS/libraries must check too: `matchMedia('(prefers-reduced-motion: reduce)')`, Motion's `useReducedMotion()`, and gate View Transitions (`if (reduce || !document.startViewTransition) { swap(); } else { startViewTransition(swap); }`). - Autoplaying motion > 5s requires pause/stop/hide regardless of preference (WCAG 2.2.2); nothing flashes > 3×/sec (2.3.1). - Scroll-driven/parallax effects: disable entirely under reduced motion; even small parallax is a top vestibular trigger. ## 7. Scroll-driven & ambient motion - CSS scroll-driven animations (`animation-timeline: view()`) for reveal-on-scroll, progress bars, header shrink — compositor-driven, no scroll listeners. Not yet Baseline (Chromium 115+, Safari 26+; Firefox still flag-gated as of mid-2026 — an Interop 2026 focus area). Feature-detect: `@supports (animation-timeline: view())`; fallback = content simply visible (never hidden-forever — the classic "opacity:0 until JS" failure: content must be visible if JS/modern CSS is absent). - Reveal-on-scroll: trigger once, animate ≤ 20–30px translate + fade, threshold so it fires before the element is fully in view; never re-hide on scroll-up (gimmick, hurts re-reading). - Scroll-jacking (hijacking wheel speed/direction) is banned in product UI; tolerated only in short marketing set-pieces with reduced-motion and keyboard escape paths. - Ambient/looping motion (gradients, blobs): pause when off-viewport (`animation-play-state` via IntersectionObserver) and under reduced motion; loops must be seamless and slow (≥ 8s cycle) or they read as a glitch. ## 7b. Gesture-driven motion spec (sheets, swipes, drags) Gestures need physics, thresholds, and escape hatches — not just animations: - **Direct manipulation tracks 1:1**: while the finger is down, the sheet/card follows the pointer exactly (no easing during drag); easing/springs apply only after release. - Release logic combines **distance + velocity**: dismiss if dragged past ~50% OR flicked past ~500px/s regardless of distance; otherwise spring back. Velocity-blind thresholds feel dead. - Resistance at boundaries: rubber-band overscroll (`offset = delta * 0.3`-style damping or log curve) signals the edge without a hard stop. - Interruptibility is mandatory: a new touch mid-spring captures the element at its current position/velocity (springs natively support this; duration-based tweens must be cancelled and re-derived). - Every gesture surface declares `touch-action` correctly (`pan-y` on a horizontally swipeable card) so the browser doesn't fight the gesture, and provides the non-gesture path (rules/04 §8, WCAG 2.5.7). ## 7c. Canvas, WebGL & 3D motion hygiene Inside a canvas, none of the CSS guarantees above apply — no compositor fast-path, no media queries, no automatic cleanup. You re-implement the hygiene: - **Delta-time, always.** rAF fires at the display's refresh rate — 60Hz, 120Hz (ProMotion), 144Hz — so advance animation by elapsed time from the rAF timestamp argument, never by per-frame constants, or motion runs 2× speed on 120Hz screens. Clamp large deltas (tab restore, debugger pause) so physics doesn't explode. - **Pause when hidden, stop when static.** Browsers pause rAF in background tabs, but `setInterval` tickers, physics/sim workers, and WebSocket-fed state are *not* auto-paused — handle `visibilitychange` / `document.hidden` and stop them explicitly. And don't run a 60fps loop drawing identical frames: render on demand (invalidate-on-change) when nothing is animating. - **Dispose GPU resources on unmount.** Removing a Three.js object from the scene frees nothing: call `geometry.dispose()`, `material.dispose()`, `texture.dispose()` (textures are not disposed with their material), `renderTarget.dispose()`, and `renderer.dispose()` on teardown; verify with `renderer.info.memory` across mount/unmount cycles. A SPA route that mounts a canvas leaks a whole scene per navigation without this. - **Keep draw calls low**: share geometries and materials; render repeated meshes with `InstancedMesh` (N instances, one draw call); prefer texture atlases over many small textures. - **Freeze settled simulations.** d3-force / physics layouts that keep ticking after convergence burn CPU and battery forever — stop at an alpha/energy threshold, restart on interaction. - **Reduced motion reaches inside canvas only if you put it there.** The CSS override in §6 can't see your draw loop: branch the render path on `matchMedia('(prefers-reduced-motion: reduce)')` (and listen for `change`) — render the settled/static state, still ambient loops, skip camera fly-throughs. ## 8. Motion as a system (tokens, testing, INP) - Motion tokens live beside color/space tokens: `--duration-instant/fast/base/slow` (100/150/250/400ms), `--ease-out/in/in-out/spring` — and the JS animation layer imports the *same* values (export tokens to a `motion.ts`); two sources of duration truth always drift. - Define per-pattern semantic tokens for repeated choreography (`--dialog-enter`, `--toast-exit`) so product teams compose, not invent. - Test motion like behavior: Playwright with `page.emulateMedia({ reducedMotion: 'reduce' })` asserting movement is replaced (not feedback-deleted); visual regression captures animation *end states*; for flake-free CI, force `animation-duration: 0.01ms` globally except in the dedicated motion test suite. - **INP discipline**: the 100ms feedback rule (rules/04 §9) means press feedback must not wait on JS work — CSS `:active` styles render before your handler runs; keep handlers under ~50ms or yield (`scheduler.yield()` / `setTimeout` chunking) and let the pressed state + spinner carry the wait. An entrance animation that delays event binding (JS-mounted listeners after choreography) is a sequencing bug. - Battery/CPU respect: pause ambient loops on `visibilitychange`, stop rAF loops when idle, and consider `prefers-reduced-data` for autoplaying media. An idle dashboard should be at 0% CPU — open the Performance monitor and verify. ## Audit checklist - [ ] Every animation states a purpose (orientation/feedback/continuity/status); decoration-only motion on task surfaces removed - [ ] Durations within standard bands (micro ≤ 150ms, enter 200–300ms, exit faster, nothing > 500ms interactive); values from duration/easing tokens, not literals - [ ] Ease-out on enter, ease-in/fast on exit, no `linear`/default-`ease` on deliberate motion; springs used for gestural/interruptible interactions - [ ] Stagger ≤ 50ms/item capped ~400ms total; transitions have one hero element; directional metaphors consistent (and RTL-mirrored) - [ ] Micro-interactions match the catalog standards (tooltip intent delay, accordion via 0fr→1fr, toast FLIP stacking, shake ≤ 2 cycles); no max-height transition hacks - [ ] View Transitions: customized old/new animations (no default-blink), per-item names assigned dynamically on large lists, DOM update fast and post-await, reduced-motion + support gates in place - [ ] Only transform/opacity animated (grep for transitions on width/height/top/left/margin/box-shadow and `transition: all`) - [ ] `will-change` absent from static stylesheets; applied/removed around animation only - [ ] Layout changes use FLIP/View Transitions/Motion layout — no JS animating layout properties per frame - [ ] View Transitions and scroll-driven animations feature-detected with working instant fallbacks; no content stuck at opacity 0 without JS - [ ] `prefers-reduced-motion` honored globally (CSS override + JS/matchMedia in every animation lib entry point); movement replaced by fades, parallax/autoplay killed; *end events still fire - [ ] Autoplay > 5s pausable; no flashing > 3/sec; ambient loops pause off-screen - [ ] 60fps verified under CPU throttle on mid-tier mobile profile; no animation blocks first interaction - [ ] Canvas/WebGL loops are delta-time based (rAF timestamp, not frame counts), stop on `document.hidden` and when the scene is static; force/physics sims freeze once settled - [ ] WebGL/Three.js teardown disposes geometries, materials, textures, render targets, and the renderer; `renderer.info.memory` stable across mount/unmount cycles - [ ] Reduced-motion branch exists *inside* canvas/WebGL render paths (matchMedia in JS — the CSS override doesn't reach the draw loop) -
07-visual-craft-distinctiveness.md 18.5 KB
# 07 — Visual Craft, Hierarchy & Anti-Generic Design The gap between "fine" and "excellent" UI is not features — it's hierarchy, restraint, and a recognizable point of view. This file is about making interfaces that look *designed*, not *generated*. ## 1. Visual hierarchy: engineer the scan path Users scan, then read. Decide the 1-2-3 order of every screen and enforce it with size, weight, color, and position — in that order of power. - **One primary element per screen/section.** If you can't name what a screen is *for*, neither can the user. Two equally-shouting CTAs = zero CTAs. - Hierarchy levers, cheapest first: position (top/left in LTR) → size → weight → color → motion. Spend the cheap ones before reaching for color; reserve accent color for interactive/primary (rules/01 §7). - De-emphasize the secondary instead of inflating the primary: metadata drops to `--text-secondary`, secondary buttons go ghost/outline, labels shrink before headings grow. Most "make it pop" problems are "make everything else quieter" problems. - Squint test (or blur the screenshot 5px): the primary action and page title should survive; if the screen becomes uniform gray mush, hierarchy is flat. - Text hierarchy needs ≥ 2 distinct cues between levels (e.g. size + weight); a 1px font-size difference is noise, not hierarchy. Adjacent scale steps should differ ≥ ~20%. - Numbers users compare (dashboards, pricing) get the size; their labels get small caps/secondary color — not the reverse. ## 2. Gestalt mechanics (the rules that actually do the work) - **Proximity beats borders**: things that belong together sit together (rules/02 §1). Audit any UI with > 3 nested boxes — most borders/cards can be replaced by spacing + alignment. - **Alignment**: every element aligns to *something*. Mixed center/left alignment in one section reads as sloppy instantly. Pick one text alignment per region; align numbers right in tables, with tabular figures. - **Repetition/consistency**: same radius, same shadow scale, same icon stroke width, same gap rhythm everywhere. One 6px radius among 8px radii is a defect, not a detail. - **Closure/containment budget**: each level of visual containment (card-in-card-in-panel) costs clarity. Max two levels of bordered containment; beyond that, use whitespace and headings. ## 3. Whitespace economics Whitespace is the highest-leverage, lowest-cost design material — and the first thing inexperienced builders delete to "fit more in". - Macro whitespace (between sections) sets perceived quality: section padding `clamp(3rem, 2rem + 4vw, 7rem)` on marketing pages; 24–48px between functional groups in apps. Cramped sections read as cheap regardless of how good the components are. - Whitespace asymmetry binds: heading sits closer to *its* content than to the previous section (e.g., 48px above an `h2`, 16px below). Equal space above and below a heading destroys grouping — the most common rhythm bug; fix at the stylesheet level (`h2 { margin-block: var(--space-7) var(--space-3); }`), not per page. - Padding scales with container size: a 320px card gets 16–20px padding, a full-width panel 32–48px. Tokenize as `--card-padding: clamp(1rem, 0.5rem + 2cqi, 2rem)` with container units. - Don't fear the fold: users scroll; cramming everything above 700px viewport height produces uniformly dense mush. Prioritize, then let it breathe. - Density ≠ crowding: pro tools earn density via *alignment and rhythm* (strict 4px grid, columns that line up), not by deleting padding. ## 4. Depth, borders & elevation: pick one language Choose a depth language and apply it systematically — don't mix all three per component: 1. **Borders-first** (dense, technical, "Linear-like"): 1px `--border-subtle` lines, minimal shadows, surfaces differ by ±1 ramp step. 2. **Shadows-first** (friendly, layered): tokenized shadow scale (`--shadow-sm/md/lg`), shadows imply z-order only — menus above cards above page. Realistic shadows: y-offset > blur softness, low opacity, **two-layer** (tight ambient + soft key): `0 1px 2px rgb(0 0 0 / .06), 0 8px 24px rgb(0 0 0 / .10)`. Never `0 0 Npx` halos. 3. **Surface-shift** (flat, calm): no borders/shadows; hierarchy via background lightness steps — requires a well-tuned gray ramp and is the dark-mode default anyway (rules/01 §6). - Elevation = proximity to user = interactivity/ephemerality: tooltips/menus highest, modals high, cards low, page base. An element's shadow must match its layer; random per-card shadow sizes break the physics. - Hairline borders on dark mode: borders lighten (`oklch` +0.08–0.12 L over surface), shadows mostly disappear — re-tokenize, don't reuse light-mode shadow tokens. ## 5. Icons & detail craft - One icon set, one stroke width (1.5px or 2px), one optical grid (20/24px). Mixed icon families are the fastest "unfinished" tell. Icons align to text via cap-height, optically centered (a play triangle needs a 1px right nudge). - Icon + label by default; icon-only requires a tooltip AND `aria-label`, and is reserved for universally-known glyphs (×, search, settings). - Border radius is a brand decision with a scale (`--radius-sm: 4px; --radius-md: 8px; --radius-lg: 12–16px; --radius-full`), nested radii follow **outer = inner + gap** or they look wrong. - Avatars/images: fixed `aspect-ratio`, `object-fit: cover`, designed fallback (initials with deterministic ramp color, not broken-image glyph). - Charts inherit the system: tokens for series colors (color-blind-safe ramp — verify deuteranopia), grid lines at `--border-subtle`, tabular figures, direct labels over legends when ≤ 4 series. ## 6. Avoiding "AI slop" / template genericism The generic-2024-AI look: Inter/system font on white, violet-to-blue gradient hero, three-column feature cards with emoji-grade icons, glassmorphism cards, purple glow shadows, rounded-2xl everything, centered everything, `bg-gradient-to-r from-purple-500 to-blue-500`. It reads as placeholder because it encodes zero decisions. Antidotes: - **Make 3 deliberate global decisions minimum**: a typeface with character (not the framework default — try a grotesque with personality, a serif for editorial confidence, or a mono accent for technical brands); a non-default accent hue defined in OKLCH (not tailwind-violet); one signature element (distinctive radius stance — sharp 2px OR very round; a border-first depth language; an unusual but disciplined layout grid). - Typography does branding cheaper than decoration: one display face used at real scale (clamp to 3–5rem+) with tight letter-spacing creates more identity than any gradient. - Asymmetry is a feature: not every hero is center-stacked; editorial layouts (offset grids, 5/7 splits, overlapping media) read as designed. Keep asymmetry on the grid — asymmetric ≠ unaligned. - Color courage with discipline: near-black-on-warm-paper, deep green, oxblood, cobalt — anything chosen beats default-blue. Keep the 60-30-10 budget and contrast math (rules/01). - Kill list for credibility: gradient text on body copy, glow shadows, glassmorphism on content surfaces (contrast hazard), stock 3D blob illustrations, emoji as feature icons, fake testimonials/logos, drop-shadowed everything. - Details that signal craft: real `::selection` color, custom focus ring matching brand, designed scrollbars where appropriate, correct typographic quotes ("" not ""), non-breaking spaces before units, hover states that were *decided* (not `opacity: .8`). - BUT: distinctiveness never outranks usability. A boring accessible form beats a memorable broken one. Spend personality on marketing/landing/empty states; keep product surfaces calm — identity there comes from type, spacing rhythm, and motion feel, not decoration. **Named directions that work** (pick one, commit fully — half-committed styles read as accidents): - *Editorial*: serif display + grotesque body, generous measure, rules/hairlines, restrained palette, asymmetric grids. For content-led products. - *Technical/dense*: mono or near-mono accents, borders-first depth, tight radii (2–4px), data tables as first-class citizens, dark-mode-primary. For dev/infra tools. - *Soft-depth*: layered shadows, larger radii (12–16px), warm neutrals, friendly type with real weight contrast. For consumer/prosumer. - *Brutalist-lite*: high contrast, oversized type, visible structure, minimal ornament — only with strong typographic skill; it amplifies both craft and its absence. Whatever the direction: contrast math, focus rings, and state coverage are identical. Style is the skin over the same non-negotiable skeleton. ## 7. Density & audience calibration - Pick a density target per product area and tokenize it: consumer/marketing (line-height 1.6, 16–18px base, generous `--space-section`), productivity default (1.5, 14–16px controls, 8px rhythm), data-dense pro tools (1.4, 13–14px, 4px rhythm, more borders — borders-first depth language). - Density is a token-level switch (`[data-density="compact"]` remaps space/control-height tokens), not per-screen overrides. - Tables for pro tools: row height 32–40px compact / 44–52px comfortable, right-aligned numerics, sticky header, row hover, and column alignment consistent with content type. ## 8. Content design (microcopy is UI) - Buttons say what they do: "Save changes", "Create project" — never "Submit", "OK", "Yes". Verb + object. - Sentence case for UI text (Title Case Is Harder To Read And Looks Shouty); no ALL CAPS except short eyebrow labels with letter-spacing (rules/01 §2). - Error/empty/confirmation copy: plain language, no blame ("Couldn't save — check your connection", not "Error 500: request failed"), and the next step always stated (rules/04 §5–6). - Numbers: localize formats, tabular figures in columns, units stated, relative time ("2h ago") with absolute on hover/title. - Truncation is a decision: `line-clamp` with full text reachable (title/tooltip/detail view); never truncate the disambiguating part (end of similar filenames — middle-truncate those). ## 9. Screen archetypes: hierarchy recipes **Landing/hero:** one headline (display scale, ≤ 12 words), one subline (secondary color, ≤ 2 lines), one primary CTA + at most one ghost secondary. Social proof below the fold-line, muted. Hero media supports, never competes — if the screenshot is the message, the headline shrinks. Above-the-fold answers in 5 seconds: what is it, who is it for, what do I do next. **Dashboard:** lead with the 1–3 numbers that answer "is everything OK?" (large, tabular, trend-annotated with direction + color + arrow — not color alone). Charts below, tables last. Every metric links to its drill-down. Resist the symmetric-grid-of-equal-cards trap: equal visual weight implies equal importance, which is never true — size cards by decision value. **Settings:** grouped by user task, search above ~20 settings, current value always visible in collapsed state, destructive zone separated at the bottom with its own visual treatment. Changes save explicitly with confirmation, or autosave with visible "Saved" status — never ambiguous. **Detail/record view:** title + status + primary action pinned in header; metadata in a quiet key-value block (labels `--text-secondary`, values primary); related content in tabs only when > 2 sections, otherwise stacked with headings. **Pricing:** ≤ 4 tiers, recommended tier visually elevated (one level — border + badge, not a circus), feature list aligned across tiers (subgrid, rules/02 §2), prices in display type with tabular figures, billing-period toggle adjacent to prices. ## 10. Imagery, illustration & data-viz craft - Photography: one treatment (duotone, consistent grade, or none) — mixed stock styles read as template. Faces look toward content, not off-page. Always `aspect-ratio` + `object-fit: cover` + meaningful `alt` (rules/05 §5). - Illustration: one style family, colors drawn from the system ramps (re-color vendor illustrations to brand tokens — default-purple unDraw is a kill-list item). - Gradients, when used: 2 stops of *adjacent* hues (or one hue, two lightness steps) in OKLCH — `linear-gradient(in oklch, …)` avoids the gray dead-zone of sRGB interpolation. Never gradient body text; gradient on display headline only as a deliberate signature, with solid fallback meeting contrast. - Charts: max 6 series before grouping; color-blind-safe ordered ramp (verify deuteranopia + grayscale print); direct line labels over legends; y-axis from zero for bar charts (always), truncation allowed for lines with explicit axis labels; gridlines `--border-subtle`; tooltips keyboard-reachable and the data available as table/text alternative (rules/05). - Empty/placeholder art is restrained: small, monochrome-ish, never larger than the message + CTA. ## 10b. Deceptive patterns: auto-fail list Visual/UX choices that exploit users fail the audit regardless of craft (and increasingly fail the law — FTC, DSA, GDPR consent rulings): - **Confirmshaming**: decline option worded as self-insult ("No thanks, I hate saving money"). - **Misdirection weighting**: the business-preferred option styled primary while the user-neutral option is hidden as low-contrast text ("Accept all" button vs "manage preferences" link is the canonical consent-banner violation — equal prominence required). - **Fake urgency/scarcity**: countdowns that reset, "3 left!" without inventory truth. - **Roach motel**: one-click subscribe, support-call cancel. Cancellation parity is law in multiple jurisdictions now. - **Preselected upsells** sneaked into flows; **disguised ads** styled as content/UI. - **Visual interference on destructive consent**: making "Delete my data" look disabled, or swapping button positions mid-flow so muscle memory misfires. These rate **Critical** in audit findings (trust/legal exposure), Blocker when they gate consent or cancellation. ## 11. Visual QA: the recurring pixel bugs The defects that survive code review because they need eyes, not grep: - **Misalignment by 1–4px**: icon not optically centered in its button; label baseline off from adjacent input text; card grids where one column is 1px wider (fractional rounding — prefer `fr` units and `gap` over percentage + margin math). - **Inconsistent paddings between sibling components** (button 12/16, adjacent input 10/14): control-height token (`--control-h`) + centered content fixes the class of bug. - **Text overflow unhandled**: usernames/emails breaking layouts; missing `min-width: 0` on flex children (the #1 cause of "ellipsis doesn't work"). - **Focus/hover states clipped** by `overflow: hidden` parents — outline-offset needs room; test focus on first/last items in scrollable containers. - **Image aspect distortion**: missing `object-fit: cover` when dimensions are constrained. - **Dark-mode leftovers**: hardcoded white shadows, light-only logos, un-themed scrollbars/ selection, `<img>` with white background on transparent-expected assets. - **Zoom/loupe pass**: review key screens at 200% screenshot zoom; sub-pixel borders (`0.5px`), blurry icons (non-integer sizing), and gradient banding show up immediately. ## 12. Audit rubric (visual quality grading) Score each dimension 1–5 when a holistic verdict is requested; below 3 on any dimension generates findings: | Dimension | 5 looks like | |---|---| | Hierarchy | Blur test passes every screen; one primary per view; scan order matches task order | | Consistency | Token-clean greps; drift counts within budget (≤ 8 sizes / ≤ 4 radii / ≤ 3 shadows) | | Spacing/rhythm | 4/8 grid throughout; proximity grouping correct; heading asymmetry right | | Craft details | Optical alignment, icon discipline, microtypography, designed states (incl. focus/selection) | | Distinctiveness | ≥ 3 identifiable decisions; no kill-list items; coherent named direction | | Robustness | Worst-case content, both themes, 320px–4K, RTL all hold | ## 13. Build vs audit calibration **Building**: lock global decisions first (type pair + scale, color ramps, space scale, radius, depth language, density) as tokens — *then* build screens. Retro-fitting a system onto 40 ad-hoc screens costs 10× more. Design the worst-case screen early (longest names, 0 items, 10k items, German strings) — pretty averages hide broken extremes. **Auditing**: screenshot key screens and grade against this file: blur test for hierarchy; count distinct font sizes (> 8 = scale erosion), radii (> 4 = drift), shadow styles (> 3 = drift), grays (> 10 = no ramp); check icon family consistency; identify the depth language (or absence); flag generic-template signals from §5. Visual drift findings are **Major** when they break component reuse/consistency, **Minor** when cosmetic. ## Audit checklist - [ ] Each screen has one identifiable primary action; squint/blur test preserves title + CTA; secondary content visibly de-emphasized - [ ] Text hierarchy levels differ by ≥ 2 cues; adjacent sizes ≥ ~20% apart; no 1px-difference pseudo-hierarchy - [ ] Grouping by proximity; ≤ 2 levels of bordered containment; all elements aligned to the grid (no mixed center/left in a region); table numerics right-aligned + tabular - [ ] One depth language applied consistently; shadows tokenized, two-layer, y > blur logic; dark mode re-tokenizes borders/elevation - [ ] One icon family/stroke/grid; icon-only buttons rare + tooltipped + labeled; radius scale ≤ 4 values, nested radii = outer − gap - [ ] No generic-template kill-list items (gradient hero default, glow shadows, glassmorphism content, emoji icons); ≥ 3 deliberate brand decisions identifiable (type, hue, signature element) - [ ] Distinctiveness spent on marketing/empty surfaces; product surfaces calm and consistent - [ ] Density tokenized and consistent per area; data tables meet row-height/alignment standards - [ ] Microcopy: verb+object buttons, sentence case, blame-free errors with next step, localized numbers, intentional truncation - [ ] Drift counts within budget: ≤ 8 font sizes, ≤ 4 radii, ≤ 3 shadows, grays from one ramp; worst-case content (long/empty/huge) renders correctly - [ ] Whitespace: section rhythm generous and tokenized; heading spacing asymmetric (closer to its content); padding scales with container - [ ] Screen archetypes follow their hierarchy recipes (hero: one CTA; dashboard: lead metrics sized by value; settings: task-grouped with visible save state) - [ ] Pixel pass clean: no 1px misalignments, `min-width: 0` on truncating flex children, focus rings unclipped, images `object-fit` correct, no dark-mode leftovers at 200% zoom - [ ] Charts/imagery: one photo/illustration treatment, OKLCH-interpolated gradients only, color-blind-safe series, bar charts zero-based, data has text alternatives
-
-
SKILL.md 9.4 KB
--- name: sota-frontend-design description: > State-of-the-art web design, UI/UX, and motion engineering standards (2026). Use when building OR auditing/reviewing user interfaces: components, pages, design systems, stylesheets, themes, forms, navigation, animations, or accessibility. Covers typography, color (OKLCH, dark mode), spacing/layout (grid, container queries, subgrid), design tokens and component APIs, UX patterns (forms, loading/empty/error states, destructive actions), WCAG 2.2 AA accessibility, motion design, and responsive/modern CSS. Trigger keywords: UI, UX, web design, CSS, component, accessibility, a11y, responsive, animation, motion design, design system, layout, typography, dark mode, design tokens, frontend review, WCAG. --- # SOTA Frontend Design ## Purpose Make every interface this agent builds — and every interface it reviews — meet 2026 state-of-the-art for visual craft, UX, accessibility, motion, and CSS architecture. The skill has two operating modes (BUILD and AUDIT) backed by seven rules files. WCAG 2.2 AA is the hard floor in both modes; distinctive, intentional design (not template/AI-generic output) is the quality bar. Rules are written for an expert reader: imperative, numeric, with good/bad pairs. ## BUILD mode When creating or modifying UI (components, pages, styles, animations): 1. **Lock the system before the screens.** Establish or locate the project's tokens first: type scale, color ramps (OKLCH, semantic layer), 4/8pt space scale, radius scale, depth language, duration/easing tokens. If a design system exists, conform to it — extend tokens rather than inlining values. Read `rules/01` + `rules/03` before writing the first stylesheet. 2. **Default stack:** semantic HTML → CSS grid/flex with `gap` → container queries for component responsiveness → logical properties → cascade layers. Headless library or native element (`<dialog>`, `popover`, `<details>`) for any interactive widget before hand-rolling. 3. **Build all states, not the happy path.** Every interactive component ships the 9-state contract (hover, focus-visible, active, disabled, loading, error, empty, skeleton + default); every data view ships loading/error/empty/partial/ideal (`rules/03 §4`, `rules/04`). 4. **Accessibility is built in, not bolted on:** labels, keyboard model per APG, focus management, live regions, contrast verified in both themes, `prefers-reduced-motion` honored from the first animation (`rules/05`, `rules/06 §6`). 5. **Motion is purposeful:** every animation justifies itself as orientation, feedback, continuity, or status; transform/opacity only; durations 200–300ms enter / faster exit (`rules/06`). 6. **Be distinctive on purpose:** make ≥ 3 deliberate global design decisions (typeface with character, OKLCH accent, signature element) and apply the anti-generic kill list (`rules/07 §5`). Spend personality on marketing/empty surfaces; keep product surfaces calm. 7. **Self-check before finishing:** run the relevant rules-file audit checklists against your own output. Test mentally (or actually) at 320px, 200% zoom, keyboard-only, dark mode, RTL, and with worst-case content (long strings, 0 items, 10k items). ## AUDIT mode When reviewing existing UI code, designs, or rendered pages: 1. **Scope pass:** identify surfaces (pages/components), the token/system layer (or its absence), and the framework. Grep for systemic smells first — they multiply findings: hex/px literals in components, `outline: none`, `transition: all`, `user-scalable=no`, placeholder-as-label, click-handler `<div>`s, `:focus` without `:focus-visible`, `will-change` in static CSS, physical properties (`margin-left`) in new code. 2. **Per-domain pass:** walk each rules file's "Audit checklist" section against the code. Prioritize `rules/05` (accessibility) — it carries legal weight — then `rules/04` (UX states), then visual/system/motion. 3. **Runtime verification where possible** (Playwright/browser): keyboard-only task completion, 320px reflow, 200% zoom, both themes, reduced-motion emulation, axe scan. Static review alone misses focus order, announcement, and jank. 4. **Rank, don't list.** Findings ordered by severity then by fix leverage (token-level fixes beat per-screen fixes). ### Severity conventions Accessibility findings rank by **WCAG level × user/task impact** — never by fix effort: | Severity | Definition | |---|---| | **Blocker** | WCAG Level A failure preventing task completion for keyboard/AT users (trap, unlabeled required field, SR-silent error, invisible focus on core flow); data-loss UX (wiped form input, no-undo hard delete); broken Back button | | **Critical** | Level A/AA failure with workaround (contrast < 4.5:1 body text, dialog without focus return, missing landmarks); missing error/loading states on core flows; layout broken at 320px or 200% zoom | | **Major** | AA failure of limited scope; incomplete state coverage (no empty state, color-only validation); motion without reduced-motion handling; systemic token violations breaking theming; touch targets < 24px | | **Minor** | AAA/best-practice gaps (24–43px targets, terse alt text); visual drift (rogue radii/shadows/font sizes); generic-template aesthetics; missing polish states | A small diff never downgrades severity; a locked-out user never rates "Minor". ### Finding format ``` [SEVERITY] <domain>: <one-line problem> Where: <file:line or component/page> Rule: <rules/NN §section> (+ WCAG SC number if a11y) Impact: <who is affected and how> Fix: <concrete change, ideally token/pattern-level> Effort: trivial | small | medium | large ``` End every audit with: counts per severity, the top 3 highest-leverage fixes, and which checklists were NOT verified (e.g., no screen-reader pass performed) so coverage is honest. ## Rules index | File | Read this when... | |---|---| | `rules/01-typography-and-color.md` | Choosing/reviewing type scales, fluid type with clamp(), line length/height, variable fonts and loading, OKLCH palettes and ramps, contrast math (4.5:1 / 3:1), semantic color tokens, dark mode architecture | | `rules/02-layout-spacing-responsive.md` | Spacing systems (4/8pt, gap-over-margin), CSS grid/subgrid layouts, container queries, breakpoints, logical properties/RTL, `:has()`, nesting, cascade layers, anchor positioning/popover, scrolling and overflow | | `rules/03-design-systems-components.md` | Design tokens (W3C DTCG format, three tiers), theming, component API design (composition, controlled/uncontrolled, asChild), the 9-state completeness contract, headless UI layering, Storybook isolation, versioning | | `rules/04-ux-patterns.md` | Forms (validation timing, autocomplete/inputmode, error recovery), loading strategy (skeletons vs spinners, optimistic UI), empty/error states, destructive actions (undo > confirm), navigation, touch targets/gestures, feedback timing | | `rules/05-accessibility.md` | Anything a11y: semantic HTML vs ARIA, keyboard/focus management, focus-visible styling, dialogs/focus traps, live regions, screen-reader test matrix, WCAG 2.2 specifics, top-10 audit failures ranked, severity mapping | | `rules/06-motion-design.md` | Any animation/transition: purpose test, duration/easing numbers, springs, stagger/choreography, CSS vs WAAPI vs FLIP vs Motion, View Transitions, scroll-driven animation, transform/opacity-only perf, will-change, prefers-reduced-motion | | `rules/07-visual-craft-distinctiveness.md` | Visual hierarchy and scan paths, Gestalt/alignment, depth languages (borders/shadows/surfaces), icon and detail craft, avoiding generic "AI slop" aesthetics, density calibration, microcopy, visual-drift auditing | Multiple domains usually apply — e.g., a new dialog touches 03 (API), 04 (confirm/undo), 05 (focus trap), 06 (enter/exit motion). Read every file whose trigger matches. ## Top 10 non-negotiables 1. **WCAG 2.2 AA always**: contrast 4.5:1 text / 3:1 UI (both themes), keyboard-complete, visible `:focus-visible` ring, no `outline: none` without replacement, no `user-scalable=no`. 2. **Semantic HTML before ARIA**; ARIA only per complete APG pattern (role + props + keyboard). No click-handler `<div>`s, ever. 3. **All states or it isn't done**: hover/focus-visible/active/disabled/loading/error/empty/ skeleton per component; loading/error/empty/partial/ideal per view. 4. **Tokens, not literals**: components consume semantic tokens only — no hex, no magic px, no ad-hoc durations. Dark mode is a token swap and a redesign, never an inversion. 5. **Spacing from the 4/8 scale, parents own it via `gap`** — components carry no outer margins. 6. **Focus is managed**: dialogs trap and restore, SPA route changes move focus and set title, deletions relocate focus, errors get focused. 7. **Forms**: visible labels (never placeholder-only), full `autocomplete`/`inputmode` stack, validate on blur → re-validate on input, specific adjacent errors, user input never wiped. 8. **Motion**: purpose-driven, transform/opacity only, enter 200–300ms ease-out, exit faster, `prefers-reduced-motion` honored in CSS *and* JS — replace movement with fades, don't just delete feedback. 9. **Undo over confirm** for destructive actions; when confirming, name the object and consequence, verb-label the button, focus cancel. 10. **Distinctive but disciplined**: ≥ 3 deliberate brand decisions, no generic-template kill-list items; usability outranks personality everywhere they conflict.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.