Web standards
Skill fernando-bertholdo/4-successful-AI-life/plugins/ui-excellence/skills/foundations/web-standards
Curated Claude Code plugin marketplace — opinionated, production-ready skills for AI-assisted work, focused on craft, rigor, and practical excellence.
npx -y skills add fernando-bertholdo/4-successful-AI-life --skill web-standardsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use when reviewing or building web interfaces for accessibility compliance, component patterns, form handling, typography, performance, animations, and UX patterns aligned with modern web standards and Vercel's guidelines.
SKILL.md
32.5 KB, as published. Nobody here has run it
UI Web Standards
Overview
Comprehensive guidance for building accessible, performant, and user-friendly web interfaces aligned with Vercel's Web Interface Guidelines. Covers accessibility compliance (WCAG 2.1 AA), component patterns, form handling, animations, typography, navigation, and anti-patterns to avoid.
When to Use
- Reviewing HTML, React, Vue, Svelte components for accessibility and standards compliance
- Building forms, buttons, links, modals, navigation, lists, and interactive elements
- Optimizing performance via virtualization, lazy loading, and animation handling
- Implementing animations, transitions, or motion-sensitive designs
- Handling dark mode, i18n, localization, or theme-aware components
- Debugging focus states, keyboard navigation, or screen reader announcements
- Designing UX copy, error messages, loading states, or empty states
When NOT to Use
- Styling decisions unrelated to accessibility, performance, or UX patterns
- Branding or visual design guidelines (use design system skill instead)
- Backend logic or server-side rendering patterns (use architecture reviews)
- Non-web platforms (native mobile, desktop apps, terminal UIs)
Accessibility
Semantic HTML First
Use semantic elements; never <div onClick> for interactive content:
<button>for actions (submit, cancel, toggle, delete)<a>or<Link>for navigation (internal/external)<label>for form controls (inputs, checkboxes, radios, selects)<table>for tabular data (with<thead>,<tbody>, proper headers)<nav>,<main>,<header>,<footer>,<section>,<article>for structure
Anti-pattern:
<div onClick={handleClick} role="button">
Click me
</div>
Correct:
<button onClick={handleClick}>
Click me
</button>
ARIA Labels & Roles
- Icon-only buttons require
aria-label="..."describing intent - Form controls without visible labels require
aria-labeloraria-labelledby - Inputs with labels: use
<label htmlFor="id">(clickable target) oraria-label - Interactive elements: expose role via semantic HTML; use
role="..."only when semantic element unavailable - Decorative icons: apply
aria-hidden="true"to skip in accessibility tree - Dynamic content: async updates use
aria-live="polite"(toasts, validation messages, status updates) - Skip links: include skip-to-main link at top of page
Example:
{/* Icon-only button */}
<button aria-label="Close modal" onClick={closeModal}>
<CloseIcon />
</button>
{/* Decorative icon */}
<span aria-hidden="true">✓</span>
{/* Form with label */}
<label htmlFor="email">Email</label>
<input id="email" type="email" name="email" />
{/* Live region for async updates */}
<div aria-live="polite" aria-atomic="true">
{validationError}
</div>
Keyboard Navigation
- Interactive elements (
<button>,<a>,<input>, etc.) are keyboard-focusable by default - Custom interactive elements require
onKeyDownoronKeyUphandlers - Common patterns:
- Button/Link: Space/Enter to activate
- Checkbox: Space to toggle
- Radio Group: Arrow keys to navigate, Space to select
- Menu: Arrow keys + Escape to close
- Modal: Escape to close (if permitted), Tab trapped to modal
- Autocomplete: Arrow keys to navigate, Enter to select, Escape to close
Example:
const MyButton = ({ onClick, disabled }) => (
<button
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick(e);
}
}}
disabled={disabled}
>
Click or press Enter/Space
</button>
);
Form Accessibility
- Every form control must have a visible label or
aria-label - Labels clickable via
htmlForattribute or label wrapping control - Semantic input types:
type="email",type="tel",type="url",type="number"(enables mobile keyboards, browser validation) - Autocomplete: use meaningful
autocompleteattribute (e.g.,autocomplete="email",autocomplete="current-password") - Spellcheck: disable for email, code, username:
spellCheck={false} - Error states:
- Display error inline next to field
- Focus first invalid field on submit
- Use
aria-describedbyto link input to error:<input aria-describedby="email-error" /><div id="email-error">{error}</div>
- Checkboxes & Radios:
- Label and control share single hit target (label wraps input or uses
htmlFor) - Group related radios with
<fieldset>and<legend>
- Label and control share single hit target (label wraps input or uses
Example:
<div>
<label htmlFor="email">Email address</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
spellCheck={false}
aria-describedby={error ? "email-error" : undefined}
required
/>
{error && <div id="email-error" style={{ color: "red" }}>{error}</div>}
</div>
{/* Checkbox with label as hit target */}
<label>
<input type="checkbox" name="terms" required />
I agree to the terms
</label>
{/* Radio group */}
<fieldset>
<legend>Preferred contact method</legend>
<label>
<input type="radio" name="contact" value="email" />
Email
</label>
<label>
<input type="radio" name="contact" value="phone" />
Phone
</label>
</fieldset>
Images
- All
<img>tags requirealttext (descriptive) oralt=""(if purely decorative) - Decorative images use
alt=""andaria-hidden="true" - Content images describe purpose/subject in alt text (e.g., "Team photo at 2024 conference", not just "photo")
Example:
{/* Content image */}
<img src="team.jpg" alt="Team photo at 2024 annual conference" />
{/* Decorative divider */}
<img src="divider.svg" alt="" aria-hidden="true" />
Headings & Structure
- Hierarchical headings from
<h1>to<h6>(skip levels only deliberately) - Single
<h1>per page (page title) - Include skip link before main content:
<a href="#main-content">Skip to main content</a> - Heading anchors: add
scroll-margin-topto avoid overlap with fixed headers
Example:
<a href="#main-content" className="sr-only">Skip to main content</a>
<h1>Page Title</h1>
<section>
<h2 id="features" style={{ scrollMarginTop: "80px" }}>Features</h2>
{/* ... */}
</section>
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
</style>
Focus States
Visible Focus Indicators
- Never remove outlines without visible replacement
- Prefer
:focus-visibleover:focus(avoids outline on mouse click, shows on keyboard) - Required indicator: ring or border on focus; sufficient contrast (3:1 minimum)
- Style example:
focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500
Anti-pattern:
button {
outline: none; /* ❌ Removes focus completely */
}
Correct:
button:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
/* Tailwind equivalent */
button {
@apply focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500;
}
Compound Controls
- Groups of controls (tabs, segmented buttons, option groups) use
:focus-withinto show parent focus state - Example: tab group highlights when any tab is focused
Example:
.tab-group:focus-within {
border-color: #0066cc;
}
.tab-group button:focus-visible {
outline: none; /* Outlined by parent :focus-within */
}
Forms
Validation & Error Handling
Submit button states:
- Enabled by default (accept input)
- Disabled + spinner while request in flight
- Re-enabled on success or error
- Clear label while loading (e.g., "Saving..." not spinner-only)
Error handling:
- Inline errors next to fields (not top of form)
- Focus first invalid field on submit
- Specific error messages with fix/next step (not "Invalid input")
- Clear syntax for required fields (asterisk or label text)
Example:
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState({});
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
try {
await submitForm(formData);
} catch (err) {
setErrors(err.validationErrors);
// Focus first error field
const firstErrorField = Object.keys(err.validationErrors)[0];
document.getElementById(firstErrorField)?.focus();
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email *</label>
<input
id="email"
type="email"
name="email"
autoComplete="email"
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<div id="email-error" role="alert">
{errors.email} – Try a different email address.
</div>
)}
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? "Saving..." : "Save"}
</button>
</form>
);
Autocomplete & Input Handling
- Never block paste: avoid
onPastewithpreventDefault() - Meaningful
nameattributes: assist password managers and form restoration - Disable autocomplete for non-auth fields to prevent password manager popup:
autoComplete="off" - Enable for auth fields (email, password): let password manager assist
inputmodeattribute: hint at mobile keyboard (e.g.,inputMode="email"for email-like fields)
Example:
{/* Auth field - allow password manager */}
<input
type="email"
name="email"
autoComplete="email"
/>
{/* Non-auth field - disable password manager */}
<input
type="text"
name="search-query"
autoComplete="off"
inputMode="search"
/>
{/* Phone number */}
<input
type="tel"
name="phone"
autoComplete="tel"
inputMode="tel"
placeholder="555-123-4567…"
/>
Placeholders
- Placeholders not labels: show example format, not required instruction
- End with
…to indicate example:placeholder="[email protected]…" - Short, descriptive: help user understand expected input
Animation
Respect Motion Preferences
Always honor prefers-reduced-motion:
/* Disable animations for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
JavaScript check:
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!prefersReducedMotion) {
// Apply animation
}
Animation Best Practices
- Only animate
transformandopacity(GPU-accelerated) - Never
transition: all– list properties explicitly - Set
transform-originwhen rotating/scaling - SVG animations: wrap with
<g>, settransform-box: fill-box; transform-origin: center - Interruptible: animations should respond to user input (e.g., close modal during fade-out)
- Reasonable duration: 200–400ms for micro-interactions, 500–800ms for page transitions
- Easing: use cubic-bezier or ease-in-out; avoid linear for motion
Anti-pattern:
/* ❌ Slow, janky, disrespects prefers-reduced-motion */
transition: all 2s linear;
Correct:
/* ✅ GPU-accelerated, respectful, smooth */
@media (prefers-reduced-motion: no-preference) {
.fade-in {
animation: fadeIn 0.3s ease-out;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Explicit properties, not "all" */
transition: opacity 0.3s ease-out, transform 0.3s ease-out;
SVG example:
<svg viewBox="0 0 100 100" style={{ transformBox: "fill-box", transformOrigin: "center" }}>
<g style={{ transform: "rotate(45deg)" }}>
<circle cx="50" cy="50" r="40" />
</g>
</svg>
Typography
Text Formatting
- Ellipsis: use
…(HTML entity…), not...(three periods) - Quotes: use curly quotes
""(HTML entities“”), not straight" - Non-breaking spaces for:
- Measurements:
10 MB,5 GB - Shortcuts:
⌘ K,Ctrl + K - Brand/product names:
GitHub Copilot - Prevent orphaned words at line breaks
- Measurements:
Example:
<p>Use ⌘ K to open the command palette.</p>
<p>Download the file (50 MB) for offline access.</p>
<p>{item.name} — {item.category}</p>
Number & Date Formatting
- Loading states end with
…: "Loading…", "Saving…", not "Loading" or spinners-only - Tabular numbers: use
font-variant-numeric: tabular-numsfor columns of numbers (ensures monospace alignment) - Date/Time: use
Intl.DateTimeFormat, never hardcoded formats - Numbers: use
Intl.NumberFormatwith locale awareness
Example:
{/* Locale-aware date */}
const formattedDate = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date());
{/* Locale-aware number */}
const formattedNumber = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(1234.56);
{/* Tabular numbers (aligned columns) */}
<table style={{ fontVariantNumeric: "tabular-nums" }}>
<tr>
<td>1,234.56</td>
</tr>
</table>
Content Handling
Text Overflow
- Long text: apply
truncate,line-clamp-*(Tailwind), ortext-wrap: balancefor headings - Flex children: always set
min-w-0to allow child truncation (flex doesn't shrink below content size by default) - Break long words: use
break-wordsorword-break: break-wordas fallback
Example:
{/* Truncate long email */}
<div style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
[email protected]
</div>
{/* Line clamp */}
<p style={{ display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
Multi-line text truncated after 2 lines…
</p>
{/* Flex container with truncation */}
<div style={{ display: "flex", minWidth: 0 }}>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
Long text
</span>
</div>
Empty States
- Gracefully handle empty lists, no results, no data scenarios
- Show message: "No items yet" with optional action (import, create, etc.)
- Avoid blank screens: empty state is content, not an error
Example:
{items.length === 0 ? (
<div style={{ textAlign: "center", padding: "40px" }}>
<p>No deployments yet.</p>
<button onClick={openCreateDialog}>Create your first deployment</button>
</div>
) : (
{/* Items list */}
)}
Input Anticipation
- Anticipate short, average, and very long inputs
- Test with: real domain data, edge cases, longest valid inputs
- Adjust layout for mobile vs desktop (stack vs side-by-side)
Images
Image Dimensions & Optimization
- Every
<img>must have explicitwidthandheight(prevents Cumulative Layout Shift - CLS) - Aspect ratio: specify as number (e.g.,
16 / 9) or via CSS for<img>responsive scaling - Lazy loading: below-fold images use
loading="lazy" - Above-fold: use
priority(Next.js) orfetchpriority="high" - Modern formats: serve WebP with fallback (via
<picture>orsrcset)
Example:
{/* Above-fold, priority */}
<img
src="hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority
/>
{/* Below-fold, lazy load */}
<img
src="feature.jpg"
alt="Feature overview"
width={800}
height={600}
loading="lazy"
/>
{/* Responsive with aspect ratio */}
<img
src="responsive.jpg"
alt="Responsive image"
width={400}
height={300}
style={{ aspectRatio: "4 / 3", width: "100%", height: "auto" }}
/>
{/* Modern formats */}
<picture>
<source srcSet="image.webp" type="image/webp" />
<source srcSet="image.jpg" type="image/jpeg" />
<img src="image.jpg" alt="Fallback" width={400} height={300} />
</picture>
Performance
Virtualization
- Large lists (>50 items): virtualize with
content-visibility: autoor library (e.g.,virtua,react-window,react-virtual) - Unvirtualized lists render off-screen DOM, causing layout thrashing and slow scrolling
- Virtual scroll: only render visible items + buffer
Example:
import { Virtualizer } from "virtua";
<Virtualizer>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</Virtualizer>
{/* CSS-based virtualization */}
<div style={{ contentVisibility: "auto" }}>
{/* Large list */}
</div>
Layout Thrashing
Anti-pattern: reading layout in render (causes forced reflows):
{/* ❌ Triggers layout recalculation every render */}
<div>
{items.map((item) => {
const height = document.getElementById(item.id)?.offsetHeight;
return <div key={item.id} style={{ height }}>{item.name}</div>;
})}
</div>
Correct: batch reads/writes or avoid measurements in render:
useLayoutEffect(() => {
// Batch read
const rect = containerRef.current?.getBoundingClientRect();
// Batch write
setLayout(rect);
}, []);
// Or use ResizeObserver for responsive measurements
useEffect(() => {
const observer = new ResizeObserver(([entry]) => {
setWidth(entry.contentRect.width);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
Forms: Controlled vs Uncontrolled
- Prefer uncontrolled inputs (no state per keystroke)
- Controlled inputs must have cheap
onChangehandlers (avoid expensive computations per keystroke) - Use
defaultValuewhen form has initial state but input is uncontrolled
Example:
{/* Uncontrolled - simpler, more performant */}
<input type="text" defaultValue="initial" />
{/* Controlled - only if needed for real-time validation/masking */}
const [value, setValue] = useState("");
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)} {/* Keep cheap */}
/>
);
Font & Network Performance
- Critical fonts: preload with
<link rel="preload" as="font" href="..." type="font/..." crossOrigin> - Use
font-display: swapto show fallback immediately (avoid invisible text while loading) - CDN domains: warm with
<link rel="preconnect" href="https://cdn.example.com">
Example:
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preload" href="/font.woff2" as="font" type="font/woff2" crossOrigin />
<style>
@font-face {
font-family: "CustomFont";
src: url("/font.woff2") format("woff2");
font-display: swap;
}
</style>
</head>
Navigation & State
URL as Source of Truth
- Query params reflect UI state: filters, tabs, pagination, expanded panels, sorting
- Sync state to URL (use
nuqs,next/router, or similar library) - Deep linking: user can share/bookmark URL and restore full state
- Back button works intuitively (doesn't require custom handler for simple navigation)
Example:
import { useQueryState } from "next-usp"; // or similar
export default function ProductList() {
const [tab, setTab] = useQueryState("tab", { defaultValue: "all" });
const [sort, setSort] = useQueryState("sort", { defaultValue: "name" });
return (
<div>
<button onClick={() => setTab("featured")} data-active={tab === "featured"}>
Featured
</button>
<select value={sort} onChange={(e) => setSort(e.target.value)}>
<option value="name">Name</option>
<option value="price">Price</option>
</select>
{/* URL: ?tab=featured&sort=price */}
</div>
);
}
Links & Navigation
- Use
<a>or<Link>for navigation (enables Cmd/Ctrl+click, middle-click, new tab) - Never
onClickon<div>for navigation - Button vs Link: button = action, link = navigation
Anti-pattern:
<div onClick={() => navigate("/page")}>Go to page</div>
Correct:
<a href="/page">Go to page</a>
{/* or Next.js */}
<Link href="/page">Go to page</Link>
Destructive Actions
- Require confirmation (modal/dialog) or undo window (toast with "Undo" button)
- Avoid accidental clicks: clear label ("Delete permanently", not "Delete"), secondary button style
- After destructive action: show success confirmation (not silent success)
Example:
const handleDelete = async () => {
const confirmed = window.confirm("Are you sure? This cannot be undone.");
if (!confirmed) return;
try {
await deleteItem(id);
showToast("Item deleted", {
action: "Undo",
onAction: () => restoreItem(id),
});
} catch (err) {
showToast("Failed to delete item", { type: "error" });
}
};
return (
<button onClick={handleDelete} style={{ background: "red", color: "white" }}>
Delete permanently
</button>
);
Touch & Interaction
Touch Targets
- Minimum 44×44px touch target (or larger on mobile)
touch-action: manipulation: prevents double-tap zoom delay (safe on mobile)- Intentional
-webkit-tap-highlight-color: replace default gray highlight
Example:
button {
min-width: 44px;
min-height: 44px;
touch-action: manipulation;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
}
Scrolling & Overflow
- Modal/drawer:
overscroll-behavior: containprevents scroll-through - Full-bleed images:
overflow-x: hiddenon body, ensure content doesn't overflow - Momentum scrolling (iOS): use
-webkit-overflow-scrolling: touch(deprecated but still supported)
Example:
.modal {
overscroll-behavior: contain;
overflow-y: auto;
}
body {
overflow-x: hidden;
}
Drag & Drop
- Disable text selection during drag:
user-select: none - Mark dragged elements
inertto prevent nested interaction - Visual feedback: cursor change, opacity, or highlight
Example:
const [isDragging, setIsDragging] = useState(false);
const handleDragStart = (e) => {
setIsDragging(true);
e.dataTransfer.effectAllowed = "move";
};
return (
<div
draggable
onDragStart={handleDragStart}
onDragEnd={() => setIsDragging(false)}
style={{
userSelect: "none",
cursor: isDragging ? "grabbing" : "grab",
opacity: isDragging ? 0.7 : 1,
}}
>
Drag me
</div>
);
Auto-focus
- Desktop only: auto-focus primary input on page load (good UX)
- Mobile: skip auto-focus (keyboard appears unexpectedly, obscures content)
- Single, clear purpose: avoid auto-focus in multi-input forms (confusing)
Example:
const isMobile = /iPhone|iPad|Android/.test(navigator.userAgent);
return (
<input
autoFocus={!isMobile}
placeholder="Start typing…"
/>
);
Safe Areas & Layout
Notches & Safe Areas
- Full-bleed layouts (hero images, sidebars): account for notches using
env(safe-area-inset-*) - iPhone notch: left/top/right/bottom insets
- Padding: add padding for critical content
Example:
.header {
padding-left: max(1rem, env(safe-area-inset-left));
padding-right: max(1rem, env(safe-area-inset-right));
padding-top: max(1rem, env(safe-area-inset-top));
}
body {
padding-bottom: env(safe-area-inset-bottom);
}
Container Overflow
overflow-x: hiddenon containers to hide off-screen content- Ensure content doesn't overflow horizontally (test responsive breakpoints)
- Flex/Grid: prefer layout algorithms over JS measurement
Dark Mode & Theming
Color Scheme & Defaults
color-scheme: darkon<html>or root element (fixes scrollbar, input borders in dark mode)- Explicit
background-colorandcoloron form inputs (Windows dark mode needs this) <meta name="theme-color">matches page background color (affects browser UI)
Example:
<html style="color-scheme: dark">
<head>
<meta name="theme-color" content="#1a1a1a" />
</head>
</html>
<style>
input,
select,
textarea {
background-color: #fff;
color: #000;
}
@media (prefers-color-scheme: dark) {
input,
select,
textarea {
background-color: #222;
color: #fff;
}
}
</style>
Localization & i18n
Date, Number, and Language Handling
Intl.DateTimeFormat: never hardcode date formatsIntl.NumberFormat: handle currency, thousands separators, percentagesAccept-Languageheader ornavigator.languages[0]**: detect locale (never IP-based)langattribute on<html>: aids screen readers and spell-check
Example:
const userLocale = navigator.language; // "en-US", "fr-FR", etc.
const formattedDate = new Intl.DateTimeFormat(userLocale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date());
const formattedCurrency = new Intl.NumberFormat(userLocale, {
style: "currency",
currency: "USD",
}).format(1234.56);
return (
<html lang={userLocale.split("-")[0]}>
<div>{formattedDate}</div>
<div>{formattedCurrency}</div>
</html>
);
Hydration Safety
Server-Side Rendering (SSR) & Hydration
- Inputs with
valuerequireonChangehandler or usedefaultValuefor uncontrolled - Avoid hydration mismatch for date/time (server renders different value than client)
suppressHydrationWarningonly as last resort for intentional client-only content
Anti-pattern (hydration mismatch):
{/* Server renders "Fri Mar 30 2026", client renders today's date */}
<div>{new Date().toLocaleDateString()}</div>
Correct:
const [isMounted, setIsMounted] = useState(false);
useEffect(() => setIsMounted(true), []);
return (
<div>{isMounted ? new Date().toLocaleDateString() : null}</div>
);
Hover & Interactive States
Hover States
- Interactive elements (buttons, links) require
:hoverstate (CSS or Tailwind) - Hover increases contrast or changes color/shadow (clear feedback)
- Avoid relying on hover alone for important info (mobile has no hover)
Example:
button {
background-color: #0066cc;
transition: background-color 0.2s ease-out;
}
button:hover {
background-color: #0052a3;
}
/* Tailwind */
button {
@apply bg-blue-600 hover:bg-blue-700 transition-colors;
}
Active & Disabled States
- Active state (
:active,.active): visual feedback for pressed button - Disabled state: reduced opacity,
cursor: not-allowed, no hover effect
Example:
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button:disabled:hover {
background-color: #0066cc; /* No change on hover when disabled */
}
Content & Copy
Writing Style
- Active voice: "Install the CLI" not "The CLI will be installed"
- Second person: "You can deploy" not "One can deploy" or "I can deploy"
- Avoid first person: "Submit your form" not "I will submit your form"
- Title Case for headings/buttons (Chicago Manual of Style)
- Numerals for quantities: "8 deployments" not "eight deployments"
- Specific button labels: "Save API Key" not "Continue"
- Ampersand (
&) over "and" in space-constrained labels - Errors include fix/next step: "Email invalid – try a different address" not "Invalid email"
Example:
{/* ✓ Active, second person, specific */}
<button>Save Your API Key</button>
{/* ✓ Error with fix */}
<div>Email invalid – Enter a different address.</div>
{/* ✓ Numerals, active */}
{count} deployments available
{/* ✓ Title Case */}
<h2>Build & Deploy</h2>
Anti-patterns to Flag
| Anti-pattern | Issue | Fix |
|---|---|---|
user-scalable=no or maximum-scale=1 | Disables zoom; accessibility failure | Remove; allow pinch zoom |
onPaste with preventDefault | Blocks paste; user frustration | Allow paste; validate input after |
transition: all | Slow, unpredictable animations | List properties: transition: opacity 0.3s, transform 0.3s |
outline-none without :focus-visible replacement | No focus indicator; keyboard navigation breaks | Add :focus-visible:ring-2 or equivalent |
Inline onClick on <div> | Not a button semantically; fails accessibility | Use <button> |
<div role="button"> with onClick | Fake button; missing keyboard handlers | Use <button> |
Missing image width/height | Layout shift; poor LCP | Add explicit dimensions |
Large array .map() without virtualization | Slow scroll; DOM bloat | Use virtua, react-window, or content-visibility: auto |
| Form control without label | Accessibility failure | Add <label> or aria-label |
Icon button without aria-label | Unclear intent; screen reader says "button" | Add descriptive aria-label |
| Hardcoded date/time formats | Breaks in other locales | Use Intl.DateTimeFormat |
| Auto-focus without justification | Keyboard appears on mobile; confusing | Use autoFocus={!isMobile} or skip |
color-scheme missing | Dark mode form inputs broken | Add color-scheme: dark to root |
Decorative images without alt="" | Clutter accessibility tree | Add alt="" and aria-hidden="true" |
| Query params not in URL | Can't share state; no deep linking | Use nuqs or router to sync URL |
display: none for accessibility | Content removed from layout and tree | Use visually hidden class (see Headings section) |
Review Output Format
Group findings by file using file:line format (clickable in VS Code):
src/components/Button.tsx:12
❌ Icon-only button missing aria-label: <button><CloseIcon /></button>
Fix: Add aria-label="Close modal"
src/pages/form.tsx:34
❌ Inputs without labels or aria-label
Fix: Add <label htmlFor="email"> or aria-label="Email address"
src/styles/globals.css:2
❌ outline-none without :focus-visible replacement
Fix: Add :focus-visible { outline: 2px solid #0066cc; }
src/components/List.tsx:5
⚠ Large array .map() without virtualization (250+ items)
Suggest: Add content-visibility: auto or use virtua library
Terse descriptions. Skip explanation unless fix non-obvious. No preamble.
Review Checklist
Accessibility (WCAG 2.1 AA)
- Semantic HTML:
<button>,<a>,<label>used correctly - Icon-only buttons have
aria-label - Form inputs have visible labels or
aria-label - Keyboard navigation works (Tab, Enter, Space, Arrow keys, Escape)
- Focus indicators visible (
:focus-visibleorring-*) - Images have
alttext (descriptive) oralt=""(decorative) - Decorative icons have
aria-hidden="true" - Async updates use
aria-live="polite" - Color not sole means of conveying info (test grayscale)
- Contrast ratio ≥ 4.5:1 for normal text, ≥ 3:1 for large text
- Headings hierarchical (
<h1>–<h6>in order)
Forms
- Inputs have
nameandautoCompleteattributes - Semantic input types (
email,tel,number,url) - Paste not blocked (
onPasteevent doesn't preventDefault) - Error messages inline, focused on submit
- Submit button disabled during request
- Non-auth fields have
autoComplete="off"
Performance
- Images have explicit
widthandheight - Below-fold images have
loading="lazy" - Lists >50 items virtualized
- Animations use
transform/opacityonly - No
transition: all - Fonts use
font-display: swap
Animation & Motion
-
prefers-reduced-motionrespected - Animations interruptible
- Duration reasonable (200–800ms)
Dark Mode & Theming
-
color-scheme: darkon root - Form inputs have explicit colors in dark mode
-
<meta name="theme-color">set
Localization
- Dates use
Intl.DateTimeFormat - Numbers use
Intl.NumberFormat -
langattribute on<html> - No hardcoded format strings
Typography & Content
- Ellipsis (
…), not... - Curly quotes (
""), not straight" - Loading states end with
… - Button labels specific and active voice
- Error messages include fix/next step
Navigation & State
- Query params reflect UI state
- Links use
<a>or<Link>(not<div onClick>) - Destructive actions require confirmation
Touch & Mobile
- Touch targets ≥ 44×44px
-
touch-action: manipulationset - Auto-focus skipped on mobile
- Safe areas considered (
env(safe-area-inset-*))
Hydration (SSR)
- No hydration mismatch (date/time values)
- Controlled inputs have
onChange - Uncontrolled inputs use
defaultValue