Frontend UI Engineering
Stack note: This skill ships with React/TypeScript examples because that is the reference stack used across these methodology skills. Teams on Vue, Svelte, Solid, Angular, or server-rendered stacks (Rails/Django/Phoenix) should treat the principles (component composition, the state-management ladder, AI-aesthetic avoidance, WCAG 2.1 AA discipline) as fully transferable, but treat the code patterns (
useState,React.memo,useMutation, JSX) as React-specific. Before invoking, verify the project's stack — if it is not React, take the principles and translate the patterns to the local idiom rather than copying JSX literally.
Overview
Build production-quality user interfaces that are accessible, performant, and visually polished. The goal is UI that looks like it was built by a design-aware engineer at a top company — not like it was generated by an AI. This means real design system adherence, proper accessibility, thoughtful interaction patterns, and no generic "AI aesthetic."
When to Use
- Building new UI components or pages
- Modifying existing user-facing interfaces
- Implementing responsive layouts
- Adding interactivity or state management
- Fixing visual or UX issues
Component Architecture
File Structure
Colocate everything related to a component:
src/components/
TaskList/
TaskList.tsx # Component implementation
TaskList.test.tsx # Tests
TaskList.stories.tsx # Storybook stories (if using)
use-task-list.ts # Custom hook (if complex state)
types.ts # Component-specific types (if needed)
Component Patterns
Prefer composition over configuration:
// Good: Composable
<Card>
<CardHeader>
<CardTitle>Tasks</CardTitle>
</CardHeader>
<CardBody>
<TaskList tasks={tasks} />
</CardBody>
</Card>
// Avoid: Over-configured (every variant requires a new prop)
<Card
title="Tasks"
headerVariant="large"
bodyPadding="md"
content={<TaskList tasks={tasks} />}
/>
Keep components focused — one job each:
export function TaskItem({ task, onToggle, onDelete }: TaskItemProps) {
return (
<li className="flex items-center gap-3 p-3">
<Checkbox checked={task.done} => onToggle(task.id)} />
<span className={task.done ? 'line-through text-muted' : ''}>{task.title}</span>
<Button variant="ghost" size="sm" => onDelete(task.id)}>
<TrashIcon />
</Button>
</li>
);
}
Separate data fetching from presentation:
// Container: handles data
export function TaskListContainer() {
const { tasks, isLoading, error, refetch } = useTasks();
if (isLoading) return <TaskListSkeleton />;
if (error) return <ErrorState message="Failed to load tasks" retry={refetch} />;
if (tasks.length === 0) return <EmptyState message="No tasks yet" />;
return <TaskList tasks={tasks} />;
}
// Presentation: handles rendering
export function TaskList({ tasks }: { tasks: Task[] }) {
return (
<ul role="list" className="divide-y">
{tasks.map(task => <TaskItem key={task.id} task={task} />)}
</ul>
);
}
The container component never returns the same JSX in two branches; the presentation component never calls a fetch hook.
State Management — Decision Ladder
Choose the simplest tier that works. Climb only when the current tier fails for a concrete reason. Going straight to a global store is the most common mistake.
| Tier | Tool | When to use |
|---|---|---|
| 1. Local | useState, useReducer |
Component-specific UI state — toggles, hover, form draft. |
| 2. Lifted | parent useState + props |
Shared between 2-3 sibling components. |
| 3. Context | React.Context |
Read-heavy, write-rare values: theme, auth identity, locale, feature flags. |
| 4. URL | searchParams, route state |
Filters, pagination, tab selection, sort order — anything that should survive reload or be shareable as a link. |
| 5. Server | React Query, SWR, RTK Query | Remote data with caching, refetching, optimistic updates. Most "global" state is actually server state. |
| 6. Global | Zustand, Redux, Jotai | Complex client state shared app-wide that isn't server state (rare; e.g. cross-cutting wizard progress, draft autosave). |
Avoid prop drilling deeper than 3 levels. If you're passing props through components that don't use them, climb to the right tier (usually context or server-state cache) — not a global store.
Decision shortcut for new state:
- Is this data fetched from a server? → Server state (Tier 5). Stop. Don't put it in Redux.
- Should this state survive reload or be shareable via URL? → URL state (Tier 4).
- Is it used only by this component? → Local (Tier 1).
- Is it used by 2-3 siblings? → Lifted (Tier 2).
- Is it cross-cutting and read-heavy? → Context (Tier 3).
- None of the above? → Global (Tier 6). Justify in a comment.
Design System Adherence
Avoid the "AI Aesthetic"
AI-generated UI has recognizable tells. Avoid all of them:
| AI Default | Why It's a Problem | Production Quality |
|---|---|---|
| Purple/indigo everything | Models default to visually "safe" palettes, making every app look identical. | Use the project's actual color palette. |
| Excessive gradients | Gradients add visual noise and clash with most design systems. | Flat or subtle gradients matching the system. |
rounded-2xl on everything |
Maximum rounding signals "friendly" but ignores the hierarchy of corner radii in real designs. | Consistent border-radius from the design system. |
| Generic hero sections | Template-driven layout with no connection to the actual content or user need. | Content-first layouts. |
| Lorem-ipsum-style copy | Placeholder text hides layout problems that real content reveals (length, wrapping, overflow). | Realistic placeholder content during development. |
| Oversized padding everywhere | Equal generous padding destroys visual hierarchy and wastes screen space, especially on mobile. | Consistent spacing scale; tighter where information density matters. |
| Stock card grids | Uniform grids are a layout shortcut that ignores information priority and scanning patterns. | Purpose-driven layouts. |
| Shadow-heavy design | Layered shadows add depth that competes with content and slows rendering on low-end devices. | Subtle or no shadows unless the design system specifies. |
| Emoji bullet points in product UI | Signals "AI demo," not "real product." | Icons from the design system, or no decoration. |
Spacing and Layout
Use a consistent spacing scale. Don't invent values:
/* Good: on the project's 4px scale */
padding: 1rem; /* 16px */
gap: 0.75rem; /* 12px */
/* Bad: arbitrary pixel values */
padding: 13px;
margin-top: 2.3rem;
Typography
Respect the type hierarchy:
h1 → Page title (one per page)
h2 → Section title
h3 → Subsection title
body → Default text
small → Secondary/helper text
Don't skip heading levels (no <h1> jumping straight to <h4>). Don't use heading styles for non-heading content (use a font utility class instead).
Color
- Use semantic color tokens:
text-primary,bg-surface,border-default— not raw hex values. - Ensure sufficient contrast (4.5:1 for normal text, 3:1 for large text or non-text UI).
- Don't rely solely on color to convey information (pair red/green with icons or text).
Accessibility (WCAG 2.1 AA)
This section covers the bar every component must clear. For the deeper checklist (forms, focus management, screen-reader testing, motion preferences), see references/accessibility-checklist.md.
Keyboard Navigation
Every interactive element must be reachable with Tab and operable with the keyboard.
// Native semantics: focusable + Enter/Space activation for free
<button me</button>
// Custom: requires manual tabIndex + key handling. Prefer <button>.
<div
role="button"
tabIndex={0}
=> {
if (e.key === 'Enter') handleClick();
if (e.key === ' ') e.preventDefault();
}}
=> {
if (e.key === ' ') handleClick();
}}
>
Click me
</div>
ARIA Labels
// Icon-only button — give it an accessible name
<button aria-label="Close dialog"><XIcon /></button>
// Form inputs — programmatic label via <label for>
<label htmlFor="email">Email</label>
<input id="email" type="email" />
// Or aria-label when no visible label exists
<input aria-label="Search tasks" type="search" />
Focus Management
function Dialog({ isOpen, onClose }: DialogProps) {
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) closeRef.current?.focus();
}, [isOpen]);
return (
<dialog open={isOpen}
<button ref={closeRef}
{/* dialog content; focus trap inside */}
</dialog>
);
}
Meaningful Empty and Error States
Don't ship blank screens.
function TaskList({ tasks }: { tasks: Task[] }) {
if (tasks.length === 0) {
return (
<div role="status" className="text-center py-12">
<TasksEmptyIcon className="mx-auto h-12 w-12 text-muted" />
<h3 className="mt-2 text-sm font-medium">No tasks</h3>
<p className="mt-1 text-sm text-muted">Get started by creating a new task.</p>
<Button className="mt-4" Task</Button>
</div>
);
}
return <ul role="list">{/* ... */}</ul>;
}
Responsive Design
Design mobile-first, then expand:
<div className="
grid grid-cols-1 /* Mobile: single column */
sm:grid-cols-2 /* Small: 2 columns */
lg:grid-cols-3 /* Large: 3 columns */
gap-4
" />
Test at: 320px, 768px, 1024px, 1440px. The 320px check catches the most layout bugs.
Loading and Transitions
// Skeletons (not spinners) for content
function TaskListSkeleton() {
return (
<div className="space-y-3" aria-busy="true" aria-label="Loading tasks">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
))}
</div>
);
}
// Optimistic updates for perceived speed
function useToggleTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: toggleTask,
onMutate: async (taskId) => {
await queryClient.cancelQueries({ queryKey: ['tasks'] });
const previous = queryClient.getQueryData<Task[]>(['tasks']);
queryClient.setQueryData<Task[]>(['tasks'], (old) =>
(old ?? []).map((t) => (t.id === taskId ? { ...t, done: !t.done } : t))
);
return { previous };
},
onError: (_err, _taskId, context) => {
if (context?.previous) queryClient.setQueryData(['tasks'], context.previous);
},
});
}
Common Rationalizations
| Rationalization | Why it's wrong |
|---|---|
| "Accessibility is a nice-to-have" | It's a legal requirement in many jurisdictions and a baseline engineering quality standard. |
| "We'll make it responsive later" | Retrofitting responsive design is 3x harder than building it from the start; layout assumptions get baked into every child component. |
| "The design isn't final, so I'll skip styling" | Use the design system defaults. Unstyled UI creates a broken first impression for reviewers and hides real layout issues. |
| "This is just a prototype" | Prototypes become production code. Build the foundation right; you won't get a second pass. |
| "The AI aesthetic is fine for now" | It signals low quality and dates instantly. Use the project's actual design system from the first commit. |
| "I'll put it in Redux to be safe" | Most "global" state is server state; reach for React Query / SWR before a client-side store. The state ladder exists for a reason. |
| "Lorem ipsum is fine for layout" | Real content reveals wrapping, length, overflow, and i18n bugs that lorem ipsum hides. Use representative content. |
Red Flags
- Components longer than ~200 lines (split them into smaller pieces).
- Inline styles or arbitrary pixel values that aren't on the design system's spacing scale.
- Missing loading, error, or empty states (only the happy path is rendered).
- No keyboard test in the PR description; reviewer can't tell whether
Tabactually works. - Color as the sole indicator of state (red/green without text or icons).
- Generic "AI look": purple gradients, oversized cards,
rounded-2xleverywhere, stock hero section. - New state went straight into a global store without trying local/lifted/context/URL/server first.
useEffectfetching data instead of a server-state library — almost always a bug source (race conditions, stale data, no caching).- A
<div onClick>doing the job of a<button>.
Verification
- The component renders without console errors or React warnings on first paint.
- Every interactive element is reachable via
Tabin a logical order, andEnter/Spaceactivate it. Confirm by tabbing through the page yourself. - Loading, error, and empty states are all implemented and visible in Storybook or a manual demo route.
- The component has been viewed at 320px, 768px, 1024px, and 1440px without horizontal scroll or clipped content.
- State for this component sits at the lowest tier of the state ladder that works (cite which tier and why).
- No values from the AI-aesthetic table appear: no
rounded-2xlon everything, no purple/indigo defaults, no oversized padding, no lorem-ipsum strings shipped. - axe-core / browser dev-tools accessibility panel reports zero serious or critical violations on the rendered output.
- The deeper accessibility checks in
references/accessibility-checklist.mdwere run against this component (forms, focus, motion preferences as applicable).