Add local agent workspace files and skills

Includes .agents/, additional .claude/skills/, and skills-lock.json.
This commit is contained in:
kbe
2026-07-22 02:24:51 +02:00
parent a34389fbba
commit 286bb9cab1
23 changed files with 6055 additions and 0 deletions
+582
View File
@@ -0,0 +1,582 @@
---
name: interface-kit
description: |
Authoritative guide for implementing stunning, accessible, performant UI. Synthesizes
design engineering philosophy, accessibility standards, animation principles, spatial design,
typography, color systems, and component craft into a single actionable reference.
Complements the design-system skill (which covers DESIGN.md spec writing) by covering
the HOW of implementation.
Trigger phrases: "build UI", "create component", "landing page", "make it look good",
"frontend", "design", "polish UI", "implement design", "make it beautiful",
"UI implementation", "component styling", "animation", "accessibility"
---
# Interface Kit: Implementation Guide for Exceptional Interfaces
> If a DESIGN.md exists at the project root, its tokens and specifications override all defaults in this skill. This skill provides sensible defaults for when no design system exists, and implementation guidance that applies regardless.
> For deep dives on any section, see the reference files in this skill's `references/` directory.
---
## 1. Core Philosophy
Taste is trained, not innate. Study why great interfaces feel right. Deconstruct apps you admire — the spacing, the timing, the weight of a shadow. The gap between "fine" and "exceptional" is built from hundreds of micro-decisions that users feel but never consciously notice.
**Unseen details compound.** A single rounded corner, a single eased transition, a single well-chosen shadow — none of these matter alone. Together they become "a thousand barely audible voices singing in tune." The cumulative effect is what separates craft from output.
**Beauty is leverage.** Polish is not vanity. Good defaults, considered typography, and intentional motion are real differentiators. Users trust interfaces that feel cared for. Investors notice. Competitors can't easily replicate taste.
**Intentionality over intensity.** Both bold maximalism and refined minimalism work — what fails is the absence of a clear point of view. Every visual decision should trace back to a deliberate conceptual direction. If you can't articulate WHY a choice was made, reconsider it.
**Choose a direction and execute with precision.** Don't hedge between styles. A brutalist page committed fully will always outperform a page that's "a little bit of everything." Commit, then refine.
**NEVER produce generic "AI slop" aesthetics.** No gratuitous gradients on white backgrounds. No cookie-cutter hero sections with stock illustrations. No safe, forgettable layouts that could belong to any product. Every interface should have a point of view that makes it recognizable.
---
## 2. The Priority Stack
When implementing UI, work through these priorities in order. Higher priorities are non-negotiable; lower priorities are polish that compounds quality.
| Priority | Level | What It Means |
|----------|-------|---------------|
| **Accessibility** | CRITICAL | Contrast 4.5:1, keyboard nav, ARIA semantics, visible focus rings. Ship nothing that excludes users. |
| **Performance** | HIGH | WebP/AVIF images, lazy loading below fold, CLS < 0.1, transform-only animations on the compositor thread. |
| **Typography** | HIGH | Font smoothing, text-wrap balance/pretty, tabular-nums for data, 65ch max line length. |
| **Layout & Spatial** | HIGH | 4/8px grid, concentric border radius, optical alignment over geometric. |
| **Color & Theme** | MEDIUM | HSL custom properties, semantic tokens, dark mode pairs tested separately. |
| **Motion & Interaction** | MEDIUM | Frequency-based animation decisions, 150-300ms durations, ease-out default. |
| **Polish & Details** | LOW | Layered shadows over borders, press feedback on buttons, staggered enter animations. |
Never skip a CRITICAL/HIGH item to chase a LOW item. A beautifully animated button that fails keyboard navigation is a net negative.
---
## 3. Aesthetic Direction
Before writing a single line of CSS, commit to a bold aesthetic direction. The most common failure mode in AI-generated UI is convergence on the same safe, forgettable look.
### Pick a Tone
Choose one and commit fully:
- **Brutally minimal** — generous whitespace, monospace type, stark contrast, near-zero decoration
- **Maximalist chaos** — layered textures, clashing type scales, dense information, intentional visual noise
- **Retro-futuristic** — CRT glow effects, monospace terminals, scan lines, neon on dark
- **Organic / natural** — earth tones, rounded shapes, paper textures, hand-drawn accents
- **Luxury / refined** — serif headlines, muted palettes, ample negative space, subtle gold or cream accents
- **Editorial / magazine** — dramatic type hierarchy, full-bleed imagery, grid-breaking layouts
- **Playful / bold** — bright primaries, chunky borders, exaggerated shadows, bouncy motion
### Match Complexity to Vision
Maximalist design demands elaborate code — layered backgrounds, complex grid structures, multiple font stacks. Minimalist design demands surgical precision — every pixel of spacing matters more when there's nothing to hide behind.
### The Ban List (When No DESIGN.md Exists)
When building without an existing design system, avoid these overused defaults that signal "AI-generated":
- **Fonts**: Inter, Roboto, Arial, system-ui as display fonts, Space Grotesk
- **Colors**: Purple-to-blue gradients on white backgrounds
- **Patterns**: Generic hero with centered text + CTA + stock illustration
Vary between light and dark themes, different font pairings, different aesthetic directions. Never converge on the same choices across projects.
### Visual Texture
Add depth through: gradient meshes, noise/grain overlays (`filter: url(#noise)`), layered transparencies, subtle background patterns, duotone image treatments.
**DESIGN.md overrides this entire section.** If DESIGN.md specifies Inter, use Inter. If it specifies purple gradients, use them. The ban list only applies when no design system exists and you're making aesthetic choices from scratch.
---
## 4. Typography Essentials
Typography is the single highest-leverage design element. Get it right and mediocre layouts still feel good. Get it wrong and nothing else saves it.
### Root Setup
```css
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
```
Apply font smoothing to the root layout. On macOS, the default sub-pixel rendering makes text appear heavier than the designer intended.
### Text Wrapping
```css
h1, h2, h3, h4, h5, h6 {
text-wrap: balance;
}
p, li, dd, blockquote {
text-wrap: pretty;
}
```
`balance` distributes heading lines evenly. `pretty` avoids orphaned words in body text.
### Numeric Display
```css
.data-value, .price, .counter, [data-numeric] {
font-variant-numeric: tabular-nums;
}
```
Use `tabular-nums` for any number that updates dynamically — prices, counters, table columns. Without it, layout shifts as digit widths change.
### Scale and Rhythm
- **Base size**: 16px minimum for body text. Never go below 14px for any readable content.
- **Line height**: 1.5-1.75 for body text, 1.1-1.3 for large headings.
- **Max line length**: `max-width: 65ch` for body text. Long lines destroy readability.
- **Type scale**: Pick a consistent scale and stick to it: 12 / 14 / 16 / 18 / 24 / 32 / 48 / 64.
### Font Pairing
Pair a distinctive display font with a refined body font. The display font carries personality; the body font carries readability. Use `font-weight` for hierarchy within a family:
- **Headings**: 600-700 (semibold to bold)
- **Body**: 400 (regular)
- **Labels / UI**: 500 (medium)
Always include font stack fallbacks:
```css
--font-display: "Instrument Serif", "Georgia", serif;
--font-body: "Söhne", "Helvetica Neue", sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
```
---
## 5. Color & Theme
### HSL Custom Properties (shadcn Pattern)
```css
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
```
Define semantic tokens: primary, secondary, destructive, muted, accent, background, foreground. Reference colors by semantic name — never hardcode hex values in components.
### Dark Mode
```css
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* ... desaturated, lighter tonal variants — NOT simply inverted */
}
```
Dark mode is not "invert colors." Use desaturated, lighter tonal variants. Backgrounds go dark but not pure black (`#000`). Text goes light but not pure white (`#fff`). Test contrast separately for dark mode — what passes in light may fail in dark.
### Contrast Requirements
- **WCAG AA minimum**: 4.5:1 for normal text, 3:1 for large text (18px+ bold or 24px+ regular)
- Never convey information by color alone — always pair with an icon, label, or pattern
- Test with browser devtools contrast checker or axe-core
### Color Confidence
Dominant colors with sharp accents outperform timid, evenly-distributed palettes. Pick one or two hero colors and let the rest of the palette recede. A confident palette has clear hierarchy; an uncertain palette spreads color evenly and feels flat.
---
## 6. Spatial Design
### Concentric Border Radius
This is the single most common thing that makes nested UI elements feel "off":
```
outer_radius = inner_radius + padding
```
```css
/* Correct: concentric */
.card { border-radius: 16px; padding: 8px; }
.card-inner { border-radius: 8px; } /* 16 - 8 = 8 */
/* Wrong: same radius on parent and child */
.card { border-radius: 12px; }
.card-inner { border-radius: 12px; } /* Looks bloated */
```
When geometric centering looks off, align optically. Play/pause icons, dropdown carets, and asymmetric glyphs often need 1-2px manual nudges to look centered.
### Shadows Over Borders
Layer multiple transparent `box-shadow` values for natural depth instead of using borders:
```css
.elevated {
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.04),
0 2px 4px rgba(0, 0, 0, 0.04),
0 4px 8px rgba(0, 0, 0, 0.04);
}
```
Multiple shadows at different spreads mimic how light works. A single hard shadow looks artificial.
### Image Outlines
Add a subtle inset outline to images and media for consistent depth against varied backgrounds:
```css
img, video {
outline: 1px solid rgba(0, 0, 0, 0.06);
outline-offset: -1px;
}
```
### Spacing Scale
Use a 4px / 8px base incremental system. Every spacing value should be a multiple of 4:
`4 / 8 / 12 / 16 / 24 / 32 / 48 / 64 / 96 / 128`
### Hit Areas
Minimum 44x44px for all interactive elements. If the visual element is smaller, extend the hit area with a pseudo-element:
```css
.small-button::before {
content: "";
position: absolute;
inset: -8px;
}
```
### Z-Index Scale
Define a layered scale and never use arbitrary values:
```css
--z-base: 0;
--z-dropdown: 10;
--z-sticky: 20;
--z-overlay: 40;
--z-modal: 100;
--z-toast: 1000;
```
---
## 7. Motion & Interaction
### The Frequency-Based Decision Framework
This is the most important mental model for animation decisions:
| Frequency | Examples | Animation |
|-----------|----------|-----------|
| **100+ times/day** | Keyboard shortcuts, command palette actions, tab switches | **None.** Zero animation. Instant. |
| **Tens of times/day** | Hover effects, list item navigation, toggles | **Remove or drastically reduce.** 50-100ms max. |
| **Occasional** | Modals, drawers, toasts, page transitions | **Standard animation.** 150-300ms. |
| **Rare / first-time** | Onboarding, celebrations, empty states | **Can add delight.** 300-500ms, more elaborate. |
High-frequency animations feel sluggish. Low-frequency animations without motion feel jarring. Match the animation budget to usage frequency.
### Custom Easing Curves
Built-in CSS easings (`ease`, `ease-in-out`) are too weak. Define custom curves:
```css
:root {
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
```
### Duration Guide
| Element | Duration |
|---------|----------|
| Buttons, toggles | 100-160ms |
| Tooltips | 125-200ms |
| Dropdowns, popovers | 150-250ms |
| Modals, drawers | 200-500ms |
| Page transitions | 250-400ms |
UI animations should stay under 300ms. Never use `ease-in` for UI animations — it front-loads the pause and feels sluggish.
### Enter/Exit Asymmetry
Exits should be softer and faster than enters. An enter animation at 250ms should have its exit at 150-200ms.
### Split and Stagger Enter Animations
When multiple elements enter the viewport, stagger them by semantic chunks with ~50-100ms delay:
```css
.stagger-item {
animation: fadeSlideIn 300ms var(--ease-out) both;
}
.stagger-item:nth-child(1) { animation-delay: 0ms; }
.stagger-item:nth-child(2) { animation-delay: 60ms; }
.stagger-item:nth-child(3) { animation-delay: 120ms; }
```
### Scale Animations
Never animate from `scale(0)`. Start from `scale(0.9)` or higher, combined with opacity:
```css
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
```
### Press Feedback
Every pressable element should scale down slightly on `:active`:
```css
button:active {
transform: scale(0.97);
}
```
### Interruptibility
Use CSS transitions (not keyframe animations) for interactive state changes. Transitions can be interrupted mid-way; keyframes cannot. This matters for hover states, toggles, and any element the user might interact with rapidly.
### Popover Origin
Make popovers transform-origin aware — they should grow from their trigger element, not from center. Exception: modals always originate from center.
### Tooltip Hover Delay
Skip the tooltip delay on subsequent hovers. If the user has already waited for one tooltip, show the next one immediately.
### Reduced Motion
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
Respect `prefers-reduced-motion`. Reduce animations — don't eliminate opacity and color transitions entirely, as those provide important feedback.
### Hover Gate
Gate hover animations behind a media query so touch devices don't trigger stuck hover states:
```css
@media (hover: hover) and (pointer: fine) {
.card:hover { transform: translateY(-2px); }
}
```
> Reference `references/animation-playbook.md` for deep dives on spring physics, gesture-driven animation, and complex choreography.
---
## 8. Component Craft
### Primitives
Use Radix UI primitives for accessible, unstyled foundations. Use CVA (class-variance-authority) for type-safe component variants:
```tsx
import { cva } from "class-variance-authority";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
sm: "h-9 px-3 text-sm",
default: "h-10 px-4 py-2",
lg: "h-11 px-8 text-lg",
},
},
defaultVariants: { variant: "default", size: "default" },
}
);
```
### Button
- Scale on press (`transform: scale(0.97)` on `:active`)
- Visible focus ring (never `outline: none` without replacement)
- Loading state with spinner replacing label, maintaining button dimensions
- Disabled state at `opacity: 0.5` with `pointer-events: none`
### Card
- Concentric border radius between card and inner elements
- Layered shadows (not borders) for depth
- Hover state: subtle elevation change (`translateY(-1px)` + shadow increase)
### Dialog / Modal
- Focus trap (keyboard cannot escape to elements behind)
- ESC to close, click outside overlay to close
- `transform-origin: center`, fade + scale enter animation
- `aria-modal="true"`, `role="dialog"`, `aria-labelledby`
### Form
- Visible labels always — never placeholder-only inputs
- Error messages near the field with `aria-live="polite"` for screen readers
- Progressive disclosure: show advanced fields only when needed
- Use React Hook Form + Zod for validation
### Theming
Use shadcn CSS variable pattern (HSL format) for all component colors. Wrap client-interactive components in server components for Next.js App Router compatibility.
> Reference `references/component-patterns.md` for the full component catalog with copy-paste implementations.
---
## 9. Accessibility Essentials
### Semantic HTML First
Use `<button>`, `<nav>`, `<main>`, `<header>`, `<footer>`, `<article>`, `<section>` before reaching for ARIA. A `<button>` gives you keyboard handling, focus management, and screen reader semantics for free. A `<div onClick>` gives you none of that.
### Keyboard Navigation
- **Tab / Shift+Tab**: move between focusable elements
- **Enter / Space**: activate buttons and links
- **Arrow keys**: navigate within lists, menus, tabs, radio groups
- **Escape**: close modals, popovers, dropdowns
- **Home / End**: jump to first/last item in lists
### Focus Management
- Visible focus rings on all interactive elements — NEVER use `outline: none` without a replacement
- Trap focus inside modals (Tab wraps within the modal, not behind it)
- Restore focus to the trigger element when a modal/popover closes
- Use `focus-visible` to show rings only for keyboard users, not mouse clicks:
```css
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
```
### ARIA Attributes
- `aria-label` for icon-only buttons: `<button aria-label="Close menu">X</button>`
- `aria-labelledby` to associate headings with sections
- `aria-describedby` to link help text or error messages to inputs
- `aria-live="polite"` for dynamic content updates (toast messages, form errors)
- `aria-hidden="true"` for decorative elements (icons next to text labels)
- `aria-expanded` for toggleable elements (dropdowns, accordions)
### Color and Contrast
- WCAG AA: 4.5:1 for normal text, 3:1 for large text
- Never use color as the sole indicator — pair with icons, text, or patterns
- Test in both light and dark modes
### Images and Media
- Descriptive `alt` text for meaningful images: `alt="Dashboard showing 23% revenue growth"`
- Empty `alt=""` for purely decorative images
- Captions for video, transcripts for audio
### Navigation Aids
- **Skip link**: first focusable element, hidden until focused:
```html
<a href="#main-content" class="sr-only focus:not-sr-only">
Skip to main content
</a>
```
- **Heading hierarchy**: sequential h1 through h6, no level skips. One `<h1>` per page.
### Touch Targets
- Minimum 44x44px interactive area
- 8px minimum spacing between adjacent touch targets
- Extend small visual elements with invisible padding or pseudo-elements
### Testing
- **Automated**: axe-core in CI, Lighthouse accessibility score 90+
- **Manual**: full keyboard-only navigation test
- **Screen reader**: test with VoiceOver (macOS) or NVDA (Windows)
- **Visual**: zoom to 200%, check nothing breaks or overlaps
> Reference `references/accessibility-checklist.md` for the full audit guide with pass/fail criteria.
---
## 10. Pre-Delivery Review
Run through this checklist before considering any UI implementation complete:
### Typography
- [ ] Font smoothing applied (`-webkit-font-smoothing: antialiased`)
- [ ] Headings use `text-wrap: balance`
- [ ] Dynamic numbers use `font-variant-numeric: tabular-nums`
### Color
- [ ] All colors referenced via semantic tokens, no hardcoded hex in components
- [ ] Color contrast meets WCAG AA (4.5:1 normal text, 3:1 large text)
- [ ] Dark mode tested separately for contrast
### Spatial
- [ ] Nested rounded elements use concentric border radius
- [ ] Spacing follows 4px / 8px scale consistently
- [ ] Interactive elements have 44x44px minimum hit area
- [ ] Shadows used instead of borders where appropriate
### Motion
- [ ] Animation frequency matches usage frequency (no animation on high-frequency actions)
- [ ] No `transition: all` anywhere — specific properties only
- [ ] Enter animations split and staggered where multiple elements appear
- [ ] `prefers-reduced-motion` respected
### Accessibility
- [ ] All interactive elements keyboard accessible
- [ ] Focus rings visible on keyboard navigation (never `outline: none` without replacement)
- [ ] Semantic HTML used before ARIA
- [ ] `aria-live` on dynamic content updates
> Reference `references/review-checklist.md` for the extended 30-item checklist with severity ratings and automated testing commands.
@@ -0,0 +1,425 @@
# WCAG 2.1 AA Accessibility Audit Guide
Comprehensive checklist for building accessible web interfaces. Every requirement maps to WCAG 2.1 Level AA success criteria.
---
## 1. Semantic HTML Priority
ALWAYS use semantic HTML before reaching for ARIA. Native elements carry built-in keyboard behavior, focus management, and screen reader announcements that ARIA can only approximate.
### Element Selection Rules
| Instead of | Use |
|---|---|
| `<div role="button">` | `<button>` |
| `<div role="navigation">` | `<nav>` |
| `<div class="header">` | `<header>` |
| `<div class="footer">` | `<footer>` |
| `<span onClick>` | `<a href>` or `<button>` |
| `<div role="list">` | `<ul>` / `<ol>` |
| `<div class="table">` | `<table>` with `<thead>`, `<tbody>`, `<th>` |
### Landmark Elements
- `<main>` — one per page, wraps primary content
- `<nav>` — navigation sections (label with `aria-label` when multiple exist)
- `<header>` — introductory content or navigation aids
- `<footer>` — footer content, copyright, related links
- `<aside>` — tangentially related content (sidebars, callouts)
- `<article>` — self-contained composition (blog post, comment, widget)
- `<section>` — thematic grouping of content (always pair with a heading)
### Form Associations
- `<label>` with `for` attribute connected to the input's `id`
- Group related inputs with `<fieldset>` and `<legend>`
- Use `<optgroup>` for grouped select options
### Heading Hierarchy
- Sequential order: h1 -> h2 -> h3 -> h4 -> h5 -> h6
- NEVER skip levels (e.g., h1 directly to h3)
- One `<h1>` per page (the page title)
- Headings must describe the content that follows
---
## 2. Keyboard Navigation Patterns
Every interactive element must be operable with a keyboard alone. No mouse-only interactions.
### Global Key Bindings
| Key | Action |
|---|---|
| `Tab` | Move focus to next focusable element |
| `Shift + Tab` | Move focus to previous focusable element |
| `Enter` | Activate links, buttons, submit forms |
| `Space` | Activate buttons, toggle checkboxes |
| `Escape` | Close modals, dropdowns, popovers, tooltips |
| `Arrow keys` | Navigate within composite widgets |
| `Home` | Jump to first item in a list or range |
| `End` | Jump to last item in a list or range |
### Composite Widget Navigation (Arrow Keys)
- **Tabs**: Left/Right arrows move between tabs
- **Menus**: Up/Down arrows move between menu items
- **Radio groups**: Arrow keys cycle through options, selecting as they go
- **Listboxes**: Up/Down arrows move highlight, Space selects
- **Tree views**: Up/Down navigate siblings, Right expands, Left collapses
### tabindex Rules
- `tabindex="0"` — places element in natural tab order (use for custom interactive elements)
- `tabindex="-1"` — removes from tab order but allows programmatic focus via `element.focus()` (use for modal containers, skip-link targets, dynamically focused content)
- **NEVER** use `tabindex > 0` — it overrides natural DOM order and creates an unpredictable, unmaintainable focus sequence
### Focus Order Principle
Focus order must match the visual reading order (left-to-right, top-to-bottom for LTR languages). If the DOM order does not match the visual layout, fix the DOM order rather than using positive tabindex values.
---
## 3. ARIA Attributes Reference
The first rule of ARIA: do not use ARIA if a native HTML element provides the behavior. When you must use ARIA, apply it correctly.
### Naming and Describing
| Attribute | Purpose | Example |
|---|---|---|
| `aria-label` | Names an element without visible text | Icon button: `<button aria-label="Close">X</button>` |
| `aria-labelledby` | Points to another element as the label | Modal: `aria-labelledby="dialog-title"` |
| `aria-describedby` | Provides additional description | Form hint: `aria-describedby="password-hint"` |
### Live Regions
| Attribute | Behavior |
|---|---|
| `aria-live="polite"` | Waits for current speech to finish before announcing (toasts, status updates) |
| `aria-live="assertive"` | Interrupts current speech immediately (critical errors, urgent alerts) |
| `aria-atomic="true"` | Re-reads entire region content on change, not just the delta |
| `role="alert"` | Shorthand for `aria-live="assertive"` + `aria-atomic="true"` |
| `role="status"` | Shorthand for `aria-live="polite"` + `aria-atomic="true"` |
### State and Properties
| Attribute | Purpose |
|---|---|
| `aria-expanded` | Indicates whether a collapsible section is open (`true`) or closed (`false`) |
| `aria-haspopup` | Indicates the trigger opens a popup (`menu`, `listbox`, `dialog`, `grid`, `tree`) |
| `aria-modal="true"` | Marks a dialog as modal (assistive tech should ignore content outside) |
| `aria-hidden="true"` | Hides element from assistive technology (decorative images, duplicate content) |
| `aria-invalid` | Marks a form field as having an error (`true`, `grammar`, `spelling`) |
| `aria-required` | Indicates the field is required before form submission |
| `aria-sort` | Indicates sort direction on table column headers (`ascending`, `descending`, `none`) |
| `aria-selected` | Indicates selected state in single/multi-select widgets |
| `aria-controls` | Identifies the element(s) controlled by this element |
| `aria-current` | Indicates the current item in a set (`page`, `step`, `location`, `date`, `true`) |
| `aria-disabled` | Marks element as disabled but still perceivable (unlike `disabled` attribute which removes from tab order) |
---
## 4. Focus Management
### Visible Focus Indicators
- **NEVER** use `outline: none` or `outline: 0` without providing a custom alternative
- Recommended default: `outline: 3px solid currentColor; outline-offset: 2px;`
- Use `:focus-visible` for keyboard-only focus styling (hides ring on mouse click):
```css
:focus-visible {
outline: 3px solid var(--focus-color, #2563eb);
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
```
- Focus indicators must meet 3:1 contrast ratio against adjacent colors (WCAG 2.4.11)
- Minimum focus indicator area: at least 2px perimeter around the component
### Modal Focus Trapping
When a modal opens:
1. Move focus to the first focusable element inside the modal (or the modal container with `tabindex="-1"`)
2. Trap Tab/Shift+Tab to cycle only through focusable elements within the modal
3. Pressing Escape closes the modal
4. On close, return focus to the element that triggered the modal
### Focus Restoration
- When a dropdown/popover/modal closes, return focus to its trigger element
- When an item is deleted from a list, move focus to the nearest remaining item
- When a dialog confirms an action, focus the result or next logical element
### SPA Route Changes
- On navigation, move focus to the main content heading or a skip-link target
- Announce the new page title to screen readers using an `aria-live` region or document.title update
- Use `<title>` updates: "Page Name | Site Name"
### Skip Links
- First focusable element on the page should be "Skip to main content"
- Link target: `<main id="main-content" tabindex="-1">`
- Visually hidden until focused:
```css
.skip-link {
position: absolute;
left: -9999px;
top: auto;
}
.skip-link:focus {
position: static;
left: auto;
}
```
---
## 5. Color Contrast Requirements
### WCAG AA Minimum Ratios
| Element | Minimum Contrast Ratio |
|---|---|
| Normal text (< 24px, or < 18.66px if bold) | 4.5:1 |
| Large text (>= 24px, or >= 18.66px if bold) | 3:1 |
| UI components (borders, icons, form controls) | 3:1 |
| Graphical objects (charts, infographics) | 3:1 |
| Disabled elements | No requirement (but keep readable) |
| Placeholder text | 4.5:1 (it is regular text) |
### Testing Tools
- Chrome DevTools: Elements panel -> Styles -> color swatch -> contrast ratio
- axe-core browser extension
- WebAIM Contrast Checker: https://webaim.org/resources/contrastchecker/
- Stark (Figma/Sketch plugin)
### Color Independence Rules
- **NEVER** convey information by color alone
- Error states: red color + error icon + descriptive text message
- Required fields: asterisk + "required" label text (not just red border)
- Status indicators: color + icon + text label (e.g., green checkmark + "Complete")
- Links in body text: color + underline (or other non-color differentiator)
- Charts/graphs: use patterns, labels, or shapes in addition to color
### Dark Mode Considerations
- Test contrast ratios separately in dark mode
- Use desaturated color variants, not simple CSS `invert()`
- Background and foreground pairs must both be intentionally chosen
- Semi-transparent overlays can reduce effective contrast -- verify computed values
---
## 6. Accessible Component Patterns
### Dropdown / Select
```
trigger: aria-haspopup="listbox", aria-expanded="false|true"
container: role="listbox"
options: role="option", aria-selected="true|false"
```
- Arrow keys navigate options
- Typeahead: typing characters jumps to matching option
- Enter/Space selects highlighted option
- Escape closes without selecting
- Selected option text updates trigger label
### Modal / Dialog
```
container: role="dialog", aria-modal="true", aria-labelledby="title-id"
title: id="title-id"
close button: aria-label="Close dialog"
```
- Focus moves into modal on open
- Tab cycles within modal (focus trap)
- Escape closes modal
- Click on backdrop closes modal
- Focus returns to trigger on close
- Background content gets `aria-hidden="true"` or `inert`
### Tabs
```
container: role="tablist"
tab: role="tab", aria-selected="true|false", aria-controls="panel-id", tabindex="0|-1"
panel: role="tabpanel", aria-labelledby="tab-id", tabindex="0"
```
- Only the active tab has `tabindex="0"`; inactive tabs have `tabindex="-1"`
- Left/Right arrows move between tabs (wrapping optional)
- Home/End jump to first/last tab
- Tab key moves focus from the active tab into the panel content
### Forms
- Every `<input>`, `<select>`, `<textarea>` has a visible `<label>`
- Required fields: `aria-required="true"` + visual asterisk indicator
- Error fields: `aria-invalid="true"` + `aria-describedby` pointing to error message element
- Error messages: use `role="alert"` or `aria-live="assertive"` region
- On failed submission: focus the first invalid field
- Helper text: linked via `aria-describedby` to the associated input
- Password fields: toggle visibility button with `aria-label` describing current state
- Groups of related controls: `<fieldset>` + `<legend>`
### Accordion
```
trigger: <button aria-expanded="true|false" aria-controls="panel-id">
panel: id="panel-id", role="region", aria-labelledby="trigger-id"
```
- Enter/Space toggles section
- Only one section open at a time (optional, depends on design)
- Panel content hidden with `hidden` attribute or `display: none` (not just visually)
### Toast / Notification
- Container: `role="status"` or `aria-live="polite"` (non-critical)
- Critical notifications: `role="alert"` (assertive)
- Must be dismissible (close button or auto-dismiss with sufficient time)
- Auto-dismiss: minimum 5 seconds visible, pauses on hover/focus
---
## 7. prefers-reduced-motion
### Global Reset
```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;
}
}
```
### Nuanced Approach
Reduced motion means fewer/gentler animations, not zero motion:
- **Keep**: opacity fades, color transitions that aid comprehension
- **Remove**: parallax scrolling, zoom/scale transforms, slide/translate animations, auto-playing carousels
- **Simplify**: complex multi-step animations to simple fades
### Framework Integration
React (framer-motion):
```jsx
import { useReducedMotion } from 'framer-motion';
function Component() {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
animate={{ x: shouldReduceMotion ? 0 : 100 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.3 }}
/>
);
}
```
CSS custom property approach:
```css
:root {
--transition-speed: 0.3s;
}
@media (prefers-reduced-motion: reduce) {
:root {
--transition-speed: 0.01ms;
}
}
```
---
## 8. Testing Approach
### Automated Testing
| Tool | Usage |
|---|---|
| axe-core | `npm install jest-axe` for unit tests; `expect(container).toHaveNoViolations()` |
| Lighthouse | Accessibility score target: 90+ |
| eslint-plugin-jsx-a11y | Static analysis for JSX accessibility issues |
| pa11y | CLI/CI integration for automated page-level audits |
| Playwright/axe | `@axe-core/playwright` for integration test accessibility checks |
### Manual Testing Checklist
1. **Keyboard-only navigation**: unplug mouse, navigate entire page with Tab, Enter, Arrows, Escape
2. **Screen reader**: VoiceOver (macOS: Cmd+F5), NVDA (Windows, free), JAWS (Windows)
3. **Zoom 200%**: content should reflow without horizontal scrolling or content clipping
4. **Zoom 400%**: text should remain readable (WCAG 1.4.10 Reflow)
5. **Focus indicators**: every interactive element shows a visible focus ring when focused via keyboard
6. **Forced colors mode**: test in Windows High Contrast Mode (use `forced-colors` media query)
7. **Text spacing**: override letter-spacing (0.12em), word-spacing (0.16em), line-height (1.5), paragraph-spacing (2em) -- content must remain readable
### CI Integration
```bash
# Example: axe-core with Playwright in CI
npx playwright test --project=accessibility
```
---
## 9. Common Mistakes
| Mistake | Fix |
|---|---|
| `outline: none` on focus | Use `:focus-visible` with a custom focus ring |
| Placeholder as only label | Always use `<label>` element |
| Icon button without label | Add `aria-label="Action description"` |
| Color-only error indication | Add icon + descriptive text alongside color |
| Missing alt text on images | Descriptive `alt` text, or `alt=""` for decorative images |
| Heading level skip (h1 to h3) | Sequential hierarchy: h1 -> h2 -> h3 |
| `tabindex > 0` | Use natural DOM order; only use `0` or `-1` |
| Emoji used as functional icons | Use SVG icons with `aria-label` |
| Auto-playing animation | Respect `prefers-reduced-motion` media query |
| Non-dismissible modal | Always support Escape key to close |
| `aria-hidden="true"` on focusable elements | Remove from tab order or remove `aria-hidden` |
| Missing `lang` attribute on `<html>` | Set `<html lang="en">` (or appropriate language code) |
| Autoplaying video/audio with sound | Require user interaction to start, or mute by default with controls |
| Tiny tap targets on mobile | Minimum 44x44 CSS pixels for touch targets |
| Using `title` attribute as primary label | `title` is unreliable; use `aria-label` or visible `<label>` |
| Links that say "click here" or "read more" | Descriptive link text: "Read the accessibility guide" |
| Missing form error summary | On submit failure, show summary of all errors at top of form |
---
## Quick Reference: Testing a New Component
Before marking any component as complete, verify:
1. Can you reach and operate it using only a keyboard?
2. Does it have a visible focus indicator?
3. Does it announce correctly in a screen reader?
4. Does it meet color contrast ratios?
5. Does it work at 200% zoom?
6. Does it respect `prefers-reduced-motion`?
7. Does it pass `jest-axe` / axe-core automated checks?
8. Does it have appropriate semantic HTML or ARIA roles?
9. Are all images, icons, and media labeled?
10. Can it be operated with one hand on mobile (44x44px touch targets)?
@@ -0,0 +1,545 @@
# Animation Playbook
Deep-dive reference for animation patterns. The main SKILL.md references these techniques
but does not include the full detail needed for implementation.
---
## 1. Easing Curve Library
The built-in CSS keywords (`ease`, `ease-in`, `ease-out`, `ease-in-out`) produce weak,
generic motion. Define custom curves as CSS custom properties so every animation in the
project shares the same vocabulary.
```css
:root {
/* Strong ease-out — the default for UI interactions (enter, appear, respond) */
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
/* Strong ease-in-out — on-screen movement and morphing transitions */
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
/* iOS-like drawer curve — slide-up sheets, bottom drawers */
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
/* Snappy — fast micro-interactions, toggles, checkboxes */
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
/* Emphasized deceleration — large surface transitions, page-level changes */
--ease-decel: cubic-bezier(0, 0, 0.2, 1);
}
```
### When to use which
| Curve | Use case |
| ---------------- | ---------------------------------------------- |
| `--ease-out` | Elements entering the viewport, appearing |
| `--ease-in-out` | Elements morphing shape, moving across screen |
| `--ease-drawer` | Sheets, drawers, panels sliding into view |
| `--ease-snappy` | Micro-interactions: toggles, checks, switches |
| `--ease-decel` | Large page transitions, route changes |
| `linear` | Constant-rate motion only: progress bars, spin |
Never use `ease-in` alone for UI elements — it makes things feel sluggish at the start.
Reserve `linear` for continuous motion (loading spinners, progress indicators) where
deceleration would look wrong.
**Resources**: [easing.dev](https://easing.dev), [easings.co](https://easings.co)
for visual curve comparison and copying.
---
## 2. Spring Animations
Springs are physics-based. They do not have a fixed duration — they simulate mass,
stiffness, and damping. This makes them ideal for anything interactive.
### When to use springs instead of easing curves
- Drag interactions (the element should follow the finger naturally)
- Elements that feel "alive" (cards, floating actions, avatars)
- Gestures that can be interrupted mid-animation
- Mouse-tracking interactions (cursor followers, magnetic buttons)
### Apple-style spring (duration + bounce)
```js
// Framer Motion / Motion One
animate(element, { x: 100 }, {
type: "spring",
duration: 0.5,
bounce: 0.2
})
```
This is the simpler API. `duration` controls overall timing, `bounce` controls overshoot.
### Traditional physics spring (mass + stiffness + damping)
```js
animate(element, { x: 100 }, {
type: "spring",
mass: 1,
stiffness: 100,
damping: 10
})
```
More control, but harder to tune. Start with mass=1 and adjust stiffness/damping.
### Guidelines
- Keep bounce subtle: **0.1 to 0.3** for most UI. Higher values feel toy-like.
- Avoid bounce entirely for actions that need to feel decisive (confirms, deletes).
- Springs **maintain velocity when interrupted** — if you change the target mid-animation,
the element smoothly redirects. Keyframe animations restart from scratch.
- Use `useSpring` (or equivalent) for mouse-tracking: it makes cursor followers feel
natural instead of artificial. The lag is intentional and pleasant.
- For lists, spring each item separately so they can settle independently.
---
## 3. clip-path Animation Patterns
`clip-path` is one of the most underused animation tools. It lets you reveal, hide,
and transition content without layout shifts.
### Inset shape basics
```css
/* Full visibility */
clip-path: inset(0 0 0 0);
/* Clipped from bottom — only top portion visible */
clip-path: inset(0 0 50% 0);
/* Fully hidden — clipped from all sides */
clip-path: inset(50% 50% 50% 50%);
/* With border-radius */
clip-path: inset(10px round 8px);
```
The values are `inset(top right bottom left)` — how far each edge clips inward.
### Pattern: Tabs with perfect color transitions
Duplicate the entire tab list. Place one copy on top of the other. The bottom copy has
inactive styles; the top copy has active styles. Animate `clip-path: inset(...)` on the
top copy to reveal only the active tab region. The color transition is instantaneous and
pixel-perfect — no fade needed.
```css
.tabs-active-overlay {
clip-path: inset(0 calc(100% - var(--tab-right)) 0 var(--tab-left));
transition: clip-path 300ms var(--ease-out);
}
```
### Pattern: Hold-to-delete
Overlay a colored fill on the button. On `:active`, animate `clip-path` from
`inset(0 100% 0 0)` to `inset(0 0 0 0)` over 2 seconds with `linear` timing (the user
needs to see constant progress). On release, snap back with `200ms ease-out`.
```css
.delete-btn::after {
clip-path: inset(0 100% 0 0);
transition: clip-path 200ms var(--ease-out);
}
.delete-btn:active::after {
clip-path: inset(0 0 0 0);
transition: clip-path 2s linear;
}
```
### Pattern: Image reveals on scroll
Start with `clip-path: inset(0 0 100% 0)` (image hidden, clipped from bottom).
Use IntersectionObserver to detect viewport entry, then animate to `inset(0 0 0 0)`.
```js
observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.style.clipPath = 'inset(0 0 0 0)';
}
});
}, { threshold: 0.1 });
```
### Pattern: Comparison sliders
Overlay two images. Clip the top image by the drag position:
`clip-path: inset(0 calc(100% - var(--pos)) 0 0)`. Update `--pos` on pointer move.
---
## 4. Gesture Design
Gestures are the hardest animation category because they involve real-time user input
and require physics-aware feedback.
### Momentum-based dismissal
Calculate velocity during drag:
```js
const velocity = distance / elapsed; // px per ms
if (velocity > 0.11) {
dismiss(); // Fast enough — dismiss regardless of distance
} else if (Math.abs(offset) > threshold) {
dismiss(); // Far enough — dismiss regardless of speed
} else {
snapBack(); // Neither fast nor far — return to origin
}
```
The velocity threshold (0.11 px/ms) matters more than distance. A quick flick should
dismiss even from a small offset.
### Damping at boundaries
When the user drags past a natural boundary (e.g., top of a scroll view), apply
increasing resistance:
```js
function dampedOffset(raw, boundary) {
const overflow = raw - boundary;
// Logarithmic damping — diminishing returns
return boundary + Math.log(1 + Math.abs(overflow)) * 30 * Math.sign(overflow);
}
```
This produces the rubber-band effect. The element still moves, but progressively less.
### Pointer capture
Once a drag begins, call `element.setPointerCapture(event.pointerId)`. This ensures
all subsequent pointer events route to this element even if the pointer leaves its
bounds. Release on `pointerup`.
### Multi-touch protection
Track only the first pointer. If a second finger touches during a drag, ignore it:
```js
let activePointerId = null;
element.addEventListener('pointerdown', (e) => {
if (activePointerId !== null) return; // Already tracking
activePointerId = e.pointerId;
element.setPointerCapture(e.pointerId);
});
```
### Friction instead of hard stops
Never hard-clamp position. Always allow movement with increasing resistance. Hard stops
feel broken. Friction feels physical.
---
## 5. Stagger Patterns
Staggering creates a sense of flow by delaying each item slightly.
### CSS implementation
```css
.stagger-item {
opacity: 0;
transform: translateY(8px);
animation: stagger-in 400ms var(--ease-out) forwards;
}
@keyframes stagger-in {
to {
opacity: 1;
transform: translateY(0);
}
}
.stagger-item:nth-child(1) { animation-delay: 0ms; }
.stagger-item:nth-child(2) { animation-delay: 40ms; }
.stagger-item:nth-child(3) { animation-delay: 80ms; }
.stagger-item:nth-child(4) { animation-delay: 120ms; }
.stagger-item:nth-child(5) { animation-delay: 160ms; }
```
Or with a custom property:
```css
.stagger-item {
animation-delay: calc(var(--index) * 40ms);
}
```
Set `--index` via `style` attribute in markup or JS.
### Guidelines
- **30-80ms** per step is the sweet spot. Under 30ms looks simultaneous. Over 80ms
feels sluggish.
- Break content into **semantic chunks** — stagger cards, not individual lines of text.
- **Never block interaction** during stagger animations. All items should be clickable
immediately, even if not yet visible.
- Cap the total stagger time. For a list of 20 items, stagger the first 5-6 and let the
rest appear together.
- Stagger on initial load only. Re-renders should not re-stagger.
---
## 6. Exit Animation Patterns
Exits are often neglected. They deserve as much care as entries.
### Principles
- **Exits should be faster than enters.** If enter is 400ms, exit should be 200-250ms.
- **Use small fixed translateY** (8-12px) instead of full-height slides. Large movements
during exit draw too much attention away from what remains.
- **Opacity + scale combination** works better than opacity alone for removal. A slight
`scale(0.96)` during fade-out makes it feel more physical.
- **Asymmetric timing is intentional.** A hold-to-delete might take 2 seconds (deliberate),
but the actual removal should be 200ms (snappy). The weight is in the decision, not
the consequence.
### Exit with height collapse
When removing an item from a list, animate both the content (opacity + translate) and
the container height. The content fades first, then the gap closes:
```css
.item-exiting {
opacity: 0;
transform: translateY(-8px);
transition: opacity 150ms var(--ease-out),
transform 150ms var(--ease-out);
}
.item-exiting-collapse {
height: 0;
margin: 0;
padding: 0;
transition: height 200ms var(--ease-out) 100ms, /* delayed start */
margin 200ms var(--ease-out) 100ms,
padding 200ms var(--ease-out) 100ms;
}
```
### Tuning
There is no formula for the right opacity/height/transform combination. Adjust until it
feels right. Test by performing the action 10 times quickly — if anything feels off on
repetition, it needs work.
---
## 7. Performance Rules
Animation jank is unacceptable. These rules keep animations at 60fps.
### The compositing-only rule
Only animate properties that skip layout and paint:
- `transform` (translate, scale, rotate)
- `opacity`
- `filter` (with caveats — see below)
Everything else triggers layout recalculation (width, height, margin, padding, top, left)
or paint (background-color, box-shadow, border). Both are expensive.
### CSS vs JavaScript animations
- **CSS animations and transitions** run off the main thread on the compositor. Use them
for predetermined animations (hover effects, enter/exit, state changes).
- **Framer Motion `x`/`y` props are NOT hardware-accelerated.** They animate inline
styles, which run on the main thread. Use the full transform string or CSS-based
approaches for performance-critical animations.
- **CSS variables on parent elements** cause expensive style recalculation when updated.
If animating a CSS variable, update the `transform` property directly instead.
### Web Animations API (WAAPI)
For programmatic animations that need CSS-level performance:
```js
element.animate(
[
{ transform: 'translateY(20px)', opacity: 0 },
{ transform: 'translateY(0)', opacity: 1 }
],
{ duration: 400, easing: 'cubic-bezier(0.23, 1, 0.32, 1)', fill: 'forwards' }
);
```
WAAPI runs on the compositor like CSS animations but is controlled from JavaScript.
### Blur and filter performance
- Keep `blur()` under **20px**, especially on Safari where large blurs are expensive.
- `backdrop-filter: blur()` is even more expensive — use sparingly.
- Prefer pre-blurred images over real-time blur when possible.
### will-change
- Only use `will-change` for `transform`, `opacity`, or `filter`.
- **Never** use `will-change: all` — it promotes every property and wastes GPU memory.
- Add `will-change` only when you observe first-frame stutter on an animation. It is a
last resort, not a default.
- Remove `will-change` after the animation completes if the element is long-lived.
### transition: all is banned
```css
/* Bad — animates every property change, including ones you did not intend */
transition: all 200ms ease;
/* Good — explicit about what animates */
transition: transform 200ms var(--ease-out), opacity 200ms var(--ease-out);
```
`transition: all` causes unexpected animations when other properties change and makes
debugging difficult.
---
## 8. The Sonner Principles
Sonner (the toast library) demonstrates principles that apply broadly to dynamic UI
components.
### Good defaults matter more than options
If you need 12 configuration props to make a component feel right, the defaults are
wrong. The component should feel right out of the box.
### Use transitions, not keyframes, for dynamic UI
Toasts are added rapidly and unpredictably. Keyframe animations have fixed timelines
that cannot adapt to rapid state changes. CSS transitions respond to the current state
and interpolate naturally.
### Handle edge cases invisibly
- Pause toast timers when the browser tab is hidden (the user should not miss toasts).
- When a toast is dismissed from the middle of a stack, the remaining toasts should
fill the gap smoothly.
- When multiple toasts arrive simultaneously, batch the visual update.
### Match motion personality to component personality
A success toast can be slightly bouncy. An error toast should be direct and firm.
A loading toast should feel steady and patient. The animation communicates as much as
the content.
---
## 9. @starting-style for Modern CSS Enter Animations
`@starting-style` defines the initial style of an element when it first renders.
Combined with transitions, it creates enter animations in pure CSS — no JavaScript
`useEffect` + `mounted` state needed.
```css
.toast {
opacity: 1;
transform: translateY(0);
transition: opacity 400ms ease, transform 400ms ease;
@starting-style {
opacity: 0;
transform: translateY(100%);
}
}
```
When the `.toast` element is inserted into the DOM, the browser starts from the
`@starting-style` values and transitions to the normal values.
### Works with display: none toggling
```css
.dialog {
display: block;
opacity: 1;
transition: opacity 300ms var(--ease-out), display 300ms allow-discrete;
@starting-style {
opacity: 0;
}
}
.dialog[hidden] {
display: none;
opacity: 0;
}
```
The `allow-discrete` keyword lets `display` participate in the transition timeline.
### Fallback for older browsers
When `@starting-style` is not supported, fall back to a `data-mounted` attribute pattern:
```css
.toast {
opacity: 1;
transform: translateY(0);
transition: opacity 400ms ease, transform 400ms ease;
}
.toast:not([data-mounted]) {
opacity: 0;
transform: translateY(100%);
}
```
Add `data-mounted` via JavaScript after a single `requestAnimationFrame`.
---
## 10. Debug Techniques
### Slow motion testing
Increase animation duration by 2-5x during development. At normal speed, problems are
invisible. At 5x, you see every hitch, wrong easing, and misaligned property.
```css
:root {
--debug-speed: 1; /* Change to 5 for slow-mo */
}
.animated {
transition-duration: calc(200ms * var(--debug-speed));
}
```
### Chrome DevTools Animations panel
Open DevTools > More Tools > Animations. This panel shows:
- A timeline of all running animations
- Frame-by-frame scrubbing
- Easing curve visualization
- Duration and delay for each animation
Use the playback speed controls (25%, 10%) for detailed inspection.
### Real device testing
Touch interactions feel completely different on a real phone versus a trackpad simulator.
Always test gestures, drag interactions, and spring animations on physical devices.
### Fresh eyes check
Review animations with fresh eyes the next day. What felt right at 11pm during
development often feels too fast, too slow, or too dramatic the next morning.
### The checklist
Before shipping any animation, verify:
- Smooth color transitions (no banding or flashing)?
- Correct easing curve for the interaction type?
- Right `transform-origin` (elements scaling/rotating from the expected point)?
- All animated properties in sync (opacity and transform finishing together)?
- No layout shift during the animation?
- Works with `prefers-reduced-motion: reduce`?
- Performs at 60fps on a mid-range device?
@@ -0,0 +1,604 @@
# Component Implementation Patterns
Deep-dive reference for building production interfaces with shadcn/ui, Radix UI, and modern React.
---
## 1. shadcn/ui Setup
```bash
npx shadcn@latest init
npx shadcn@latest add button input form card dialog select sheet toast
```
Key concepts:
- **Not an npm package** -- components are copied into your project. You own the code and can modify it freely.
- Built on **Radix UI** primitives, which provide accessibility out of the box (focus management, ARIA attributes, keyboard navigation).
- Styled with **Tailwind CSS** utilities -- no CSS-in-JS runtime.
- Required dependencies:
- `class-variance-authority` (CVA) -- variant management
- `clsx` -- conditional class joining
- `tailwind-merge` -- deduplicates conflicting Tailwind classes
- `lucide-react` -- icon library
- `tailwindcss-animate` -- animation utilities
The `cn()` utility combines `clsx` and `tailwind-merge`:
```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
```
---
## 2. CSS Variables for Theming (HSL Format)
shadcn uses HSL values without the `hsl()` wrapper so Tailwind can apply opacity modifiers:
```css
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
/* ... remaining dark overrides */
}
}
```
Usage in `tailwind.config.ts`:
```ts
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
// ...
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
}
```
---
## 3. Button Patterns
Use CVA to define variants declaratively:
```tsx
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.97]",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
```
Design rules:
- **Press feedback**: `active:scale-[0.97]` gives tactile response without layout shift.
- **Focus ring**: Always visible via `focus-visible:ring-2`. Never use `outline: none` without a replacement.
- **Loading state**: Disable the button and show a spinner inline.
```tsx
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, isLoading, children, ...props }, ref) => (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={isLoading || props.disabled}
{...props}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{children}
</button>
)
)
```
---
## 4. Form Patterns (React Hook Form + Zod)
Schema-first validation keeps validation logic co-located and type-safe:
```tsx
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
const formSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
name: z.string().min(2).max(50),
})
type FormValues = z.infer<typeof formSchema>
```
The shadcn Form components wire React Hook Form to accessible markup:
```tsx
function SignUpForm() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { email: "", password: "", name: "" },
mode: "onBlur", // validate on blur, not keystroke
})
function onSubmit(values: FormValues) {
// handle submission
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* ...more fields */}
<Button type="submit" isLoading={form.formState.isSubmitting}>
Sign Up
</Button>
</form>
</Form>
)
}
```
Accessibility rules:
- `FormMessage` renders error text with `aria-describedby` linked to the input.
- Inputs get `aria-invalid="true"` when in error state automatically.
- Mark required fields with `aria-required="true"`.
- Validate on **blur**, not on every keystroke -- reduces noise and respects user flow.
- Use **progressive disclosure** for complex forms: show additional fields only when relevant.
---
## 5. Card Patterns
```tsx
import {
Card, CardHeader, CardTitle, CardDescription,
CardContent, CardFooter,
} from "@/components/ui/card"
<Card className="hover:shadow-lg hover:-translate-y-0.5 transition-all duration-200">
<CardHeader>
<CardTitle>Project Settings</CardTitle>
<CardDescription>Manage your project configuration.</CardDescription>
</CardHeader>
<CardContent>
{/* form fields or content */}
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline">Cancel</Button>
<Button>Save</Button>
</CardFooter>
</Card>
```
Design rules:
- **Concentric border radius**: Outer radius = inner radius + padding. If inner elements have `rounded-md` (6px) and padding is 16px, outer card should be `rounded-xl` (12px) or greater.
- **Layered shadows**: Use multiple shadow values for natural depth -- `shadow-sm` at rest, `shadow-lg` on hover.
- **Hover lift**: Subtle `translateY(-2px)` on hover, never more than 4px.
- Use semantic color tokens (`bg-card`, `text-card-foreground`) so cards adapt to theme changes.
---
## 6. Dialog (Modal) Patterns
```tsx
import {
Dialog, DialogTrigger, DialogContent,
DialogHeader, DialogTitle, DialogDescription,
DialogFooter, DialogClose,
} from "@/components/ui/dialog"
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Edit Profile</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>
Make changes to your profile here.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* form content */}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit">Save changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
```
Accessibility and interaction rules (handled by Radix):
- **Focus trap**: Focus stays inside the modal while open. Tab wraps from last to first focusable element.
- **ESC to close**: Always. No exceptions.
- **Click outside overlay**: Closes the dialog by default.
- `aria-modal="true"` is set automatically.
- `aria-labelledby` points to `DialogTitle`, `aria-describedby` points to `DialogDescription`.
- **Restore focus**: When dialog closes, focus returns to the trigger element.
- **Animation origin**: `transform-origin: center` -- dialogs are an exception to the popover origin-from-trigger rule since they appear center-screen.
---
## 7. Select/Dropdown Patterns
```tsx
import {
Select, SelectTrigger, SelectValue,
SelectContent, SelectItem, SelectGroup, SelectLabel,
} from "@/components/ui/select"
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Fruits</SelectLabel>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="blueberry">Blueberry</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
```
Interaction rules:
- **Keyboard navigation**: Arrow keys to move between items, Enter/Space to select, ESC to close, type-ahead to jump to matching items.
- ARIA: `aria-haspopup="listbox"` on trigger, `aria-expanded` toggles with open state.
- **Transform origin**: Popover should animate from the trigger position (origin-aware), not from center.
- **Tooltip delay skip**: If a user hovers over one select and then moves to another, skip the tooltip delay on the second hover.
---
## 8. Sheet (Slide-over) Patterns
```tsx
import {
Sheet, SheetTrigger, SheetContent,
SheetHeader, SheetTitle, SheetDescription,
SheetFooter, SheetClose,
} from "@/components/ui/sheet"
<Sheet>
<SheetTrigger asChild>
<Button variant="outline">Open Menu</Button>
</SheetTrigger>
<SheetContent side="right"> {/* "left" | "right" | "top" | "bottom" */}
<SheetHeader>
<SheetTitle>Navigation</SheetTitle>
<SheetDescription>Browse sections of the app.</SheetDescription>
</SheetHeader>
<nav className="flex flex-col gap-2 py-4">
{/* nav links */}
</nav>
<SheetFooter>
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
```
Use cases:
- **Mobile navigation**: Slide from left with full-height overlay.
- **Detail panels**: Slide from right to show item details without leaving the list view.
- **Filters**: Slide from bottom on mobile for filter controls.
Sheets share the same accessibility behavior as Dialog: focus trap, ESC to close, overlay click to close, and focus restoration.
---
## 9. Toast/Notification Patterns
Using the shadcn Toast (or Sonner for a lighter API):
```tsx
// With shadcn toast
import { useToast } from "@/components/ui/use-toast"
function SaveButton() {
const { toast } = useToast()
return (
<Button
onClick={() => {
toast({
title: "Changes saved",
description: "Your settings have been updated.",
})
}}
>
Save
</Button>
)
}
// With Sonner (simpler API)
import { toast } from "sonner"
toast.success("Changes saved")
toast.error("Something went wrong")
toast.promise(saveSettings(), {
loading: "Saving...",
success: "Settings saved",
error: "Could not save",
})
```
Design and accessibility rules:
- **Auto-dismiss**: 3-5 seconds for informational toasts. Errors should persist or have longer duration.
- `aria-live="polite"` -- screen readers announce without stealing focus.
- **CSS transitions, not keyframes** -- toasts can be triggered rapidly; transitions handle interruption gracefully while keyframes restart from the beginning.
- **Pause timers** when the browser tab is hidden (`document.visibilityState`).
- **Swipe to dismiss**: Support horizontal swipe with momentum detection (velocity > threshold = dismiss, otherwise snap back).
---
## 10. Table Patterns
```tsx
import {
Table, TableHeader, TableBody, TableFooter,
TableHead, TableRow, TableCell, TableCaption,
} from "@/components/ui/table"
<div className="overflow-x-auto rounded-md border">
<Table>
<TableCaption>A list of recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell className="font-medium">{invoice.id}</TableCell>
<TableCell>{invoice.status}</TableCell>
<TableCell className="text-right">{invoice.amount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
```
Rules:
- **Responsive**: Wrap table in `overflow-x-auto` container. Below tablet breakpoint, allow horizontal scroll rather than collapsing columns.
- **Sortable columns**: Use `aria-sort="ascending"` or `aria-sort="descending"` on the active `TableHead`. Show a visual indicator (chevron icon).
- **Virtualization**: For lists exceeding ~50 items, use `@tanstack/react-virtual` or similar to render only visible rows.
- **Row distinction**: Use zebra striping (`even:bg-muted/50`) or subtle borders between rows. Never rely on color alone.
---
## 11. Chart Integration
When integrating charts (Recharts, Chart.js, or similar):
- **Match chart type to data intent**:
- Trend over time: line chart
- Comparison across categories: bar chart
- Part-of-whole: pie/donut chart
- Distribution: histogram
- Correlation: scatter plot
- **Accessible color palettes**: Use colors distinguishable by colorblind users. Supplement with patterns, textures, or different shapes for data points.
- **Always include a legend** and provide **tooltips on hover/focus** for precise values.
- **Screen reader alternative**: Provide a visually hidden `<table>` with the same data so screen readers can access it.
- **Respect `prefers-reduced-motion`**: Skip entrance animations or reduce them to simple fades when the user has requested reduced motion.
```tsx
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches
<LineChart data={data}>
<Line
type="monotone"
dataKey="value"
animationDuration={prefersReducedMotion ? 0 : 500}
/>
</LineChart>
```
---
## 12. Server Component Wrapping (Next.js)
Most shadcn/ui components use React state or event handlers and require `"use client"`. Structure your components to keep data fetching in server components:
```tsx
// app/dashboard/page.tsx (Server Component -- no "use client")
import { getProjects } from "@/lib/data"
import { ProjectList } from "./project-list"
export default async function DashboardPage() {
const projects = await getProjects()
return <ProjectList projects={projects} />
}
```
```tsx
// app/dashboard/project-list.tsx (Client Component)
"use client"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
interface ProjectListProps {
projects: { id: string; name: string; status: string }[]
}
export function ProjectList({ projects }: ProjectListProps) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<Card key={project.id}>
<CardHeader>
<CardTitle>{project.name}</CardTitle>
</CardHeader>
<CardContent>
<p>{project.status}</p>
<Button variant="outline" size="sm">View</Button>
</CardContent>
</Card>
))}
</div>
)
}
```
The pattern: **Server component fetches data, passes to client component as serializable props.** This keeps the client bundle small and data fetching on the server.
---
## 13. CVA (class-variance-authority) Deep Dive
CVA lets you define component variants declaratively, replacing sprawling conditional class logic:
```ts
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
```
Key patterns:
- **Compose with `cn()`**: Always wrap CVA output with `cn()` so consumer-passed `className` can override defaults via `tailwind-merge`.
- **Type extraction**: `VariantProps<typeof badgeVariants>` generates the TypeScript type for variant props automatically.
- **Compound variants**: Handle combinations of variant values that need special styling:
```ts
const inputVariants = cva("...", {
variants: {
size: { sm: "...", lg: "..." },
state: { error: "...", success: "..." },
},
compoundVariants: [
{ size: "sm", state: "error", class: "border-2 border-red-500" },
],
})
```
- **Use CVA for any component with visual variants** -- buttons, badges, alerts, inputs, cards. It replaces manual `if/else` class concatenation with a declarative, type-safe API.
@@ -0,0 +1,204 @@
# Pre-Delivery Review Checklist
Extended 30-item checklist for UI implementation quality. Run through this before marking any UI task as complete.
## Typography (6 items)
### 1. Font Smoothing Applied
- **Check**: Root layout has `-webkit-font-smoothing: antialiased`
- **How**: Inspect `<html>` or `<body>` computed styles
- **Failing looks like**: Text appears heavy/blurry on macOS, especially at small sizes
### 2. Headings Use text-wrap: balance
- **Check**: All `<h1>``<h4>` elements have `text-wrap: balance`
- **How**: Resize viewport to trigger wrapping — headings should break evenly
- **Failing looks like**: One long line followed by a single orphan word
### 3. Body Text Uses text-wrap: pretty
- **Check**: Paragraphs and body text use `text-wrap: pretty`
- **How**: Check for orphaned words at the end of paragraphs
- **Failing looks like**: A single short word sitting alone on the last line
### 4. Dynamic Numbers Use tabular-nums
- **Check**: Counters, prices, timers, and data columns have `font-variant-numeric: tabular-nums`
- **How**: Watch numbers update — layout should not shift
- **Failing looks like**: Content jumps horizontally as digits change width
### 5. Line Length Controlled
- **Check**: Body text containers are capped at `max-width: 65ch`
- **How**: Measure character count on a full-width line
- **Failing looks like**: Text stretching edge-to-edge on wide monitors, hard to read
### 6. Type Scale Consistency
- **Check**: All text sizes come from the defined type scale (no arbitrary sizes)
- **How**: Inspect font sizes — they should match scale values (12/14/16/18/24/32/48)
- **Failing looks like**: Random sizes like 15px, 19px, 22px that aren't in the scale
## Color & Theme (5 items)
### 7. Semantic Color Tokens Only
- **Check**: No hardcoded hex/rgb values in component code
- **How**: Search for `#[0-9a-f]` or `rgb(` in component files
- **Failing looks like**: `background: #3b82f6` instead of `bg-primary` or `var(--primary)`
### 8. WCAG AA Contrast Met
- **Check**: Normal text ≥ 4.5:1, large text ≥ 3:1, UI components ≥ 3:1
- **How**: Run axe-core or Chrome DevTools contrast checker
- **Failing looks like**: Light gray text on white background, low-contrast placeholders
### 9. Dark Mode Contrast Verified
- **Check**: Contrast ratios pass in dark mode separately
- **How**: Toggle dark mode, re-run contrast checks
- **Failing looks like**: Passing in light mode but failing in dark (common with desaturated variants)
### 10. Color Not Sole Information Channel
- **Check**: Error, success, warning states use icon + text alongside color
- **How**: View the page in grayscale (browser DevTools → Rendering → Emulate vision deficiency)
- **Failing looks like**: Red border on error field with no icon or text explanation
### 11. Dark Mode Visual Review
- **Check**: All surfaces, borders, shadows, and text are legible in dark mode
- **How**: Toggle dark mode and visually scan every component
- **Failing looks like**: Invisible borders, washed-out shadows, or text-on-background collision
## Layout & Spatial (5 items)
### 12. Concentric Border Radius
- **Check**: Outer radius = inner radius + padding on all nested rounded elements
- **How**: Inspect nested cards, buttons-in-containers, input groups
- **Failing looks like**: Inner and outer corners don't follow the same curvature — looks "off"
| Before | After | Why |
|--------|-------|-----|
| Parent `rounded-lg` (12px), child `rounded-lg` (12px), padding 8px | Parent `rounded-xl` (16px), child `rounded-md` (8px), padding 8px | 8 + 8 = 16 — radii now concentric |
### 13. Spacing Follows Scale
- **Check**: All padding, margin, and gap values are multiples of 4px
- **How**: Inspect spacing values — no 5px, 7px, 13px, 19px etc.
- **Failing looks like**: Inconsistent spacing that makes the layout feel uneven
### 14. Hit Areas Meet Minimum
- **Check**: All interactive elements have at least 44×44px clickable area
- **How**: Use browser DevTools to measure element + padding dimensions
- **Failing looks like**: Tiny icon buttons, close buttons, or links that are hard to tap on mobile
### 15. Shadows Over Borders
- **Check**: Depth is created with layered box-shadows, not solid borders between sections
- **How**: Look for `border: 1px solid` between content sections
- **Failing looks like**: Hard dividing lines instead of natural depth transitions
### 16. Optical Alignment Verified
- **Check**: Icons in buttons, play triangles, and asymmetric elements are optically centered
- **How**: Squint at the element — does it look centered to the eye?
- **Failing looks like**: A play triangle that's geometrically centered but looks shifted left
## Motion & Interaction (7 items)
### 17. Animation Frequency Appropriate
- **Check**: High-frequency actions (keyboard shortcuts, command palette) have NO animation
- **How**: Review the frequency table — occasional actions get standard animation, frequent actions get none
- **Failing looks like**: A command palette with a 300ms open animation that feels sluggish after the 50th use
### 18. No `transition: all`
- **Check**: Every transition specifies exact properties
- **How**: Search for `transition: all` or `transition-property: all`
- **Failing looks like**: Unintended properties animating (color, padding, border) causing jank
### 19. Custom Easing Curves Used
- **Check**: UI animations use custom bezier curves, not built-in `ease`, `ease-in`, `ease-out`
- **How**: Inspect transition/animation easing values
- **Failing looks like**: Animations feel generic and lack punch
### 20. Enter Animations Split and Staggered
- **Check**: Multi-element entrances use 30-80ms stagger between items
- **How**: Watch page load or section reveal — elements should cascade, not appear all at once
- **Failing looks like**: An entire section popping in as one block
### 21. Press Feedback on Buttons
- **Check**: All pressable elements have subtle `scale(0.96-0.97)` on `:active`
- **How**: Click and hold buttons — they should compress slightly
- **Failing looks like**: Clicking a button with zero visual feedback
### 22. prefers-reduced-motion Respected
- **Check**: Animations reduce/simplify when the user has reduced motion enabled
- **How**: Enable reduced motion in OS settings, reload, check all animations
- **Failing looks like**: Full animations playing for users who opted out
### 23. Hover States Gated
- **Check**: Hover animations are behind `@media (hover: hover) and (pointer: fine)`
- **How**: Test on touch device or emulate touch in DevTools
- **Failing looks like**: Hover states triggering on tap on mobile, causing sticky hover effects
## Accessibility (7 items)
### 24. Keyboard Navigation Complete
- **Check**: Every interactive element is reachable and operable with keyboard only
- **How**: Unplug mouse, Tab through entire page, operate every control
- **Failing looks like**: Unreachable buttons, inoperable dropdowns, trapped focus
### 25. Focus Rings Visible
- **Check**: Every focusable element has a visible focus indicator
- **How**: Tab through the page and verify each element shows focus
- **Failing looks like**: `outline: none` with no replacement, invisible focus state
### 26. Semantic HTML Used
- **Check**: `<button>` for actions, `<a>` for links, `<nav>` for navigation, proper heading hierarchy
- **How**: Inspect the DOM — look for `<div onclick>` or `<span>` where buttons should be
- **Failing looks like**: Divs with click handlers instead of buttons, missing landmarks
### 27. ARIA Labels on Icon Buttons
- **Check**: Every icon-only button has `aria-label` describing its action
- **How**: Inspect icon buttons in DevTools or run axe-core
- **Failing looks like**: Screen reader announcing "button" with no context
### 28. Form Errors Accessible
- **Check**: Error messages use `aria-live` or `role="alert"`, linked via `aria-describedby`
- **How**: Submit an invalid form, check screen reader announces errors
- **Failing looks like**: Visual error message that screen reader users never hear
### 29. Images Have Alt Text
- **Check**: Meaningful images have descriptive `alt`, decorative images have `alt=""`
- **How**: Search for `<img>` without `alt` attribute
- **Failing looks like**: Screen reader announcing file names or nothing for important images
### 30. Skip Link Present
- **Check**: First focusable element is "Skip to main content" link
- **How**: Tab once on page load — skip link should appear
- **Failing looks like**: Keyboard users forced to Tab through entire header/nav on every page
## Quick Pass/Fail Summary
Use this table to record results:
| # | Item | Pass | Notes |
|---|------|------|-------|
| 1 | Font smoothing | | |
| 2 | text-wrap: balance | | |
| 3 | text-wrap: pretty | | |
| 4 | tabular-nums | | |
| 5 | Line length | | |
| 6 | Type scale | | |
| 7 | Semantic tokens | | |
| 8 | WCAG contrast | | |
| 9 | Dark mode contrast | | |
| 10 | Color not sole channel | | |
| 11 | Dark mode visual | | |
| 12 | Concentric radius | | |
| 13 | Spacing scale | | |
| 14 | Hit areas | | |
| 15 | Shadows over borders | | |
| 16 | Optical alignment | | |
| 17 | Animation frequency | | |
| 18 | No transition: all | | |
| 19 | Custom easing | | |
| 20 | Staggered enter | | |
| 21 | Press feedback | | |
| 22 | Reduced motion | | |
| 23 | Hover gated | | |
| 24 | Keyboard nav | | |
| 25 | Focus rings | | |
| 26 | Semantic HTML | | |
| 27 | ARIA labels | | |
| 28 | Form errors | | |
| 29 | Alt text | | |
| 30 | Skip link | | |