skills/frontend-ui-engineering/SKILL.md
Builds production-quality, accessible, performant user interfaces. Applies to React/TypeScript frontends — verify stack before invoking; teams using other stacks should treat the principles (state-management ladder, AI-aesthetic avoidance, WCAG 2.1 AA) as transferable but the code patterns as React-specific. Use when building or modifying user-facing components, managing UI state, implementing layouts, or when output needs to look hand-built rather than AI-generated.
npx skillsauth add jankneumann/agentic-coding-tools frontend-ui-engineeringInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
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."
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)
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} onChange={() => onToggle(task.id)} />
<span className={task.done ? 'line-through text-muted' : ''}>{task.title}</span>
<Button variant="ghost" size="sm" onClick={() => 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.
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:
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. |
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;
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).
text-primary, bg-surface, border-default — not raw hex values.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.
Every interactive element must be reachable with Tab and operable with the keyboard.
// Native semantics: focusable + Enter/Space activation for free
<button onClick={handleClick}>Click me</button>
// Custom: requires manual tabIndex + key handling. Prefer <button>.
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter') handleClick();
if (e.key === ' ') e.preventDefault();
}}
onKeyUp={(e) => {
if (e.key === ' ') handleClick();
}}
>
Click me
</div>
// 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" />
function Dialog({ isOpen, onClose }: DialogProps) {
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) closeRef.current?.focus();
}, [isOpen]);
return (
<dialog open={isOpen} onClose={onClose}>
<button ref={closeRef} onClick={onClose}>Close</button>
{/* dialog content; focus trap inside */}
</dialog>
);
}
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" onClick={onCreateTask}>Create Task</Button>
</div>
);
}
return <ul role="list">{/* ... */}</ul>;
}
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.
// 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);
},
});
}
| 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. |
Tab actually works.rounded-2xl everywhere, stock hero section.useEffect fetching data instead of a server-state library — almost always a bug source (race conditions, stale data, no caching).<div onClick> doing the job of a <button>.Tab in a logical order, and Enter/Space activate it. Confirm by tabbing through the page yourself.rounded-2xl on everything, no purple/indigo defaults, no oversized padding, no lorem-ipsum strings shipped.references/accessibility-checklist.md were run against this component (forms, focus, motion preferences as applicable).testing
Create and edit Obsidian Flavored Markdown with wikilinks, embeds, callouts, properties, and other Obsidian-specific syntax. Use when working with .md files in Obsidian, or when the user mentions wikilinks, callouts, frontmatter, tags, embeds, or Obsidian notes.
tools
Interact with Obsidian vaults using the Obsidian CLI to read, create, search, and manage notes, tasks, properties, and more. Also supports plugin and theme development with commands to reload plugins, run JavaScript, capture errors, take screenshots, and inspect the DOM. Use when the user asks to interact with their Obsidian vault, manage notes, search vault content, perform vault operations from the command line, or develop and debug Obsidian plugins and themes.
data-ai
Create and edit Obsidian Bases (.base files) with views, filters, formulas, and summaries. Use when working with .base files, creating database-like views of notes, or when the user mentions Bases, table views, card views, filters, or formulas in Obsidian.
tools
Create and edit JSON Canvas files (.canvas) with nodes, edges, groups, and connections. Use when working with .canvas files, creating visual canvases, mind maps, flowcharts, or when the user mentions Canvas files in Obsidian.