| name | frontend |
| description | Use when building UI components, pages, layouts, dashboards, forms, or 3D scenes. Also for design systems, responsive design, and server components. |
Context Gathering Protocol
Design work produces generic output without project context. Before any UI implementation, you MUST have answers to these blocking questions:
- Target audience — Who uses this product and in what context? (e.g. "B2B SaaS admins on desktop" vs "teens on mobile")
- Use cases — What jobs are they trying to get done?
- Brand personality/tone — How should the interface feel? (e.g. "playful and casual" vs "enterprise and serious")
Where to find answers (in order):
- Check loaded instructions for a Design Context section — if present, proceed
- Check
.impeccable.md at project root — if it has the context, proceed
- If neither has it: ASK the user before writing any UI code. You cannot infer audience/tone from code — code tells you what was built, not who it's for
The AI Slop Test
Before shipping any UI, run this checklist. If 3+ items are true, the design looks AI-generated:
The test: If someone saw this UI and said "AI made this", would they be right? A distinctive interface makes someone ask "how was this made?" not "which AI made this?"
Gotchas
"use client" only for hooks/event handlers/browser APIs — over-marking kills SSR. Never blanket-mark a whole page; push the directive down to the smallest interactive leaf and keep the page/shell a server component
useEffect for derived state = anti-pattern. Compute inline during render, never sync with effects
key={index} dynamic lists = silent bugs on reorder/delete. Stable IDs only
- Next.js
fetch() Server Components caches by default prod. { cache: 'no-store' } or revalidate
- Importing server component into client component silently becomes client-side. Client-into-server fine, reverse NOT
Rules
Component Architecture
File colocation — keep everything related to a component together:
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
- Composition (Card+CardHeader) not boolean props (each doubles states)
- Compound components (Context+Provider) for sibling shared state. Never prop-drill
- Variant prop with defined values, not boolean modes (isPrimary, isGhost...)
- Children over renderX props. Render props ONLY when passing data back
- File order: types -> hooks -> useMemo -> useCallback -> render -> default export
- Context: state+actions+meta; provider only place knowing state impl. Lift state into provider for sibling access
- Controlled/uncontrolled via slot pattern. Semantic props (isLoading not loading), sensible defaults
- className/style overrides. Error Boundaries with retry
- Components over 200 lines must be split. Each component does one thing.
- Design-to-code translation: extract intent before pixels -- when implementing from Figma/design, extract in order: 1) Purpose (what should the user understand/do first?), 2) Visual hierarchy (primary/secondary/tertiary), 3) Stretch behavior (what grows, what's fixed), 4) States (loading/error/empty/disabled), 5) Edge cases (long text, 0 items, slow network). Never copy px values -- translate to scale/ratios/constraints. Fixed values are exceptions that need justification.
Composition over configuration — props create exponential state space, children are linear:
<Card>
<CardHeader>
<CardTitle>Tasks</CardTitle>
</CardHeader>
<CardBody>
<TaskList tasks={tasks} />
</CardBody>
</Card>
<Card
title="Tasks"
headerVariant="large"
bodyPadding="md"
content={<TaskList tasks={tasks} />}
/>
Separate data fetching from presentation — container handles data/states, presentational component is pure rendering:
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} />;
}
export function TaskList({ tasks }: { tasks: Task[] }) {
return (
<ul role="list" className="divide-y">
{tasks.map(task => <TaskItem key={task.id} task={task} />)}
</ul>
);
}
Error Boundary with retry — any subtree that fetches/renders remote data MUST be wrapped in an Error Boundary that exposes a retry action. An inline if (error) return ... is NOT an Error Boundary (it can't catch render-time throws); you need both. Use react-error-boundary — the canonical one-pass pattern:
import { ErrorBoundary } from 'react-error-boundary';
function TaskListError({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role="alert" className="rounded border border-danger bg-surface p-4">
<p className="text-danger">Failed to load tasks: {error.message}</p>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
);
}
export function TaskListSection() {
return (
<ErrorBoundary FallbackComponent={TaskListError} onReset={() => { /* invalidate/refetch */ }}>
<Suspense fallback={<TaskListSkeleton />}>
<TaskListContainer />
</Suspense>
</ErrorBoundary>
);
}
Forms
Controlled vs uncontrolled — use uncontrolled (refs, FormData) for simple submit-only forms. Use controlled (useState) when you need: real-time validation, conditional fields, or derived values.
Validation UX rules:
- Validate on blur (not on every keystroke) for fields the user hasn't submitted yet
- Validate on change AFTER the first blur or submission attempt
- Show field-level errors inline next to the field, not in a toast
- Server validates regardless of client — client validation is UX, not security
Form patterns with useActionState + progressive enhancement -- forms should work without JS via standard form submission, then enhance with useActionState for inline validation and optimistic UI. Always: <form action={formAction}> not onSubmit. Server validates regardless of client. Display field-level errors from server response. Reset form state on success.
React 19 + Server Components
useActionState forms, useOptimistic optimistic UI, use() promises/context; ref is regular prop (no forwardRef)
- React Compiler auto-memoizes; less manual useMemo/useCallback
- Prefer server components;
"use client" only for interactivity/browser APIs
- Minimize RSC boundary serialization, pass only needed fields
State Management
- local: useState/useReducer. feature-shared: Context+Reducer. server: TanStack Query/SWR. global: Zustand/Jotai
- URL state (searchParams) for filters, pagination, shareable UI state — don't put shareable state in useState
- Zustand: ALWAYS
useStore(s => s.count), NEVER destructure full store — unnecessary re-renders
- Suspense-first:
useSuspenseQuery + <Suspense fallback={<Skeleton/>}>. NOT isLoading early returns
- Never
if (isLoading) return <Spinner/> — use if (loading && !data) to avoid flash when data exists
- No inline fetch; isolate in feature
api/ layer. All responses typed
- Avoid prop drilling deeper than 3 levels — introduce context or restructure the component tree
UX Patterns
- Error hierarchy: inline (field) > toast (recoverable) > banner (partial) > full screen (fatal)
- Disable buttons + loading indicator during async submission. Every list needs empty state
- Skeleton loading for content areas, spinners only for actions. Use
aria-busy="true" on loading regions
Accessibility
Semantic HTML landmarks first, ARIA second -- use <main> (once), <header>, <footer>, <nav>, <aside> for page landmarks. <article> for self-contained content, <section> for thematic groups with heading. <figure>/<figcaption> for media. Heading levels (h1-h6) must never skip levels. ARIA roles only when no semantic HTML equivalent exists.
Keyboard navigation — every interactive element must work with keyboard:
<button onClick={handleClick}>Click me</button> // Focusable by default
<div onClick={handleClick}>Click me</div> // NOT focusable — never do this
<div role="button" tabIndex={0} onClick={handleClick}
onKeyDown={e => e.key === 'Enter' && handleClick()}>
ARIA labels — label interactive elements that lack visible text:
<button aria-label="Close dialog"><XIcon /></button>
<input aria-label="Search tasks" type="search" />
Focus management:
- Focus trap modals — Tab cycles within the modal, not behind it
- Move focus to dialog close button (or first focusable element) on open
- Restore focus to the trigger element on close
- After dynamic content changes (route change, item deletion), move focus to a meaningful target
WCAG 2.1 AA:
- Contrast: 4.5:1 for normal text, 3:1 for large text
prefers-reduced-motion: disable transforms, opacity only
- Never use color as the sole indicator of state — pair with icons, text, or patterns
- Don't rely solely on placeholder text for input purpose — use labels
Responsive Design
Mobile-first breakpoints — start with the smallest screen, add complexity upward:
<div className="
grid grid-cols-1 /* Mobile: single column */
sm:grid-cols-2 /* ≥640px: 2 columns */
lg:grid-cols-3 /* ≥1024px: 3 columns */
gap-4
">
Test at these breakpoints: 320px, 768px, 1024px, 1440px.
CSS container queries for component-based responsive design -- use @container instead of @media for components that must adapt to their parent, not the viewport. Define containers: container-type: inline-size. Query them: @container (min-width: 400px) { ... }. Tailwind: @container / @lg:. Components become portable across layouts without media query rewrites. Use @media only for page-level layout shifts.
Project Structure
- Feature-based:
features/{name}/api/, components/, hooks/, types/, index.ts
- Shared UI
components/ui/, layouts components/layout/. @/ = src/
- Types colocated with feature (
./types/../types), NOT global @/types/ dump — if you see a type imported from a global @/types/... path, move it to the feature folder
- No feature logic in shared components; use hooks/context not prop drilling
- TS strict, no
any, import type, JSDoc public APIs. Folder-based routing, lazy-load routes
- Micro-frontend decision gate -- micro-frontends only when: 3+ teams own distinct UI areas AND teams deploy at different cadences AND coordination cost exceeds MFE overhead. Do NOT use when: single team, early-stage product, strong UI cohesion needed (cross-boundary animations), or performance is critical (extra network round-trips). Wrong splits are expensive to undo.
Typography Guidelines
- Choose fonts that have personality — avoid overused fonts (Inter, Roboto, Arial, Open Sans, system defaults)
- Pair a distinctive display font with a refined body font
- Use a modular type scale with fluid sizing:
clamp(1rem, 0.5rem + 1vw, 1.5rem)
- Vary font weights and sizes to create clear visual hierarchy — don't use the same size everywhere
- Heading levels form a semantic hierarchy: h1 (page title, one per page) > h2 (section) > h3 (subsection). Never skip levels. Never use heading styles on non-heading content.
- No monospace typography as lazy shorthand for "developer vibes"
- No large icons with rounded corners above every heading — they rarely add value
Color & Theme Guidelines
- Use oklch/color-mix/light-dark for perceptually uniform palettes
- Semantic color tokens (
text-primary, bg-surface, border-default) — never raw hex in components
- Tint your neutrals toward your brand hue — even subtle tinting creates subconscious cohesion
- Dominant colors with sharp accents outperform timid, evenly-distributed palettes
- Never use gray text on colored backgrounds — use a shade of the background color instead
- Never use pure black (#000) or pure white (#fff) — always tint
- Dark mode is not just "invert colors" — it requires its own palette with adjusted contrast
Styling
- Design tokens always, never hard-coded colors/spacing. Use the project's spacing scale — no arbitrary pixel values
- Dark: bg-neutral-bg1/bg2/bg3 layers, border-border-subtle, glass via
@layer
- Tailwind+CVA | CSS Modules | MUI sx | @layer+backdrop-blur glassmorphism
- Anchor Positioning for tooltips/popovers -- CSS
anchor() function positions elements relative to another element without JS. Replaces Floating UI/Popper for most tooltip/popover cases. position-anchor: --my-trigger; top: anchor(bottom); left: anchor(center);. Progressive enhancement: fall back to fixed positioning.
Performance
Animation & 3D
- Framer Motion staggerChildren/spring/useScroll. GSAP ScrollTrigger+scrub, Lenis smooth scroll
- Parallax: bg 0.2x, mid 0.5x, fg 1.0x, floating 1.2x. No scroll hijacking/overload
- View Transitions API for SPA navigation -- use
document.startViewTransition() for page-level transitions instead of wrapping everything in AnimatePresence. Browser-native, works with any framework, costs 0 JS bundle. Define transition names via view-transition-name CSS property. Combine with @media (prefers-reduced-motion: reduce) to disable.
- R3F | Spline | Three.js. <100K polys, GLB+draco+webp <5MB. 3D only if image won't suffice
Presentations & Portfolio
- HTML: single file, zero deps, inline CSS/JS, scroll-snap, keyboard+touch, progress bar
- PPT: python-pptx extract -> confirm -> style -> HTML
- Portfolio 30s: identity/skills/work/contact. Case studies: quantified outcomes not tasks
Misc
- React Flow: memo nodes, NodeResizer, typed data. Scaffold: component/types/styles/tests/stories/barrel
- No silent errors; surface via toast/UI. Vue 3: composables+provide/inject. Svelte 5: runes+snippets
- FFCI (Fit+Reuse+Perf-Complexity-Maintenance): proceed >=6, redesign <=2
Pre-Output Trigger Checklist (STOP — scan before you emit code)
These are the rules most often missed. Each is a concrete IF → THEN. Scan your draft against every trigger; if the trigger fires and you didn't do the THEN, fix it before output.
'use client' at top of a file → only keep it if the file itself uses hooks/event handlers/browser APIs. If the page is mostly data display, split: keep the interactive leaf as the only 'use client' component, make the page/shell a server component. Never blanket-mark a whole page client.
- A type imported from
@/types/... (global dump) → colocate it: define/import the type from the feature folder (e.g. ./types or ../types), not a global types/. If only an import line is shown, change it to a feature-local path and define the type inline if needed.
if (isLoading) return <Spinner/> / if (loading) ... early return → replace with Suspense-first: useSuspenseQuery + <Suspense fallback={<Skeleton/>}>. If you must keep a flag, gate it if (loading && !data) so cached data never flashes. The bare if (isLoading) return early-return is the trap — remove it.
- Search/filter input whose
onChange updates query/filter state every keystroke → debounce 300–500ms (useDebouncedValue, useDeferredValue, or a setTimeout cleanup). Raw onChange={e => setSearch(e.target.value)} driving a filter/fetch is the trap.
fetch(...) written inside a component or useEffect → move it to the feature api/ layer and call it via a typed function (and a query hook). No inline fetch/await fetch in component bodies — this includes form submit handlers, not just data loads.
- A component's body mixes types, hooks, and JSX out of order → enforce order inside every component: types → hooks (useState/useQuery) → useMemo → useCallback → render/JSX → default export. No module-level mutable
let, no hooks/refs declared outside a component.
- Any element with
onClick that is not a native <button>/<a> (e.g. <div>, <article>, <li> onClick) → make it a real <button>, OR add ALL of: role="button", tabIndex={0}, and an onKeyDown handling Enter/Space. A clickable non-button without keyboard support is the trap — applies to clickable cards/rows too.
- Component tree that fetches/renders remote data → wrap it in an Error Boundary with a retry action — import
{ ErrorBoundary } from react-error-boundary, give it a FallbackComponent whose fallback renders a Retry button wired to resetErrorBoundary, and an onReset that refetches. See the canonical "Error Boundary with retry" example above. An inline if (error) return ... is NOT an Error Boundary (it can't catch render throws) — you need the actual <ErrorBoundary> wrapper AND a retry button.
- A list that can exceed ~50 items (any
.map over fetched/filtered data with no fixed small cap) → virtualize with @tanstack/react-virtual (useVirtualizer). When item count is unbounded, assume it can be long and virtualize.
- A heavy or below-fold component (chart, rich editor, map, large modal body, any 100KB+ dependency, or one flagged "heavy") → wrap it in
next/dynamic, JAMAIS le supprimer ni le décrire en prose. Replace the static import with const X = dynamic(() => import('./X'), { ssr: false, loading: () => <Skeleton/> }) and keep rendering <X .../> exactly where it was. Deleting the component, or writing "would use next/dynamic" / "à loader si lourd" instead of the actual dynamic(() => import(...)) call, is the trap. The component remains its own module — you do NOT inline its body.
- A modal/dialog/overlay → mandatory minimum, never ship without BOTH: (a) close on
Escape (keydown listener, cleaned up on unmount), and (b) move focus into the dialog (close button or first focusable element) on open. Overlay-click-only close is the trap. Then add the two best-practice mechanisms for a complete dialog: trap Tab focus inside (Tab/Shift+Tab cycle within, never escape behind) and restore focus to the trigger element on close (save document.activeElement on open, .focus() it on cleanup).
Verification Checklist
After building UI: