Add local agent workspace files and skills
Includes .agents/, additional .claude/skills/, and skills-lock.json.
This commit is contained in:
@@ -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 | | |
|
||||
Reference in New Issue
Block a user