From ui-excellence
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.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ui-excellence:web-standardsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
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.
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.
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 structureAnti-pattern:
<div onClick={handleClick} role="button">
Click me
</div>
Correct:
<button onClick={handleClick}>
Click me
</button>
aria-label="..." describing intentaria-label or aria-labelledby<label htmlFor="id"> (clickable target) or aria-labelrole="..." only when semantic element unavailablearia-hidden="true" to skip in accessibility treearia-live="polite" (toasts, validation messages, status updates)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>
<button>, <a>, <input>, etc.) are keyboard-focusable by defaultonKeyDown or onKeyUp handlersExample:
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>
);
aria-labelhtmlFor attribute or label wrapping controltype="email", type="tel", type="url", type="number" (enables mobile keyboards, browser validation)autocomplete attribute (e.g., autocomplete="email", autocomplete="current-password")spellCheck={false}aria-describedby to link input to error: <input aria-describedby="email-error" /><div id="email-error">{error}</div>htmlFor)<fieldset> and <legend>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>
<img> tags require alt text (descriptive) or alt="" (if purely decorative)alt="" and aria-hidden="true"Example:
{/* Content image */}
<img src="team.jpg" alt="Team photo at 2024 annual conference" />
{/* Decorative divider */}
<img src="divider.svg" alt="" aria-hidden="true" />
<h1> to <h6> (skip levels only deliberately)<h1> per page (page title)<a href="#main-content">Skip to main content</a>scroll-margin-top to avoid overlap with fixed headersExample:
<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-visible over :focus (avoids outline on mouse click, shows on keyboard)focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500Anti-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;
}
:focus-within to show parent focus stateExample:
.tab-group:focus-within {
border-color: #0066cc;
}
.tab-group button:focus-visible {
outline: none; /* Outlined by parent :focus-within */
}
Submit button states:
Error handling:
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>
);
onPaste with preventDefault()name attributes: assist password managers and form restorationautoComplete="off"inputmode attribute: 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…"
/>
… to indicate example: placeholder="[email protected]…"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
}
transform and opacity (GPU-accelerated)transition: all – list properties explicitlytransform-origin when rotating/scaling<g>, set transform-box: fill-box; transform-origin: centerAnti-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>
… (HTML entity …), not ... (three periods)" " (HTML entities “ ”), not straight "10 MB, 5 GB⌘ K, Ctrl + KGitHub CopilotExample:
<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>
…: "Loading…", "Saving…", not "Loading" or spinners-onlyfont-variant-numeric: tabular-nums for columns of numbers (ensures monospace alignment)Intl.DateTimeFormat, never hardcoded formatsIntl.NumberFormat with locale awarenessExample:
{/* 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>
truncate, line-clamp-* (Tailwind), or text-wrap: balance for headingsmin-w-0 to allow child truncation (flex doesn't shrink below content size by default)break-words or word-break: break-word as fallbackExample:
{/* 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>
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 */}
)}
<img> must have explicit width and height (prevents Cumulative Layout Shift - CLS)16 / 9) or via CSS for <img> responsive scalingloading="lazy"priority (Next.js) or fetchpriority="high"<picture> or srcset)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>
content-visibility: auto or library (e.g., virtua, react-window, react-virtual)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>
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();
}, []);
onChange handlers (avoid expensive computations per keystroke)defaultValue when form has initial state but input is uncontrolledExample:
{/* 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 */}
/>
);
<link rel="preload" as="font" href="..." type="font/..." crossOrigin>font-display: swap to show fallback immediately (avoid invisible text while loading)<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>
nuqs, next/router, or similar library)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>
);
}
<a> or <Link> for navigation (enables Cmd/Ctrl+click, middle-click, new tab)onClick on <div> for navigationAnti-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>
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-action: manipulation: prevents double-tap zoom delay (safe on mobile)-webkit-tap-highlight-color: replace default gray highlightExample:
button {
min-width: 44px;
min-height: 44px;
touch-action: manipulation;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
}
overscroll-behavior: contain prevents scroll-throughoverflow-x: hidden on body, ensure content doesn't overflow-webkit-overflow-scrolling: touch (deprecated but still supported)Example:
.modal {
overscroll-behavior: contain;
overflow-y: auto;
}
body {
overflow-x: hidden;
}
user-select: noneinert to prevent nested interactionExample:
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>
);
Example:
const isMobile = /iPhone|iPad|Android/.test(navigator.userAgent);
return (
<input
autoFocus={!isMobile}
placeholder="Start typing…"
/>
);
env(safe-area-inset-*)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);
}
overflow-x: hidden on containers to hide off-screen contentcolor-scheme: dark on <html> or root element (fixes scrollbar, input borders in dark mode)background-color and color on 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>
Intl.DateTimeFormat: never hardcode date formatsIntl.NumberFormat: handle currency, thousands separators, percentagesAccept-Language header or navigator.languages[0]**: detect locale (never IP-based)lang attribute on <html>: aids screen readers and spell-checkExample:
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>
);
value require onChange handler or use defaultValue for uncontrolledsuppressHydrationWarning only as last resort for intentional client-only contentAnti-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 state (CSS or Tailwind)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, .active): visual feedback for pressed buttoncursor: not-allowed, no hover effectExample:
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button:disabled:hover {
background-color: #0066cc; /* No change on hover when disabled */
}
&) over "and" in space-constrained labelsExample:
{/* ✓ 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-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) |
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.
<button>, <a>, <label> used correctlyaria-labelaria-label:focus-visible or ring-*)alt text (descriptive) or alt="" (decorative)aria-hidden="true"aria-live="polite"<h1>–<h6> in order)name and autoComplete attributesemail, tel, number, url)onPaste event doesn't preventDefault)autoComplete="off"width and heightloading="lazy"transform/opacity onlytransition: allfont-display: swapprefers-reduced-motion respectedcolor-scheme: dark on root<meta name="theme-color"> setIntl.DateTimeFormatIntl.NumberFormatlang attribute on <html>…), not ..." "), not straight "…<a> or <Link> (not <div onClick>)touch-action: manipulation setenv(safe-area-inset-*))onChangedefaultValueCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub fernando-bertholdo/4-successful-ai-life --plugin ui-excellence