| name | frontend-ui-engineering |
| description | Builds production-quality UIs. Use when building or modifying user-facing interfaces. Use when creating components, implementing layouts, managing state, or when the output needs to look and feel production-quality rather than AI-generated. |
Project-specific note: This skill uses Angular 22 examples. The project's
canonical frontend conventions — component recipe, performance rules, layering,
styling, routing, accessibility, and testing — are defined in
instructions/frontend.instructions.md.
UI primitive rules are in
instructions/ui-primitives.instructions.md.
Where this skill's general advice conflicts with project instructions, the
instructions take precedence. See SKILLS_INDEX.md
for framework translations (Prisma→Drizzle, React→Angular, etc.).
Frontend UI Engineering
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/app/ui/
task-item/
task-item.component.ts # Component implementation
task-item.component.spec.ts # Tests
task-item.component.html # Template (if inline grows too large)
Project-specific component conventions are defined in
instructions/frontend.instructions.md.
Every component is standalone, uses OnPush change detection, and signal APIs
(input(), output(), model(), signal, computed).
Component Patterns
Prefer composition over configuration:
// Good: Composable (Angular template)
<app-card>
<app-card-header>
<app-card-title>Tasks</app-card-title>
</app-card-header>
<app-card-body>
<app-task-list [tasks]="tasks()" />
</app-card-body>
</app-card>
Keep components focused:
@Component({
selector: 'app-task-item',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CheckboxComponent, ButtonComponent],
template: `
<li class="flex items-center gap-3 p-3">
<app-checkbox
[checked]="task().done"
(change)="toggle.emit(task().id)" />
<span
[class.line-through]="task().done"
[class.text-muted]="task().done">{{ task().title }}</span>
<app-button variant="ghost" size="sm" (click)="delete.emit(task().id)"
ariaLabel="Delete task">
<app-icon name="trash" />
</app-button>
</li>
`,
})
export class TaskItemComponent {
task = input.required<Task>();
toggle = output<string>();
delete = output<string>();
}
Separate data fetching from presentation:
@Component({
selector: 'app-task-list-container',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TaskListComponent, TaskListSkeletonComponent, ErrorStateComponent, EmptyStateComponent],
template: `
@if (store.loading()) {
<app-task-list-skeleton />
} @else if (store.error()) {
<app-error-state
message="Failed to load tasks"
(retry)="store.refresh()" />
} @else if (store.tasks().length === 0) {
<app-empty-state message="No tasks yet" />
} @else {
<app-task-list [tasks]="store.tasks()" />
}
`,
})
export class TaskListContainerComponent {
store = inject(AppStore);
}
@Component({
selector: 'app-task-list',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TaskItemComponent],
template: `
<ul role="list" class="divide-y">
@for (task of tasks(); track task.id) {
<app-task-item
[task]="task"
(toggle)="onToggle($event)"
(delete)="onDelete($event)" />
}
</ul>
`,
})
export class TaskListComponent {
tasks = input.required<Task[]>();
toggle = output<string>();
delete = output<string>();
onToggle(id: string) { this.toggle.emit(id); }
onDelete(id: string) { this.delete.emit(id); }
}
State Management
Choose the simplest approach that works:
Component signal / model() → Component-specific UI state
Service with signals → Shared between sibling components (providedIn)
AppStore → Global app state, remote data, caching
Router query params → Filters, pagination, shareable UI state
LocalStorage signal wrapper → Persisted preferences
Project-specific layering rules are in
instructions/frontend.instructions.md.
Components get data through AppStore; only data/api.service.ts talks HTTP.
Screens never inject ApiService directly unless the data is screen-local and
read-only.
Avoid prop drilling deeper than 3 levels. If you're passing inputs through
components that don't use them, introduce a shared service or restructure the
component tree.
Design System Adherence
Avoid the AI Aesthetic
AI-generated UI has recognizable patterns. Avoid all of them:
| AI Default | Why It Is 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 design system |
| Rounded everything (rounded-2xl) | 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 |
| Oversized padding everywhere | Equal generous padding destroys visual hierarchy and wastes screen space | Consistent spacing scale |
| 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 |
Spacing and Layout
Use a consistent spacing scale. Don't invent values:
padding: 1rem;
gap: 0.75rem;
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. Don't use heading styles for non-heading content.
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)
- Don't rely solely on color to convey information (use icons, text, or patterns too)
Accessibility (WCAG 2.1 AA)
Every component must meet these standards. Project-specific rules are in
instructions/frontend.instructions.md.
Keyboard Navigation
<!-- Every interactive element must be keyboard accessible -->
<button (click)="handleClick()">Click me</button> <!-- ✓ Focusable by default -->
<div (click)="handleClick()">Click me</div> <!-- ✗ Not focusable -->
<div role="button" tabindex="0" (click)="handleClick()" <!-- ✓ But prefer <button> -->
(keydown.enter)="handleClick()"
(keydown)="onSpaceKey($event)">
Click me
</div>
ARIA Labels
<!-- Label interactive elements that lack visible text -->
<button aria-label="Close dialog"><app-icon name="x" /></button>
<!-- Label form inputs -->
<label for="email">Email</label>
<input id="email" type="email" />
<!-- Or use aria-label when no visible label exists -->
<input aria-label="Search tasks" type="search" />
Focus Management
@Component({
selector: 'app-dialog',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<dialog [open]="isOpen()">
<button #closeBtn (click)="close.emit()">Close</button>
<!-- dialog content -->
</dialog>
`,
})
export class DialogComponent {
isOpen = input.required<boolean>();
close = output();
closeBtn = viewChild.required<ElementRef<HTMLButtonElement>>('closeBtn');
constructor() {
effect(() => {
if (this.isOpen()) {
this.closeBtn().nativeElement.focus();
}
});
}
}
Meaningful Empty and Error States
<!-- Don't show blank screens; always handle every state -->
@if (tasks().length === 0) {
<div role="status" class="text-center py-12">
<app-icon name="tasks-empty" class="mx-auto h-12 w-12 text-muted" />
<h3 class="mt-2 text-sm font-medium">No tasks</h3>
<p class="mt-1 text-sm text-muted">Get started by creating a new task.</p>
<app-button class="mt-4" (click)="createTask()">Create Task</app-button>
</div>
} @else {
<ul role="list">...</ul>
}
Responsive Design
Design for mobile first, then expand. This project uses TailwindCSS v4
(src/tailwind.css) with a @theme block — see
instructions/frontend.instructions.md
for the design token mapping.
<!-- Tailwind: mobile-first responsive -->
<div class="
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 these breakpoints: 320px, 768px, 1024px, 1440px.
Loading and Transitions
@Component({
selector: 'app-task-list-skeleton',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="space-y-3" aria-busy="true" aria-label="Loading tasks">
@for (i of skeletonCount(); track i) {
<div class="h-12 bg-muted animate-pulse rounded"></div>
}
</div>
`,
})
export class TaskListSkeletonComponent {
skeletonCount = signal([0, 1, 2]);
}
@Component({ })
export class TaskToggleComponent {
store = inject(AppStore);
api = inject(ApiService);
async toggleTask(id: string): Promise<void> {
const previous = this.store.tasks();
this.store.tasks.set(
previous.map(t => t.id === id ? { ...t, done: !t.done } : t)
);
try {
await this.api.toggleTask(id).toPromise();
} catch {
this.store.tasks.set(previous);
}
}
}
For performance rules specific to this project (computed view-models, @for
tracking, @defer), see
instructions/frontend.instructions.md.
See Also
Common Rationalizations
| Rationalization | Reality |
|---|
| "Accessibility is a nice-to-have" | It's a legal requirement in many jurisdictions and an engineering quality standard. |
| "We'll make it responsive later" | Retrofitting responsive design is 3x harder than building it from the start. |
| "The design isn't final, so I'll skip styling" | Use the design system defaults. Unstyled UI creates a broken first impression for reviewers. |
| "This is just a prototype" | Prototypes become production code. Build the foundation right. |
| "The AI aesthetic is fine for now" | It signals low quality. Use the project's actual design system from the start. |
Red Flags
- Components with more than 200 lines (split them)
- Inline styles or arbitrary pixel values
- Missing error states, loading states, or empty states
- No keyboard navigation testing
- Color as the sole indicator of state (red/green without text or icons)
- Generic "AI look" (purple gradients, oversized cards, stock layouts)
Verification
After building UI: