frontend-developer
Senior frontend engineering expertise for building high-quality web interfaces. Use this skill when writing, reviewing, or optimizing frontend code - HTML, CSS, JavaScript, TypeScript, components, layouts, forms, or interactive UI. Triggers on web performance optimization (Core Web Vitals, bundle size, lazy loading), accessibility audits (WCAG, ARIA, keyboard navigation, screen readers), code quality reviews, component architecture decisions, testing strategy, and modern CSS patterns. Covers the full frontend spectrum from semantic markup to production performance.
engineering frontendweb-performanceaccessibilitycssjavascriptuiWhat is frontend-developer?
Senior frontend engineering expertise for building high-quality web interfaces. Use this skill when writing, reviewing, or optimizing frontend code - HTML, CSS, JavaScript, TypeScript, components, layouts, forms, or interactive UI. Triggers on web performance optimization (Core Web Vitals, bundle size, lazy loading), accessibility audits (WCAG, ARIA, keyboard navigation, screen readers), code quality reviews, component architecture decisions, testing strategy, and modern CSS patterns. Covers the full frontend spectrum from semantic markup to production performance.
frontend-developer
frontend-developer is a production-ready AI agent skill for claude-code, gemini-cli, openai-codex, and 1 more. Senior frontend engineering expertise for building high-quality web interfaces. Use this skill when writing, reviewing, or optimizing frontend code - HTML, CSS, JavaScript, TypeScript, components, layouts, forms, or interactive UI.
Quick Facts
| Field | Value |
|---|---|
| Category | engineering |
| Version | 0.1.0 |
| Platforms | claude-code, gemini-cli, openai-codex, mcp |
| License | MIT |
How to Install
- Make sure you have Node.js installed on your machine.
- Run the following command in your terminal:
npx skills add AbsolutelySkilled/AbsolutelySkilled --skill frontend-developer- The frontend-developer skill is now available in your AI coding agent (Claude Code, Gemini CLI, OpenAI Codex, etc.).
Overview
A senior frontend engineering skill that encodes 20+ years of web development expertise into actionable guidance. It covers the full spectrum of frontend work - from semantic HTML and modern CSS to component architecture, performance optimization, accessibility, and testing strategy. Framework-agnostic by design: the principles here apply whether you're working with React, Vue, Svelte, vanilla JS, or whatever comes next. The web platform is the foundation.
Tags
frontend web-performance accessibility css javascript ui
Platforms
- claude-code
- gemini-cli
- openai-codex
- mcp
Related Skills
Pair frontend-developer with these complementary skills:
Frequently Asked Questions
What is frontend-developer?
Senior frontend engineering expertise for building high-quality web interfaces. Use this skill when writing, reviewing, or optimizing frontend code - HTML, CSS, JavaScript, TypeScript, components, layouts, forms, or interactive UI. Triggers on web performance optimization (Core Web Vitals, bundle size, lazy loading), accessibility audits (WCAG, ARIA, keyboard navigation, screen readers), code quality reviews, component architecture decisions, testing strategy, and modern CSS patterns. Covers the full frontend spectrum from semantic markup to production performance.
How do I install frontend-developer?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill frontend-developer in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support frontend-developer?
This skill works with claude-code, gemini-cli, openai-codex, mcp. Install it once and use it across any supported AI coding agent.
Maintainers
Generated from AbsolutelySkilled
SKILL.md
Frontend Developer
A senior frontend engineering skill that encodes 20+ years of web development expertise into actionable guidance. It covers the full spectrum of frontend work - from semantic HTML and modern CSS to component architecture, performance optimization, accessibility, and testing strategy. Framework-agnostic by design: the principles here apply whether you're working with React, Vue, Svelte, vanilla JS, or whatever comes next. The web platform is the foundation.
When to use this skill
Trigger this skill when the user:
- Asks to build, review, or optimize frontend UI code (HTML, CSS, JS/TS)
- Wants to improve web performance or Core Web Vitals scores
- Needs an accessibility audit or WCAG compliance guidance
- Is designing component architecture or deciding on state management
- Asks about testing strategy for frontend code
- Wants a code review with senior-level frontend feedback
- Is working with modern CSS (container queries, cascade layers, subgrid)
- Needs to optimize images, fonts, or bundle size
Do NOT trigger this skill for:
- Backend-only code with no frontend implications
- DevOps, CI/CD, or infrastructure work unrelated to frontend delivery
Key principles
The platform is your framework - Use native HTML elements, CSS features, and Web APIs before reaching for libraries. A
<dialog>beats a custom modal. CSS:has()beats a JS parent selector. The browser is remarkably capable - lean on it.Accessibility is not a feature, it's a baseline - Every element must be keyboard navigable. Every image needs alt text. Every form input needs a label. Every color combination must meet contrast ratios. Build accessible from the start - retrofitting is 10x harder.
Measure before you optimize - Never guess at performance. Use Lighthouse, the Performance API, and real user metrics (CrUX data). Optimize the actual bottleneck, not what you assume is slow. An unmeasured optimization is just code complexity.
Test behavior, not implementation - If a refactor breaks your tests but not your app, you have bad tests. Query by role, assert visible text, simulate real user actions. Tests should prove the product works, not that the code has a certain shape.
Simplicity scales, cleverness doesn't - Prefer 3 clear lines over 1 clever line. Prefer explicit over implicit. Prefer boring patterns over novel ones. The next developer to read your code (including future you) will thank you.
Core concepts
Frontend development sits at the intersection of three disciplines: engineering (code quality, architecture, testing), design (layout, interaction, visual fidelity), and user experience (performance, accessibility, resilience).
The mental model for good frontend work is layered:
Layer 1 - Markup (HTML): The semantic foundation. Choose elements for their meaning, not their appearance. Good HTML is accessible by default, works without CSS or JS, and communicates document structure to browsers, screen readers, and search engines.
Layer 2 - Presentation (CSS): Visual design expressed declaratively. Modern CSS handles responsive layouts, theming, animation, and complex selectors without JavaScript. Push as much visual logic into CSS as possible - it's faster, more maintainable, and progressive by nature.
Layer 3 - Behavior (JavaScript/TypeScript): Interactivity, state management, data fetching, and dynamic UI. This is the most expensive layer for users (parse + compile + execute), so minimize what you ship and maximize what the platform handles natively.
Layer 4 - Quality (Testing + Tooling): Automated verification that the other three layers work correctly. Tests, linting, type checking, and performance monitoring form the safety net that lets you ship with confidence.
Common tasks
1. Performance audit
Evaluate a page or component for performance issues. Start with measurable data, not hunches.
Checklist:
- Run Lighthouse and note LCP (< 2.5s), INP (< 200ms), CLS (< 0.1)
- Check the network waterfall for render-blocking resources
- Audit bundle size - look for unused code, large dependencies, missing code splitting
- Verify images use modern formats (AVIF/WebP), responsive
srcset, and lazy loading - Check font loading strategy (
font-display: swap, preloading, subsetting) - Look for layout shifts caused by unsized images, dynamic content, or web fonts
Load
references/web-performance.mdfor deep technical guidance on each metric.
2. Accessibility audit
Evaluate code for WCAG 2.2 AA compliance. Automated tools catch ~30% of issues - manual review is essential.
Checklist:
- Run axe-core or Lighthouse a11y audit for automated checks
- Verify semantic HTML - are
<nav>,<main>,<button>,<label>used correctly? - Tab through the entire UI - is every interactive element reachable and operable?
- Check color contrast ratios (4.5:1 for normal text, 3:1 for large text)
- Verify all images have meaningful alt text (or empty
alt=""for decorative images) - Test with a screen reader - do announcements make sense?
- Check that
aria-liveregions announce dynamic content updates - Verify forms have visible labels, error messages, and required field indicators
Load
references/accessibility.mdfor ARIA patterns and screen reader testing procedures.
3. Code review (frontend-focused)
Review frontend code with a senior engineer's eye. Prioritize in this order:
- Correctness - Does it work? Edge cases handled? Error states covered?
- Accessibility - Can everyone use it? Semantic HTML? Keyboard works?
- Performance - Will it be fast? Bundle impact? Render-blocking?
- Readability - Can the team maintain it? Clear naming? Reasonable complexity?
- Security - Any XSS vectors? innerHTML? User input rendered unsafely?
Load
references/code-quality.mdfor detailed review heuristics and refactoring signals.
4. Component architecture design
Design component structure for a feature or page. Apply these heuristics:
- Split when a component has more than one reason to change
- Don't split just because a component is long - cohesion matters more than size
- Prefer composition - pass children/slots instead of configuring via props
- State belongs where it's used - lift only when shared, push down when not
- Decision tree for state: Form input -> local state. Filter/sort -> URL params. Server data -> server state/cache. Theme/auth -> context/global.
Load
references/component-architecture.mdfor composition patterns and state management guidance.
5. Writing modern CSS
Use the platform's full power before reaching for JS-based solutions.
Decision guide:
- Layout -> CSS Grid (2D) or Flexbox (1D)
- Responsive -> Container queries for component-level, media queries for page-level
- Theming -> Custom properties +
light-dark()+color-mix() - Typography ->
clamp()for fluid sizing, no breakpoints needed - Animation -> CSS transitions/animations first, JS only for complex orchestration
- Specificity management ->
@layerfor ordering,:where()for zero-specificity resets
Load
references/modern-css.mdfor container queries, cascade layers, subgrid, and new selectors.
6. Testing strategy
Design a test suite that catches bugs without slowing down development.
The frontend testing trophy (most value in the middle):
- Static analysis (base): TypeScript + ESLint catch type errors and common bugs
- Unit tests (small): Pure functions, utilities, data transformations
- Integration tests (large - most value): Render a component, interact like a user, assert the result
- E2E tests (top): Critical user flows only - signup, checkout, core workflows
Rules:
- Query by
roleandname, not by test ID or CSS class - Assert what users see, not internal state
- Mock the network (use MSW), not the components
- If a test breaks on refactor but the app still works, delete the test
Load
references/testing-strategy.mdfor mocking strategy, visual regression, and a11y testing.
7. Bundle optimization
Reduce what ships to the client.
- Audit with
source-map-explorerorwebpack-bundle-analyzer - Replace large libraries with smaller alternatives (e.g.,
date-fns-> nativeIntl) - Use dynamic
import()for routes and heavy components - Check for duplicate dependencies in the bundle
- Ensure tree shaking works - use ESM, avoid side effects in modules
- Set performance budgets: < 200KB JS (compressed) for most pages
8. Progressive enhancement
Build resilient UIs that work across conditions.
- Core content and navigation must work without JavaScript
- Use
<form>with properaction- it works without JS by default - Add loading states, error states, and empty states for every async operation
- Respect
prefers-reduced-motion,prefers-color-scheme, andprefers-contrast - Handle offline gracefully where possible (service worker, optimistic UI)
- Never assume fast network, powerful device, or latest browser
Anti-patterns / common mistakes
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
| Div soup | Loses all semantic meaning, breaks a11y, hurts SEO | Use <nav>, <main>, <article>, <button>, <section> |
| ARIA abuse | Adding role="button" to a <div> when <button> exists |
Use native HTML elements first - they have built-in semantics, focus, and keyboard support |
| Performance theater | Lazy loading everything without measuring impact | Measure with Lighthouse/CrUX first, optimize the actual bottleneck |
| Testing implementation | Tests break on refactor, coupled to internal state | Test behavior - what the user sees and does, not how the code works |
| Premature abstraction | Shared component after 2 occurrences | Wait for the third use case, then extract with the real pattern visible |
| CSS avoidance | Runtime JS for styling that CSS handles natively | Modern CSS covers layout, theming, responsive design, and most animations |
| Ignoring the network | No loading/error states, assumes instant responses | Every async operation needs loading, error, and empty states |
| Bundle blindness | Never checking what ships to users | Audit bundle regularly, set performance budgets, check before adding deps |
| A11y as afterthought | Bolting on accessibility at the end | Build accessible from the start - semantic HTML, keyboard nav, ARIA where needed |
| Overengineering state | Global state for everything | Use local state by default, URL params for shareable state, server cache for API data |
| Emojis as UI icons | Render inconsistently across OS/browsers, unstyled, break a11y and theming | Use SVG icon libraries: Lucide React, React Icons, Heroicons, Phosphor, or Font Awesome |
Gotchas
CSS class-based queries in tests break on refactor - Using
getByClassNameor querying by CSS selectors couples tests to implementation. When you rename a class, tests fail even though the UI still works. Always query byrole,label, or visible text.Third-party bundle size is invisible until it's catastrophic - Adding a dependency like
moment.jsor a UI component library can triple your bundle silently. Runsource-map-explorerorwebpack-bundle-analyzerbefore merging any PR that adds a newnpm install.ARIA roles on wrong elements break screen readers worse than no ARIA - Adding
role="button"to a<div>makes screen readers announce "button" but keyboard users still can't Tab to it or activate it with Enter/Space. Use<button>or add bothtabindex="0"and akeydownhandler. Incomplete ARIA is worse than none.CSS container queries require a containment context on the parent - A container query will silently never fire if the parent element doesn't have
container-typeset. Addingcontainer-type: inline-sizeto the wrong ancestor (e.g., body) changes layout behavior unexpectedly. Always set the containment on the direct parent of the component.Lazy loading below-the-fold images is fine; lazy loading LCP images kills performance - Adding
loading="lazy"to every image is cargo-cult optimization. The Largest Contentful Paint image must load eagerly (or usefetchpriority="high"). Lazy-loading the LCP image can drop your LCP score by seconds.
References
For detailed guidance on specific topics, load the relevant reference file:
references/web-performance.md- Core Web Vitals, rendering, bundle optimization, caching, images, fontsreferences/accessibility.md- WCAG 2.2, semantic HTML, ARIA patterns, keyboard navigation, screen reader testingreferences/modern-css.md- Container queries, cascade layers, subgrid, :has()/:is(), view transitionsreferences/component-architecture.md- Composition patterns, state management, render optimization, design systemsreferences/testing-strategy.md- Testing trophy, integration tests, visual regression, a11y testing, mockingreferences/code-quality.md- Code review heuristics, refactoring signals, TypeScript patterns, security, linting
Only load a reference file when the current task requires that depth - they are detailed and will consume context.
References
accessibility.md
Web Accessibility Reference
WCAG 2.2 Conformance Levels
WCAG (Web Content Accessibility Guidelines) is organized into three levels:
| Level | Meaning | Target |
|---|---|---|
| A | Minimum - removing major barriers | Legal floor in many jurisdictions |
| AA | Standard - removes most barriers | The industry standard target; required by most legal standards (ADA, EN 301 549) |
| AAA | Enhanced - specialized needs | Aspire to where feasible; not required for full sites |
AA is the practical target. It covers the majority of users with disabilities without being prohibitively restrictive. New WCAG 2.2 criteria added to AA: focus appearance (2.4.11), dragging movements alternative (2.5.7), target size minimum 24x24px (2.5.8).
Semantic HTML
Use the right element - native semantics are free accessibility, no ARIA needed.
| Need | Use | Not |
|---|---|---|
| Main navigation | <nav> |
<div class="nav"> |
| Primary content | <main> |
<div id="main"> |
| Standalone content | <article> |
<div class="article"> |
| Grouped related content | <section> (with heading) |
<div> |
| Clickable action | <button> |
<div onclick> |
| Page-level heading | <h1> (one per page) |
<p class="title"> |
| Supplementary content | <aside> |
<div class="sidebar"> |
| Site header/footer | <header>, <footer> |
<div id="header"> |
| Data table | <table> with <th scope> |
CSS grid/flex layout |
| Form control | <input>, <select>, <textarea> |
<div contenteditable> |
<!-- BAD: div soup -->
<div class="btn" onclick="submit()">Submit</div>
<!-- GOOD: native button - keyboard accessible, announced as button, activatable with Space/Enter -->
<button type="submit">Submit</button>Heading hierarchy matters: screen readers use headings for page navigation. Don't skip levels (h1 -> h3). One <h1> per page.
ARIA
ARIA (Accessible Rich Internet Applications) adds semantics to non-semantic HTML. It only affects the accessibility tree - it does not change visual rendering or behavior.
The 5 Rules of ARIA
- Don't use ARIA if a native HTML element exists - prefer
<button>overrole="button" - Don't change native semantics unless you must - don't add
role="heading"to a<button> - All interactive ARIA controls must be keyboard operable - if you add a role, add keyboard support
- Don't hide focusable elements -
aria-hidden="true"on a focusable element traps keyboard users - All interactive elements must have an accessible name - every button, input, link needs a label
Common ARIA Patterns
Dialog (Modal)
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title" aria-describedby="dialog-desc">
<h2 id="dialog-title">Confirm Delete</h2>
<p id="dialog-desc">This action cannot be undone.</p>
<button autofocus>Cancel</button>
<button>Delete</button>
</div>aria-modal="true"tells screen readers to ignore content behind the modal- Move focus to first interactive element (or dialog itself) on open
- Return focus to trigger on close
Tabs
<div role="tablist" aria-label="Settings">
<button role="tab" aria-selected="true" aria-controls="panel-1" id="tab-1">General</button>
<button role="tab" aria-selected="false" aria-controls="panel-2" id="tab-2" tabindex="-1">Privacy</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">...</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>...</div>Combobox (autocomplete)
<input type="text" role="combobox" aria-expanded="true" aria-haspopup="listbox"
aria-autocomplete="list" aria-controls="suggestions" aria-activedescendant="opt-2">
<ul id="suggestions" role="listbox">
<li role="option" id="opt-1">Apple</li>
<li role="option" id="opt-2" aria-selected="true">Apricot</li>
</ul>Live Regions
<!-- Polite: waits for user to finish current action before announcing -->
<div aria-live="polite" aria-atomic="true">
3 results found
</div>
<!-- Assertive: interrupts immediately - use only for errors/urgent info -->
<div aria-live="assertive" role="alert">
Error: Form submission failed
</div>When NOT to use ARIA
- Don't add
role="button"to a<div>- use<button> - Don't add
aria-labelto<div>or<span>with no role - it does nothing - Don't use
aria-hidden="true"on the<body>or on focused elements - Don't add
role="presentation"to elements with children that have meaning - Redundant ARIA:
<button aria-role="button">- the<button>already has that role
Keyboard Navigation
All interactive functionality must be keyboard accessible. Mouse-only interactions (hover-only menus, drag-only sorting) fail WCAG 2.1 AA.
Tab order
- Follows DOM order by default - keep DOM order logical
tabindex="0": makes non-interactive element focusable, joins natural tab ordertabindex="-1": programmatically focusable (via.focus()) but removed from tab ordertabindex="1+": avoid - creates unpredictable tab order, hard to maintain
Skip links
Provide a skip navigation link as the first focusable element on every page:
<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- ... navigation ... -->
<main id="main-content" tabindex="-1">...</main>.skip-link {
position: absolute;
top: -100%;
left: 0;
}
.skip-link:focus {
top: 0; /* visible only on focus */
}Focus Management
Move focus programmatically when UI changes significantly:
// After opening modal
modalEl.querySelector('[autofocus], button, [href], input').focus();
// After closing modal - return to trigger
triggerButton.focus();
// After route change in SPA
document.querySelector('h1').focus(); // h1 should have tabindex="-1"Focus Trapping (modals)
When a modal is open, Tab must cycle within the modal only:
function trapFocus(element) {
const focusable = element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
element.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
});
}Roving tabindex (composite widgets)
For widgets like toolbars, tab lists, radio groups - only one item in tab order at a time; arrow keys navigate within:
// Tab moves focus into/out of group; arrow keys move within
items.forEach((item, i) => {
item.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') {
items[i].setAttribute('tabindex', '-1');
const next = items[(i + 1) % items.length];
next.setAttribute('tabindex', '0');
next.focus();
}
});
});Screen Reader Testing
Manual testing with real screen readers is irreplaceable. Automated tools catch ~30% of issues.
VoiceOver (macOS/iOS)
- Enable: Cmd + F5 (macOS) or triple-click home (iOS)
- Navigate: VO key (Caps Lock or Ctrl+Option) + arrows
- Read page: VO + A
- Open rotor (landmark/heading nav): VO + U
- Test checklist: headings make sense out of context, buttons/links are descriptive, forms announce errors, modals trap focus, dynamic content is announced
NVDA (Windows, free)
- Most-used screen reader on Windows
- Navigate by heading: H; by landmark: D; by form element: F
- Browse mode (reading) vs. Forms mode (interacting) - be aware of mode switching
- Test in Firefox + NVDA (strong combination)
What to verify
- Every image has meaningful alt text (or
alt=""for decorative) - Form inputs are announced with their label, type, and required state
- Error messages are associated with their inputs and announced
- Button and link text is descriptive standalone ("Submit order" not "Click here")
- Modal focus trap works; focus returns to trigger on close
- Dynamic updates (toasts, status messages) are announced without stealing focus
- Page title changes on route navigation in SPAs
Color and Contrast
WCAG Contrast Ratios (AA level)
| Element | Minimum ratio |
|---|---|
| Normal text (< 18pt / < 14pt bold) | 4.5:1 |
| Large text (>= 18pt / >= 14pt bold) | 3:1 |
| UI components (input borders, focus rings) | 3:1 against adjacent color |
| Graphical objects (icons, chart lines) | 3:1 |
| Decorative elements | No requirement |
/* Focus indicator must meet 3:1 against adjacent colors */
:focus-visible {
outline: 3px solid #005fcc; /* check contrast against both background and element color */
outline-offset: 2px;
}Never convey information by color alone
<!-- BAD - colorblind users can't distinguish -->
<span style="color: red">Error</span>
<!-- GOOD - icon + color + text -->
<span class="error">
<svg aria-hidden="true"><!-- error icon --></svg>
Error: Invalid email address
</span>Tools
- Browser DevTools: Chrome accessibility panel shows contrast ratio
- axe DevTools browser extension: flags contrast violations
- Colour Contrast Analyser (desktop app): eyedrop any pixels
- APCA (Advanced Perceptual Contrast Algorithm): more nuanced, used in WCAG 3.0 (future)
Forms
Labels
Every input needs a visible, associated label:
<!-- Preferred: explicit label with for/id -->
<label for="email">Email address</label>
<input type="email" id="email" name="email">
<!-- Wrapping label also works -->
<label>
Email address
<input type="email" name="email">
</label>
<!-- aria-label for icon-only inputs -->
<input type="search" aria-label="Search products">
<!-- aria-labelledby for labels defined elsewhere -->
<h2 id="billing">Billing address</h2>
<input type="text" aria-labelledby="billing" aria-label="Street address">Do NOT use placeholder as the only label - it disappears on focus, fails contrast requirements.
Required fields and validation
<!-- Use required attribute (announced by screen readers) -->
<input type="email" id="email" required aria-describedby="email-hint email-error">
<span id="email-hint">We'll never share your email</span>
<span id="email-error" role="alert" hidden>Please enter a valid email</span>// On validation failure: show error, move focus, announce via role=alert
input.setAttribute('aria-invalid', 'true');
errorEl.removeAttribute('hidden'); // role=alert triggers announcement
input.focus();Grouping
<!-- Fieldset + legend for related inputs (radio groups, checkboxes, address) -->
<fieldset>
<legend>Shipping method</legend>
<label><input type="radio" name="shipping" value="standard"> Standard (5-7 days)</label>
<label><input type="radio" name="shipping" value="express"> Express (2 days)</label>
</fieldset>Dynamic Content
Live regions
<!-- Status messages (non-urgent) -->
<div role="status" aria-live="polite">Changes saved</div>
<!-- Alerts (urgent, interrupts) -->
<div role="alert" aria-live="assertive">Session expiring in 1 minute</div>
<!-- Log (chat, feed) -->
<div role="log" aria-live="polite" aria-relevant="additions">...</div>Inject content into pre-existing live region elements - don't create new ones dynamically, as some screen readers only register them at page load.
SPA route changes
Single-page apps don't trigger native browser page announcements. Implement:
- Update
document.titleon every route change - Move focus to
<h1>(withtabindex="-1") or a skip-link after navigation - Optionally use a live region to announce "Navigated to: [page title]"
Loading states
<!-- Indicate busy state to AT -->
<button aria-disabled="true" aria-busy="true">
<span aria-hidden="true"><!-- spinner --></span>
Saving...
</button>
<!-- Container loading state -->
<section aria-busy="true" aria-label="Loading search results">
<!-- skeleton content -->
</section>Media
Alt text
- Informative images: describe purpose and content concisely
- Decorative images:
alt=""(empty string) - screen readers skip entirely; never omit the attribute - Functional images (links/buttons with only an image): describe the action, not the image
- Complex images (charts, infographics): short alt + long description via
aria-describedbyor adjacent text - Never start with "Image of..." or "Picture of..." - screen readers already announce it's an image
<!-- Decorative -->
<img src="divider.svg" alt="">
<!-- Functional -->
<a href="/home"><img src="logo.svg" alt="Acme Corp - Home"></a>
<!-- Complex -->
<img src="chart.png" alt="Q4 revenue chart" aria-describedby="chart-desc">
<p id="chart-desc">Revenue grew from $2M in October to $3.5M in December...</p>Video and audio
<video>needs closed captions (<track kind="captions">) for all speech/meaningful audio- Audio-only content needs a transcript
- Video-only content needs an audio description or text alternative
- Auto-playing media with sound violates WCAG 1.4.2 - provide pause/stop control
Reduced motion
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}Parallax, auto-playing carousels, and large motion animations can trigger vestibular disorders. Provide a static alternative.
Automated Testing
axe-core
Industry-standard accessibility rules engine. Powers Deque's axe DevTools, Lighthouse, and many CI tools.
import axe from 'axe-core';
// In tests (works with any test runner)
const results = await axe.run(document.body);
expect(results.violations).toHaveLength(0);
// Analyze a specific element
const results = await axe.run(document.querySelector('#modal'));Lighthouse accessibility audit
Built into Chrome DevTools. Gives a 0-100 score. Run via:
- DevTools > Lighthouse tab > check Accessibility
- CLI:
npx lighthouse https://example.com --only-categories=accessibility
Limitations of automated tools
Automated tools catch approximately 30% of WCAG failures. They reliably catch:
- Missing alt text, labels, ARIA attributes
- Contrast failures
- Missing form labels
- Structural issues (duplicate IDs, invalid ARIA roles)
They cannot catch:
- Meaningful vs. decorative image judgment
- Whether alt text is actually descriptive
- Focus management correctness
- Screen reader announcement quality
- Cognitive load and plain language issues
- Whether keyboard navigation order is logical
Always supplement automated testing with:
- Keyboard-only navigation walkthrough
- Screen reader testing (VoiceOver, NVDA)
- Testing with real users with disabilities
code-quality.md
Code Quality - Senior Frontend Engineering Reference
Code Review Heuristics
A senior engineer reviews code across multiple dimensions simultaneously. Not just "does it work?"
Correctness:
- Does it handle edge cases? (empty arrays, null/undefined, 0, empty string, network failure)
- Are async operations handled correctly? (loading state, error state, race conditions)
- Is state mutation avoided where it shouldn't happen?
- Are event listeners and subscriptions cleaned up?
Readability:
- Can you understand what this code does in 30 seconds without the author explaining?
- Are names meaningful at the right level of abstraction?
- Is the code structured to minimize surprise - does it do what it looks like it does?
- Are there comments explaining why, not just what?
Performance:
- Are there unnecessary re-renders or recalculations on every render?
- Are large lists virtualized?
- Are heavy computations deferred or moved off the main thread?
- Does any synchronous work block the UI?
Accessibility:
- Do interactive elements have accessible names?
- Is the focus order logical?
- Do dynamic changes announce to screen readers (live regions)?
- Can everything be done with keyboard alone?
Security:
- Is user-supplied content rendered as HTML anywhere? (
innerHTML,dangerouslySetInnerHTML) - Are authentication checks done server-side (not just hidden in the UI)?
- Are sensitive values exposed in source, logs, or URL params?
Refactoring Signals
Refactor when code has these smells - not before, not never.
Rule of Three - the first time you write something, write it. The second time, note the duplication. The third time, refactor to a shared abstraction.
Shotgun surgery - a single conceptual change requires edits in many unrelated files. This signals that a concern is not properly encapsulated. Solution: co-locate related logic.
Feature envy - a function or component is more interested in the data and methods of another module than its own. Solution: move the behavior to where the data lives.
Primitive obsession - using raw strings, numbers, or booleans to represent domain concepts with their own behavior. Solution: introduce a type or class that encapsulates the concept.
// Bad - primitive obsession
const status = 'PENDING' // magic string everywhere
if (status === 'PENDING' || status === 'PROCESSING') { ... }
// Good - encapsulated concept
const OrderStatus = {
PENDING: 'PENDING',
PROCESSING: 'PROCESSING',
isPending: (s) => s === 'PENDING',
isInProgress: (s) => s === 'PENDING' || s === 'PROCESSING',
}Long parameter list - a function with 4+ parameters is usually doing too much or passing too much context. Group related parameters into an options object. Consider whether the function should be split.
Boolean parameter flags - a boolean argument that changes fundamental behavior is a sign of two functions merged into one:
// Bad
function fetchUser(id, includeDeleted) { ... }
// Good
function fetchUser(id) { ... }
function fetchDeletedUser(id) { ... }TypeScript Patterns
Discriminated unions over type assertions - model state machines explicitly:
// Bad - overlapping optional fields, runtime confusion
type RequestState = {
loading?: boolean
data?: User
error?: Error
}
// Good - each state is unambiguous
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: Error }
// Exhaustive handling with discriminated unions
switch (state.status) {
case 'success': return state.data.name // TypeScript knows data exists here
case 'error': return state.error.message // TypeScript knows error exists here
}Const assertions - preserve literal types instead of widening:
const DIRECTIONS = ['north', 'south', 'east', 'west'] as const
type Direction = typeof DIRECTIONS[number] // 'north' | 'south' | 'east' | 'west'Template literal types - type-safe string composition:
type EventName = `on${Capitalize<string>}`
type CSSProperty = `--${string}` // CSS custom property
type ApiRoute = `/api/${string}`Branded types - prevent accidentally mixing semantically different primitives:
type UserId = string & { readonly _brand: 'UserId' }
type ProductId = string & { readonly _brand: 'ProductId' }
function getUser(id: UserId) { ... }
const productId = '123' as ProductId
getUser(productId) // TypeScript error - wrong brandsatisfies operator - validate a value matches a type without widening it:
const palette = {
red: [255, 0, 0],
blue: '#0000ff',
} satisfies Record<string, string | number[]>
// palette.red is still number[], not string | number[]
// satisfies validates without losing specificityAvoid type assertions (as) - they silence the type checker. If you need as, ask why TypeScript disagrees. Often it's a sign the types are wrong, not the code.
Clean Code Principles for Frontend
Naming conventions:
- Event handlers passed as props:
onX(onClick,onSubmit,onUserSelect) - Event handler implementations:
handleX(handleClick,handleSubmit) - Boolean variables:
is,has,should,can(isLoading,hasError,shouldRedirect) - Collections: plural nouns (
users,selectedItems,pendingRequests) - Async functions: describe the action (
fetchUser,loadConfig,savePreferences) - Avoid abbreviations except universally understood ones (
id,url,api,db,iin loops)
File organization:
components/
Button/
Button.tsx - component
Button.test.tsx - tests co-located
Button.stories.tsx - Storybook stories (if applicable)
index.ts - re-export (controls public API)- Co-locate tests and stories with the component they test
- Index files as barrel exports - but avoid deep barrel chains (they hurt tree-shaking and dev server performance)
- Group by feature, not by type:
features/checkout/notcomponents/checkout/ + hooks/checkout/ + utils/checkout/
Function length - if a function doesn't fit on one screen, look for natural extraction points. Named sub-functions communicate intent better than comments.
Early returns reduce nesting and make the happy path clear:
// Bad - arrow-head anti-pattern
function processOrder(order) {
if (order) {
if (order.items.length > 0) {
if (order.status === 'pending') {
// actual logic buried here
}
}
}
}
// Good - guard clauses
function processOrder(order) {
if (!order) return
if (order.items.length === 0) return
if (order.status !== 'pending') return
// actual logic at the top level
}Linting Philosophy
Lint for bugs, not style. Style is solved by formatters (Prettier). Use ESLint to catch:
- Potential runtime errors (
no-undef,no-unused-vars,eqeqeq) - Accessibility violations (
jsx-a11yrules) - Security issues (
no-eval,no-implied-eval) - Deprecated patterns and API misuse
- Rule-of-hooks violations (for React codebases)
Start from recommended configs and add rules deliberately:
// A reasonable baseline
{
extends: [
'eslint:recommended',
'plugin:jsx-a11y/recommended',
'@typescript-eslint/recommended',
]
}Custom rules sparingly - every custom rule has a maintenance cost. Only add a custom rule if it catches a real recurring bug pattern in your codebase.
Never disable lint warnings wholesale. // eslint-disable-next-line is sometimes necessary - but always add a comment explaining why. PRs with unexplained disables should be questioned in review.
Security in the Frontend
XSS prevention:
- Never set
innerHTMLwith user-supplied content - Framework templating systems escape by default - don't bypass them
dangerouslySetInnerHTML(React) orv-html(Vue) require sanitization first - use DOMPurify
// Bad - XSS vector
element.innerHTML = userProvidedContent
// Good - use safe DOM APIs
element.textContent = userProvidedContent
// If HTML is required, sanitize first
element.innerHTML = DOMPurify.sanitize(userProvidedContent)Content Security Policy (CSP) - HTTP header that restricts what sources scripts, styles, and media can load from. Prevents injected scripts from executing. Implement at the server level, not in JavaScript.
CSRF tokens - for any state-mutating request. Most modern SPA frameworks with same-site cookies are protected by default, but verify your setup.
Secure cookies: always set HttpOnly (prevents JS access), Secure (HTTPS only), SameSite=Strict or Lax for auth cookies.
Never trust the frontend for authorization. Every permission check must be enforced on the server. Frontend checks are UX, not security.
Avoid exposing sensitive data in:
- URL query parameters (appear in server logs, browser history, referer headers)
console.login production builds (readable in DevTools)- Client-accessible local storage for auth tokens (prefer
HttpOnlycookies)
Error Handling Patterns
Error boundaries - wrap top-level sections in error boundaries. Components fail; the page shouldn't.
Global error handlers:
window.addEventListener('unhandledrejection', (event) => {
reportError(event.reason)
})
window.addEventListener('error', (event) => {
reportError(event.error)
})User-facing error messages - separate what you log from what you show:
try {
await submitOrder(cart)
} catch (error) {
// Log technical details for debugging
logger.error('Order submission failed', { error, cart })
// Show human-readable message to user
setErrorMessage("We couldn't place your order. Please try again or contact support.")
}Never expose stack traces, internal IDs, or technical error messages to users.
Error reporting - integrate with an error monitoring service (Sentry, Datadog). Capture: error message, stack trace, user context (anonymized), reproduction steps (breadcrumbs), release version.
Performance Code Patterns
Avoiding layout thrashing - interleaving DOM reads and writes forces multiple reflows:
// Bad - read, write, read, write = multiple reflows
elements.forEach(el => {
const height = el.offsetHeight // read (forces reflow)
el.style.height = height + 10 + 'px' // write
})
// Good - batch reads then writes
const heights = elements.map(el => el.offsetHeight) // all reads
elements.forEach((el, i) => {
el.style.height = heights[i] + 10 + 'px' // all writes
})Debounce vs throttle:
debounce- delay execution until N ms after the last call. Use for search-as-you-type, window resize handlers.throttle- execute at most once per N ms. Use for scroll handlers, mousemove, real-time position tracking.
Virtualization - for lists over ~100 items, render only the visible items. Libraries: @tanstack/virtual, react-window. Drastically reduces DOM node count and re-render cost.
Web Workers - offload CPU-intensive work (large data parsing, image processing, cryptography) to a background thread. Keeps the main thread free for user interaction.
// Main thread stays responsive
const worker = new Worker('./heavy-computation.js')
worker.postMessage({ data: largeDataset })
worker.onmessage = (e) => setResult(e.data)requestAnimationFrame for any animation or visual update tied to rendering - ensures updates happen at the browser's next paint cycle, preventing jank.
Dependency Hygiene
Before adding a dependency:
- Check bundle impact with bundlephobia.com
- Verify it's actively maintained (last commit, open issues, npm downloads)
- Check for a simpler native alternative (is a library really needed for this?)
- Prefer dependencies with ESM exports for better tree-shaking
Micro-package problem - packages that do one trivial thing (is-even, left-pad) add supply chain risk with no value. Write the 3-line utility yourself.
Auditing:
- Run
npm auditin CI, fail on high/critical vulnerabilities - Review the lockfile in PRs - dependency additions should be intentional
Lockfile hygiene:
- Always commit lockfiles (
package-lock.json,yarn.lock,pnpm-lock.yaml) - Unexplained lockfile churn in a PR (hundreds of lines changed) is a red flag - investigate why
Git Workflow for Frontend
Meaningful commits - each commit should represent one logical change. The message should complete the sentence "This commit will...":
feat: add keyboard navigation to Dropdown component
fix: prevent cart total from showing NaN when quantity is empty
refactor: extract useFormValidation hook from CheckoutFormFeature flags for WIP - don't block a long-running feature on a branch for weeks. Merge incrementally behind a flag. Reduces merge conflicts and keeps main releasable.
Branch naming:
feat/user-profile-redesign
fix/checkout-npe-on-empty-cart
refactor/migrate-to-tanstack-query
chore/upgrade-typescript-5PR size guidelines:
- Aim for under 400 lines changed per PR (excluding generated files, lockfiles)
- Large PRs are reviewed worse - reviewers lose context and miss bugs
- If a PR is large, add a description that walks through the structure
- Split refactoring PRs from feature PRs - mixing them hides intent
What goes in a PR description:
- What changed and why (not just "updated X")
- How to test it manually
- Screenshots or recordings for visual changes
- Decisions made and alternatives rejected
- Risks or areas that need extra review attention
component-architecture.md
Component Architecture - Senior Frontend Engineering Reference
Composition Over Inheritance
Inheritance creates tight coupling and deep hierarchies that are hard to reason about. Composition builds behavior by assembling small, focused pieces.
Bad - inheritance-based:
// BaseButton -> IconButton -> LoadingIconButton -> DisabledLoadingIconButton
// Every new variation requires a new subclass
class BaseButton { render() { ... } }
class IconButton extends BaseButton { render() { /* duplicates + extends */ } }Good - composition-based:
// Assemble behavior at call site
<Button loading={true} icon={<Spinner />} disabled={false}>
Submit
</Button>
// Internally, Button composes primitives
function Button({ loading, icon, children, ...rest }) {
return (
<button {...rest}>
{loading ? <Spinner /> : icon}
{children}
</button>
)
}Slot patterns - expose named areas for consumers to inject content without needing props for every variation:
// Consumer controls what goes in each slot
<Card
header={<h2>Title</h2>}
footer={<Button>Save</Button>}
>
<p>Body content</p>
</Card>Render delegation - let the parent decide how to render a child item:
// List doesn't know how to render items - consumer provides renderItem
<List
items={users}
renderItem={(user) => <UserRow key={user.id} user={user} />}
/>Component Boundaries - When to Split
Apply the single responsibility principle: a component should have one reason to change.
The "reason to change" test:
- If you can describe a component's purpose with "and", split it.
- "This component fetches data AND renders a table AND handles sorting" = three components.
Signals to split a component:
- File is over ~150-200 lines and growing
- Component has multiple independent units of state
- Part of the component re-renders when unrelated state changes
- Reusing part of it in another context requires copy-pasting
Signals NOT to split:
- The pieces are never used independently
- Splitting would require prop-drilling through one layer just to split for the sake of it
- The component is genuinely simple - don't split for splitting's sake
Layer model:
Page / Route component - orchestrates layout, data fetching
Feature component - specific business domain (UserProfile, CheckoutForm)
UI component - reusable, stateless or controlled (Button, Input, Modal)
Primitive / Token - lowest level building block (Icon, Text, Stack)State Management Decision Tree
Ask these questions in order:
1. Does only this component need it?
YES -> local component state
2. Does it belong in the URL (shareable, bookmarkable, survive refresh)?
YES -> URL/query string state (search params, router state)
3. Is it data from the server (cached, async, invalidated by mutations)?
YES -> server state (React Query, SWR, Apollo cache)
4. Is it truly global UI state shared across distant components (auth, theme, cart)?
YES -> global client state (context, Zustand, Redux)Local state - default choice. Co-locate as close to usage as possible.
URL state - underused. Pagination, filters, selected tab, search query. Makes pages shareable and survives refresh.
Server state - the largest category. Don't duplicate it into global state. Server state libraries handle caching, deduplication, background refresh, and stale-while-revalidate automatically.
Global client state - the last resort. If you find yourself putting server data into global state, reconsider. Common legitimate uses: auth session, theme preference, notification queue, open modals.
Render Optimization
Optimize only when you have evidence of a problem. Premature optimization adds complexity with no benefit.
Memoization patterns:
// Memoize expensive derived values - not all values
const sortedItems = useMemo(
() => items.slice().sort(compareFn),
[items, compareFn] // only recompute when inputs change
)
// Memoize callbacks passed to children that rely on referential equality
const handleSubmit = useCallback(
() => submitForm(formData),
[formData]
)
// Memoize components that receive stable props but re-render due to parent
const MemoizedRow = memo(Row) // only useful if Row's props are stableAvoiding unnecessary re-renders:
- Keep state as local as possible - lifting state high causes wide re-renders
- Split context by concern:
ThemeContext,AuthContext,CartContext- not one giant app context - Avoid creating new object/array literals in render - they break referential equality on every render:
// Bad - new object on every render causes child to re-render
<Child config={{ timeout: 3000 }} />
// Good - stable reference
const CONFIG = { timeout: 3000 }
<Child config={CONFIG} />Stable references rule: If a value is passed as a prop or dependency, and it changes identity on every render without changing value, you have a bug waiting to happen - not just a performance issue.
Props Design
Minimal props principle: Pass only what the component needs. If a component accepts a whole user object but only uses user.name, consider passing just name.
Avoid boolean prop explosion:
// Bad - combinatorial explosion of boolean props
<Button primary large outline disabled loading />
// Good - use a variant prop for mutually exclusive states
<Button variant="primary" size="large" appearance="outline" disabled loading />Compound components pattern - for components with tightly related sub-parts:
// Instead of a monolithic component with many props:
<Select
options={options}
renderOption={...}
renderTrigger={...}
groupBy={...}
/>
// Use compound components that share implicit context:
<Select value={value} onChange={setValue}>
<Select.Trigger>{selectedLabel}</Select.Trigger>
<Select.Options>
{options.map(opt => (
<Select.Option key={opt.value} value={opt.value}>
{opt.label}
</Select.Option>
))}
</Select.Options>
</Select>Compound components give consumers full control over structure while components share state through implicit context.
Design System Thinking - Layered Architecture
Tokens - raw design decisions (colors, spacing, typography, radii)
e.g. --color-blue-500, --space-4, --radius-md
Primitives - single-purpose, token-consuming components
Box, Text, Stack, Inline, Icon
No opinions about business logic
Composables - assembled from primitives, cover common patterns
Card, Badge, Button, Input, Modal
Feature components - business-domain components using composables
UserAvatar, ProductCard, CheckoutSummary
Page components - route-level, orchestrate data + layoutRules:
- Higher layers can use lower layers. Lower layers must never know about higher layers.
- Tokens live outside component code - in CSS custom properties or a theme object.
- Primitives accept all valid HTML attributes (spread
...restto the underlying element). - Document when/why to use a component, not just how.
Controlled vs Uncontrolled Components
Uncontrolled - the component manages its own state. Consumer reads it only when needed (e.g., on submit via ref).
- Use when: the value is ephemeral, form submit is the only time parent cares, reducing re-renders matters.
Controlled - the consumer owns the state and passes it via props. Component is a pure rendering function.
- Use when: the value must be synchronized with other UI, parent needs to react to every change, external validation is needed.
Hybrid approach - accept an optional value prop. If provided, be controlled. If absent, manage internally:
function Input({ value: controlledValue, defaultValue, onChange }) {
const isControlled = controlledValue !== undefined
const [internalValue, setInternalValue] = useState(defaultValue ?? '')
const value = isControlled ? controlledValue : internalValue
function handleChange(e) {
if (!isControlled) setInternalValue(e.target.value)
onChange?.(e.target.value)
}
return <input value={value} onChange={handleChange} />
}Rule: never switch between controlled and uncontrolled during a component's lifetime - it causes bugs. Log a warning if the consumer does this.
Error Boundaries
Error boundaries catch rendering errors in the component subtree and display fallback UI instead of crashing the whole page.
Placement strategy:
- One at the app root - catches anything that slips through
- One per major page section (sidebar, main content, header) - lets rest of page survive
- One per independently loaded widget or third-party integration
Fallback UI patterns:
// Minimal fallback - show nothing, log error
<ErrorBoundary fallback={null} onError={reportToMonitoring}>
<Sidebar />
</ErrorBoundary>
// Meaningful fallback - tell user what happened
<ErrorBoundary fallback={<ErrorMessage retry={reset} />}>
<ProductList />
</ErrorBoundary>What error boundaries do NOT catch: async errors, event handlers, server-side errors, errors inside the boundary itself. Handle async errors separately with .catch() or try/catch in async functions and feed them to state.
Data Fetching Patterns
Colocation - fetch data as close to where it's needed as possible. Don't fetch everything at the top and drill it down.
Waterfall prevention - parallel fetches are faster than sequential:
// Bad - waterfall: user fetch completes, THEN posts fetch starts
const user = await fetchUser(id)
const posts = await fetchPostsByUser(user.id)
// Good - parallel when IDs are known upfront
const [user, posts] = await Promise.all([
fetchUser(id),
fetchPostsByUser(id) // if you can derive the ID early
])Optimistic updates - update UI immediately, revert on failure:
// Immediately update local state
setTodos(prev => prev.map(t => t.id === id ? { ...t, done: true } : t))
// Send to server in background
try {
await api.updateTodo(id, { done: true })
} catch {
// Revert on failure
setTodos(originalTodos)
showError('Failed to save. Your change was reverted.')
}Cache invalidation - after a mutation, declare which cached queries are stale. Don't manually merge - just refetch or invalidate.
Side Effect Management
Cleanup is mandatory for: timers, subscriptions, event listeners, fetch/abort controllers, websockets.
useEffect(() => {
const controller = new AbortController()
fetchData({ signal: controller.signal })
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') setError(err)
})
return () => controller.abort() // cleanup on unmount or dependency change
}, [dependency])Race conditions - when a fast response arrives after a slow one:
useEffect(() => {
let cancelled = false
fetchSearch(query).then(results => {
if (!cancelled) setResults(results) // ignore stale responses
})
return () => { cancelled = true }
}, [query])Stale closures - a closure captures the variable value at the time it was created. If that variable changes later, the closure sees the old value. Solutions: include the variable in the dependency array, use a ref to hold the latest value, or use the functional update form of setState:
// Safe - uses functional update, doesn't close over count
setCount(prev => prev + 1)
// Risky - closes over count, may be stale in async context
setCount(count + 1) modern-css.md
Modern CSS Reference
Container Queries
Style elements based on their container's size, not the viewport. Solves the component reusability problem with media queries.
/* 1. Define a containment context */
.card-wrapper {
container-type: inline-size; /* tracks width changes */
container-name: card; /* optional name for targeting */
}
/* 2. Query the container */
@container card (min-width: 400px) {
.card { flex-direction: row; }
}
/* Without a name, queries the nearest ancestor with containment */
@container (min-width: 600px) {
.sidebar-widget { font-size: 1.2rem; }
}container-type values:
inline-size: tracks width (most common)size: tracks width and height (use only if needed - blocks intrinsic sizing)normal: enables style queries only, no size queries
Container vs media queries: Use media queries for page-level layout; use container queries for reusable components whose context varies. A card component should not need to know if it's in a sidebar or a main content area.
Style queries (newer)
Query custom property values on a container:
.card-wrapper { --variant: featured; }
@container style(--variant: featured) {
.card { border: 2px solid gold; }
}Cascade Layers
@layer provides explicit control over the cascade, making specificity battles a thing of the past.
/* Declare layer order first - earlier = lower priority */
@layer reset, base, components, utilities;
@layer reset {
* { margin: 0; padding: 0; box-sizing: border-box; }
}
@layer base {
a { color: var(--color-link); }
}
@layer components {
.btn { padding: 0.5rem 1rem; border-radius: 4px; }
}
@layer utilities {
.mt-4 { margin-top: 1rem; }
}Rules: unlayered styles always win over layered styles (same specificity). Within layers, later declaration wins. This means you can import third-party CSS into a layer and override it easily:
/* Third-party CSS is locked into 'vendor' layer - your styles always win */
@layer vendor {
@import url('https://cdn.example.com/library.css');
}
@layer components {
/* This overrides vendor styles regardless of their specificity */
.library-button { color: var(--color-primary); }
}Reset strategy with layers
@layer reset {
*, *::before, *::after { box-sizing: border-box; }
img, video { max-width: 100%; display: block; }
h1, h2, h3, h4 { text-wrap: balance; }
p { text-wrap: pretty; }
}New Selectors
:has() - the "parent selector"
/* Card that contains an image gets different padding */
.card:has(img) { padding: 0; }
/* Form fields that contain invalid inputs */
.form-group:has(input:invalid) label { color: red; }
/* Navigation with an open dropdown */
nav:has(.dropdown[open]) { z-index: 100; }
/* Sibling targeting - li after a checked checkbox */
li:has(input:checked) + li { opacity: 0.5; }:is() - forgiving selector list
Reduces repetition, takes the highest specificity of its arguments:
/* Old way */
h1 a, h2 a, h3 a, h4 a { color: inherit; }
/* New way */
:is(h1, h2, h3, h4) a { color: inherit; }:where() - zero-specificity version of :is()
/* Good for resets - zero specificity, easy to override */
:where(h1, h2, h3) { font-weight: bold; }
/* Overriding is trivial since :where has 0 specificity */
.article h2 { font-weight: 400; }:not() level 4 - complex selectors and lists
/* Old: only simple selectors allowed inside :not() */
/* New: full selector lists supported */
a:not(.btn, .nav-link, [aria-current]) { text-decoration: underline; }
/* Every list item except the last */
li:not(:last-child) { border-bottom: 1px solid var(--border); }Native CSS nesting
/* No preprocessor needed */
.card {
padding: 1rem;
/* Nested rule - & is explicit parent reference */
& .title { font-size: 1.25rem; }
&:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
/* At-rules can be nested too */
@media (min-width: 768px) {
padding: 2rem;
}
}Subgrid
Allows nested elements to participate in the parent grid's tracks, solving the alignment-across-components problem.
/* Old way: each card is its own grid, headers don't align across cards */
/* New way: cards share the parent's row tracks */
.grid {
display: grid;
grid-template-rows: auto 1fr auto; /* header, content, footer */
gap: 1rem;
}
.card {
display: grid;
/* Inherit the parent's row tracks */
grid-row: span 3;
grid-template-rows: subgrid;
}
/* Now card-header, card-body, card-footer align across all cards automatically */For column subgrid, use grid-template-columns: subgrid on an item that spans multiple columns.
When to use: Cards in a grid that need aligned internal sections (consistent header/footer heights); form layouts where labels and inputs must align across rows; any time sibling elements inside different containers need to share alignment.
Modern Layout
Flexbox vs Grid decision tree
- Flexbox: one-dimensional layout, content-driven sizing, distributing space along one axis
- Grid: two-dimensional layout, layout-driven sizing, precise placement in rows AND columns simultaneously
- When unsure: if you're thinking in rows OR columns - flex; rows AND columns - grid
auto-fit vs auto-fill
/* auto-fill: creates as many tracks as fit, even empty ones */
.grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
/* auto-fit: collapses empty tracks, items stretch to fill */
.grid { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }Use auto-fit when you want fewer items to expand; use auto-fill when empty cells should maintain column widths.
Named grid areas
Use grid-template-areas for readable page layouts. Assign elements with grid-area: name. Redefine the template in a media query for responsive reflow - no element reordering needed.
aspect-ratio
/* Old way: padding-top hack (56.25% for 16:9) */
/* New way: */
.video-embed { aspect-ratio: 16 / 9; width: 100%; }
.avatar { aspect-ratio: 1; width: 48px; }
.card-image { aspect-ratio: 4 / 3; object-fit: cover; }Logical Properties
Physical properties (margin-left, padding-top) break in RTL languages and vertical writing modes. Logical properties adapt automatically.
| Physical | Logical equivalent | Maps to (LTR) |
|---|---|---|
margin-left |
margin-inline-start |
left |
margin-right |
margin-inline-end |
right |
margin-top |
margin-block-start |
top |
margin-bottom |
margin-block-end |
bottom |
padding-left/right |
padding-inline |
shorthand |
padding-top/bottom |
padding-block |
shorthand |
border-left |
border-inline-start |
left border |
width |
inline-size |
width in LTR |
height |
block-size |
height in LTR |
Use margin-inline-start instead of margin-left, text-align: start instead of text-align: left, etc. Physical properties remain fine for truly directional things (e.g., a drop shadow always to the bottom-right).
Modern Color
oklch
Perceptually uniform color space - equal numeric changes produce equal-looking changes. Better for generating color scales and accessible palettes.
:root {
/* oklch(lightness chroma hue) */
--color-primary: oklch(55% 0.2 250); /* medium blue */
--color-primary-light: oklch(80% 0.15 250); /* lighter, same hue */
--color-primary-dark: oklch(35% 0.2 250); /* darker, same hue */
}color-mix()
:root {
--color-primary: oklch(55% 0.2 250);
/* 20% lighter */
--color-primary-100: color-mix(in oklch, var(--color-primary) 20%, white);
/* Semi-transparent */
--color-overlay: color-mix(in srgb, black 50%, transparent);
}Relative color syntax
Derive colors from existing ones: oklch(from var(--brand) calc(l + 0.2) calc(c * 0.5) h) - same hue, lighter, less saturated.
light-dark()
Set color-scheme: light dark on :root, then use light-dark(#light, #dark) for any property. No @media (prefers-color-scheme) needed per property.
Responsive Design Without Breakpoints
clamp() for fluid sizing
/* clamp(minimum, preferred, maximum) */
/* preferred is usually a viewport-relative value */
:root {
--font-size-body: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
--font-size-h1: clamp(2rem, 1.5rem + 2.5vw, 4rem);
--spacing-section: clamp(2rem, 5vw, 8rem);
}
h1 { font-size: var(--font-size-h1); }Use utopia.fyi to generate fluid type scales. Container query units (cqi, cqw) size relative to container instead of viewport.
View Transitions API
Animate between DOM states and page navigations natively - no JS animation library needed.
Same-document transitions
// Wrap DOM mutation in startViewTransition
document.startViewTransition(() => {
// Update the DOM
listEl.innerHTML = newContent;
});CSS controls the animation:
/* Default: fade. Customize: */
::view-transition-old(root) {
animation: slide-out 300ms ease-in forwards;
}
::view-transition-new(root) {
animation: slide-in 300ms ease-out forwards;
}
/* Animate specific elements independently */
.hero-image {
view-transition-name: hero; /* must be unique per page */
}
::view-transition-old(hero),
::view-transition-new(hero) {
animation-duration: 500ms;
}For cross-document (MPA) transitions, use @view-transition { navigation: auto; } in both pages - no JS needed.
Always wrap in @media (prefers-reduced-motion: no-preference) or check window.matchMedia.
Scroll-Driven Animations
Animate elements based on scroll position using pure CSS - no scroll event listeners needed.
scroll() - link to scroll position
@keyframes fade-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animated-section {
animation: fade-in linear;
animation-timeline: scroll(); /* progress = page scroll progress */
animation-range: entry 0% entry 50%; /* animate during this range */
}view() - link to element's position in viewport
.card {
animation: fade-in linear both;
animation-timeline: view(); /* progress based on element entering/leaving viewport */
animation-range: entry 10% entry 60%; /* start when 10% visible, end at 60% */
}Combine scroll(root) with a scaleX keyframe for a pure-CSS reading progress bar. Check animation-timeline browser support before using in production.
Custom Properties
Define tokens on :root, override on [data-theme="dark"] - children inherit automatically. Works with calc() for dynamic computations.
@property - typed and animatable custom properties
/* Register a typed custom property */
@property --hue {
syntax: '<number>'; /* type: number, length, color, percentage, etc. */
inherits: false;
initial-value: 250;
}
/* Now it can be animated! */
.colorful {
background: oklch(60% 0.2 var(--hue));
transition: --hue 300ms;
}
.colorful:hover { --hue: 150; } /* animates smoothly */Unregistered custom properties cannot be animated because the browser doesn't know their type.
Performance
contain
contain: layout paint tells the browser this element is independent - changes don't affect outside. contain: strict adds size containment.
content-visibility
content-visibility: auto skips rendering off-screen content entirely. Pair with contain-intrinsic-size: auto 500px to prevent CLS. Can reduce initial render time by 50%+ on content-heavy pages.
will-change - use sparingly
will-change: transform, opacity promotes to compositor layer. Consumes GPU memory - add/remove programmatically before/after animation. Never leave on permanently or apply to many elements.
Avoid triggering layout
Properties that trigger layout (reflow): width, height, top, left, margin, padding, font-size. Compositor-only (no layout/paint): transform, opacity, filter. Always animate transform/opacity instead of geometric properties.
testing-strategy.md
Testing Strategy - Senior Frontend Engineering Reference
The Testing Trophy (Not Pyramid)
The classical pyramid (many unit, some integration, few E2E) was designed for backend services. For frontend UI, the testing trophy reflects higher value:
/\
/E2E\ - Few, covering critical user flows
/------\
/Integrat\ - Most tests live here (component + user flow tests)
/----------\
/ Unit \ - Pure functions, utilities, formatters, parsers
/--------------\
/ Static Analysis\ - TypeScript, ESLint (catches bugs before tests run)
/------------------\Why integration tests give the most value for frontend:
- They test real behavior: render a component, simulate a user interaction, assert what the user sees.
- They survive implementation refactors - if you rename a state variable, the test shouldn't care.
- They catch interaction bugs that unit tests of individual pieces would miss entirely.
Unit Tests
Best suited for code with no DOM involvement:
- Pure utility functions (
formatCurrency,parseDate,sortBy) - Data transformations and normalizers
- Business logic that doesn't touch UI (
calculateDiscount,validateEmail) - Complex state reducers
- Custom hook logic (if it contains substantial business logic)
Keep unit tests:
- Fast (milliseconds, no DOM, no network)
- Focused (one function, one behavior per test)
- Isolated (no external dependencies - mock at the boundary)
// Good unit test - tests behavior of a pure function
test('formatCurrency formats negative values with parentheses', () => {
expect(formatCurrency(-1500, 'USD')).toBe('($1,500.00)')
})
test('sortBy handles null values last', () => {
const result = sortBy([{ name: 'B' }, { name: null }, { name: 'A' }], 'name')
expect(result.map(r => r.name)).toEqual(['A', 'B', null])
})Integration / Component Tests
This is where most frontend test effort should go.
Core approach:
- Render the component in a realistic environment (with providers, routing context)
- Simulate what a user would actually do (click, type, submit)
- Assert what the user would actually see (visible text, ARIA state, DOM presence)
// Arrange - render with realistic context
render(<LoginForm />, { wrapper: AppProviders })
// Act - interact as a user would
await userEvent.type(getByLabelText('Email'), 'user@example.com')
await userEvent.type(getByLabelText('Password'), 'secret')
await userEvent.click(getByRole('button', { name: 'Log in' }))
// Assert - check what the user sees
expect(await findByText('Welcome back!')).toBeVisible()Query priority (most to least preferred):
getByRole- mirrors how screen readers navigate, tests accessibility toogetByLabelText- for form inputsgetByPlaceholderText- acceptable fallback for inputsgetByText- visible textgetByDisplayValue- current form field valuegetByAltText- imagesgetByTitle- accessible name via titlegetByTestId- last resort, implementation detail, avoid when possible
Test behavior, not implementation:
- Don't assert on component state variables
- Don't assert on class names or inline styles (unless testing visual intent)
- Don't assert on internal function calls
- Do assert on what the user sees, hears (ARIA), and can interact with
End-to-End Tests
E2E tests run against a real browser, real network (or realistic stub), full stack.
Use E2E for:
- Critical happy paths (checkout, signup, login, core feature flows)
- Key failure modes on critical paths (payment failure, auth error)
- Flows that span multiple pages or require real navigation
Do NOT use E2E for:
- Every UI component variation - that's what component tests are for
- Testing business logic - unit tests do that faster
- Validating every error message - too slow, too brittle
Resilient E2E test principles:
- Use accessible selectors (
getByRole,aria-label) not CSS selectors or XPath - Assert on stable user-visible outcomes, not transient loading states
- Avoid fixed
sleep/waitcalls - use assertions that wait for condition - Keep each test independent - no shared state between tests, seed data per test
// Good E2E - tests the critical checkout flow
test('user can complete a purchase', async () => {
await page.goto('/products/widget')
await page.getByRole('button', { name: 'Add to cart' }).click()
await page.getByRole('link', { name: 'Checkout' }).click()
await page.getByLabel('Card number').fill('4242424242424242')
// ... fill form
await page.getByRole('button', { name: 'Place order' }).click()
await expect(page.getByText('Order confirmed')).toBeVisible()
})Visual Regression Testing
Screenshot comparison catches unintended visual changes that functional tests miss.
Tools:
- Chromatic - integrates with Storybook, per-component screenshot, UI review workflow
- Percy - CI-integrated, page-level and component-level
- Playwright screenshots - built-in, lower overhead, good for specific components
When it's worth the cost:
- Design systems and component libraries - high visual stability expectation
- Marketing pages or landing pages with strict brand requirements
- Tables, charts, data visualizations where layout bugs are subtle
When to skip:
- Rapidly-changing UI during early development (too many false positives)
- Content-driven pages where text changes trigger false positives
- Small teams without a review process to handle visual diffs
Practical approach: Run visual tests on Storybook stories of UI primitives only. Keep the scope narrow so the signal-to-noise ratio stays high.
Accessibility Testing
axe-core in component tests:
import { axe } from 'jest-axe'
test('Modal is accessible', async () => {
const { container } = render(<Modal open title="Confirm deletion">...</Modal>)
const results = await axe(container)
expect(results).toHaveNoViolations()
})This catches: missing labels, insufficient color contrast (in some configurations), invalid ARIA roles, missing alt text, duplicate IDs.
Keyboard navigation tests:
test('dropdown can be navigated with keyboard', async () => {
render(<Dropdown options={options} />)
await userEvent.tab() // focus trigger
await userEvent.keyboard('{Enter}') // open
expect(getByRole('listbox')).toBeVisible()
await userEvent.keyboard('{ArrowDown}') // move to first option
await userEvent.keyboard('{Enter}') // select
expect(getByRole('button', { name: 'Option 1' })).toBeInTheDocument()
})Manual screen reader testing - automated tools catch ~30-40% of accessibility issues. For critical flows, manually test with VoiceOver (macOS/iOS) or NVDA/JAWS (Windows).
What NOT to Test
Knowing what to skip is as important as knowing what to cover.
| Don't test | Why |
|---|---|
| CSS class names | Implementation detail - refactoring styles breaks tests for no reason |
| Third-party library internals | You don't own them, they have their own tests |
| That a function was called | Test outcomes, not mechanism |
| Component state variables | Internal implementation - test what renders |
| Exact HTML structure | Brittle - a div to section change breaks tests unnecessarily |
| Framework rendering behavior | Trust the framework |
console.log calls |
Not a user-observable behavior |
Test Structure - Arrange / Act / Assert
Keep tests readable by separating phases clearly:
describe('ShoppingCart', () => {
describe('when the cart has items', () => {
test('shows item count in the badge', async () => {
// Arrange
const items = [{ id: '1', name: 'Widget', quantity: 3 }]
render(<CartBadge items={items} />)
// Act - (none needed for a render test)
// Assert
expect(getByRole('status', { name: 'Cart items' })).toHaveTextContent('3')
})
test('removes item when delete is clicked', async () => {
// Arrange
const onRemove = jest.fn()
render(<CartItem item={item} onRemove={onRemove} />)
// Act
await userEvent.click(getByRole('button', { name: 'Remove Widget' }))
// Assert
expect(onRemove).toHaveBeenCalledWith('1')
})
})
})Async patterns:
- Use
findBy*queries when waiting for async rendering (they retry until timeout) - Use
waitForwhen asserting on something that becomes true after an async operation - Avoid arbitrary
await new Promise(r => setTimeout(r, 100))- it's fragile and slow
Mocking Strategy
Mock when:
- Network calls - use MSW (Mock Service Worker) to intercept at the network level, not in code
- Timers (
setInterval,setTimeout,Date.now) - use fake timers for determinism - Browser APIs not in jsdom (
IntersectionObserver,ResizeObserver,matchMedia) - Complex third-party services (payment SDK, analytics)
Don't mock when:
- Simple utility functions - just call them (they're fast, they're pure)
- The DOM itself - jsdom is good enough for component tests
- Your own modules unless they have side effects
MSW is the gold standard for API mocking:
// Define once, reuse across unit, integration, and E2E tests
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: 'Test User' })
})
// Override per-test for error scenarios
server.use(
http.get('/api/users/:id', () => HttpResponse.error())
)MSW works at the network layer - your data fetching code runs exactly as in production. No coupling to implementation.
Performance Testing
Lighthouse CI - run Lighthouse in CI, fail builds when performance scores drop below threshold. Catches regressions before they reach users.
Bundle size checks:
// bundlesize config in package.json or bundlesize.config.js
{
"files": [
{ "path": "./dist/main.*.js", "maxSize": "150 kB" },
{ "path": "./dist/vendor.*.js", "maxSize": "200 kB" }
]
}Run in CI on every PR. Forces intentional decisions when adding large dependencies.
Web Vitals monitoring - measure real user LCP, CLS, INP in production. Tools: web-vitals library, Sentry performance, Datadog RUM. Synthetic tests (Lighthouse) don't replace real user measurement.
Test Quality Signals
Signs of a bad test:
- Breaks when you rename an internal variable or extract a helper function
- Tests that test only that code runs without error (no meaningful assertion)
- Tests with more mocks than real code
- Tests that duplicate other tests without covering new cases
- Flaky tests that sometimes pass, sometimes fail (fix or delete them)
Signs of a good test:
- Would catch a real bug a developer could plausibly introduce
- Survives a complete internal refactor as long as behavior is preserved
- Reads like documentation - another engineer can understand what the feature does from the test
- Fails clearly with a message that points to what broke and why
web-performance.md
Web Performance Reference
Core Web Vitals
Google's user-centric metrics that directly affect Search ranking and UX quality.
LCP - Largest Contentful Paint
Measures loading performance: time until the largest visible content element is rendered.
- Good: < 2.5s | Needs Improvement: 2.5s - 4.0s | Poor: > 4.0s
- Measured from navigation start to when the largest image/text block in the viewport is painted
- Common LCP elements: hero images, large text blocks,
<video>poster frames - Diagnose: Chrome DevTools > Performance panel > look for "LCP" marker; or Lighthouse
Top causes of slow LCP:
- Slow server response (TTFB > 600ms)
- Render-blocking resources (CSS/JS in
<head>) - Slow resource load time (unoptimized images)
- Client-side rendering without SSR/SSG
INP - Interaction to Next Paint
Replaced FID in March 2024. Measures responsiveness: the worst interaction latency (p98) across the full page visit.
- Good: < 200ms | Needs Improvement: 200ms - 500ms | Poor: > 500ms
- Covers all clicks, taps, and keyboard interactions (not hover/scroll)
- An interaction = input delay + processing time + presentation delay
Reduce INP:
- Break up long tasks with
scheduler.yield()orsetTimeout(0)chunking - Move heavy work off the main thread (Web Workers)
- Minimize JS execution time in event handlers
- Avoid layout thrashing inside event callbacks
// Break long tasks
async function processItems(items) {
for (const item of items) {
process(item);
await scheduler.yield(); // yield to browser between iterations
}
}CLS - Cumulative Layout Shift
Measures visual stability: sum of all unexpected layout shift scores during a page's lifetime.
- Good: < 0.1 | Needs Improvement: 0.1 - 0.25 | Poor: > 0.25
- Score = impact fraction * distance fraction (per shift)
Common causes and fixes:
| Cause | Fix |
|---|---|
| Images without dimensions | Always set width + height attributes |
| Ads/embeds without reserved space | Use min-height or aspect-ratio containers |
| Late-injected content above fold | Reserve space; avoid inserting DOM above existing content |
| Web fonts causing FOUT | Use font-display: optional or preload fonts |
<!-- Always include dimensions to prevent layout shift -->
<img src="hero.jpg" width="1200" height="630" alt="..." />
<!-- Or use aspect-ratio in CSS -->
<style>
.image-container { aspect-ratio: 16 / 9; }
</style>Critical Rendering Path
Browser steps to render a page: HTML parsing -> DOM -> CSSOM -> Render Tree -> Layout -> Paint -> Composite
What blocks rendering
Render-blocking CSS: All <link rel="stylesheet"> in <head> block paint until downloaded and parsed.
- Solution: inline critical CSS; load non-critical CSS asynchronously
Parser-blocking JS: <script> without async/defer stops HTML parsing.
defer: executes after HTML parsed, before DOMContentLoaded, in orderasync: executes as soon as downloaded, out of order - use for independent scripts (analytics)- Module scripts (
type="module") are deferred by default
<!-- Blocks parsing - avoid for non-critical JS -->
<script src="app.js"></script>
<!-- Deferred - recommended for most scripts -->
<script src="app.js" defer></script>
<!-- Async - analytics, ads, independent widgets -->
<script src="analytics.js" async></script>Inline critical CSS
Extract above-the-fold CSS and inline it in <head>. Load the rest asynchronously.
<style>/* critical above-the-fold styles here */</style>
<link rel="preload" href="styles.css" as="style" onload="this.rel='stylesheet'">Bundle Optimization
Tree Shaking
Dead code elimination. Requires ES modules (static import/export). Ensure:
"sideEffects": falsein package.json (or list files with side effects)- Avoid
import *- import named exports specifically - CommonJS (
require()) cannot be tree-shaken by most bundlers
Code Splitting
Split your bundle so users only download what they need.
// Route-level splitting (framework-agnostic dynamic import)
const module = await import('./heavy-feature.js');
// Conditional loading
if (userNeedsFeature) {
const { init } = await import('./feature.js');
init();
}Chunk Strategy
- Entry chunks: one per page/route
- Vendor chunk: stable third-party code - long cache TTL
- Shared chunk: code used across multiple routes
- Keep initial JS under 170KB compressed for mobile
Analyze bundles
- webpack-bundle-analyzer, rollup-plugin-visualizer
source-map-explorerfor any bundle with source maps- Check: duplicate dependencies, large libraries with small-usage (moment.js -> date-fns)
Image Optimization
Modern Formats
| Format | Best for | Browser support |
|---|---|---|
| AVIF | Photos, high compression | ~90% (2024) |
| WebP | Photos and graphics | ~97% |
| SVG | Icons, logos, illustrations | Universal |
| PNG | Graphics with transparency (fallback) | Universal |
Use <picture> for format negotiation with fallback:
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img src="hero.jpg" alt="Hero image" width="1200" height="630">
</picture>Responsive Images
<!-- srcset with sizes - browser picks best fit -->
<img
srcset="img-400.webp 400w, img-800.webp 800w, img-1600.webp 1600w"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 800px"
src="img-800.webp"
alt="..."
>Lazy Loading and Priority
<!-- Lazy load below-fold images (native, no JS required) -->
<img src="below-fold.jpg" loading="lazy" alt="...">
<!-- Never lazy-load LCP image; add fetchpriority=high -->
<img src="hero.jpg" loading="eager" fetchpriority="high" alt="...">fetchpriority="high": boosts resource priority in browser's fetch queue (use on LCP image)fetchpriority="low": deprioritize non-critical images- Do NOT lazy-load images in the initial viewport
Font Optimization
font-display values
@font-face {
font-family: 'MyFont';
src: url('font.woff2') format('woff2');
font-display: swap; /* FOUT: shows fallback immediately, swaps when loaded */
/* font-display: optional; best for CLS - uses fallback if font not cached */
/* font-display: block; FOIT: invisible text for up to 3s - avoid */
}swap: best for content fonts - text visible immediatelyoptional: best for decorative fonts - no layout shift, may not show on slow connections
Preload critical fonts
<!-- Preload only the font variants used above the fold -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>Subsetting
Reduce font file size by including only needed characters. Tools: pyftsubset, glyphhanger.
A full latin font might be 200KB; a subset for English can be under 20KB.
Variable fonts
One file replaces multiple weight/style files. Use when you need 3+ font variants.
@font-face {
font-family: 'Inter';
src: url('inter-variable.woff2') format('woff2-variations');
font-weight: 100 900; /* weight range covered by this file */
}Caching Strategies
Cache-Control headers
# Immutable assets (hash in filename) - cache forever
Cache-Control: public, max-age=31536000, immutable
# HTML - always revalidate
Cache-Control: no-cache
# API responses - short cache + stale-while-revalidate
Cache-Control: public, max-age=60, stale-while-revalidate=300stale-while-revalidate
Serve stale content immediately while fetching fresh content in background. Eliminates wait time on cache miss.
Service Worker caching
// Cache-first for static assets
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then(cached =>
cached ?? fetch(event.request).then(response => {
const clone = response.clone();
caches.open('v1').then(cache => cache.put(event.request, clone));
return response;
})
)
);
});Strategies: Cache-first (static assets), Network-first (API), Stale-while-revalidate (balance).
Resource Hints
<!-- preconnect: establish TCP+TLS early to critical third-party origins -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<!-- dns-prefetch: DNS only - lighter than preconnect, use for non-critical origins -->
<link rel="dns-prefetch" href="https://analytics.example.com">
<!-- preload: high-priority fetch for current page resources (fonts, hero images, critical JS) -->
<link rel="preload" href="critical.js" as="script">
<link rel="preload" href="hero.webp" as="image" fetchpriority="high">
<!-- prefetch: low-priority fetch for next navigation resources -->
<link rel="prefetch" href="/next-page-bundle.js">
<!-- modulepreload: preload + parse ES module -->
<link rel="modulepreload" href="/app.js">Rules:
- Only
preloadwhat you'll use on the current page - unused preloads generate console warnings preconnectto max 2-3 origins - each has CPU cost- Use
prefetchfor likely-next-page routes based on user behavior
Performance Measurement
Performance API
// Navigation timing
const [nav] = performance.getEntriesByType('navigation');
console.log('TTFB:', nav.responseStart - nav.requestStart);
console.log('DOM ready:', nav.domContentLoadedEventEnd);
// Custom marks and measures
performance.mark('feature-start');
doExpensiveWork();
performance.mark('feature-end');
performance.measure('feature', 'feature-start', 'feature-end');
const [measure] = performance.getEntriesByName('feature');
console.log(measure.duration + 'ms');PerformanceObserver
// Observe LCP
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lcp = entries[entries.length - 1];
console.log('LCP:', lcp.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
// Observe CLS
let cls = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) cls += entry.value;
}
}).observe({ type: 'layout-shift', buffered: true });web-vitals library
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
onLCP(({ value, rating }) => sendToAnalytics({ metric: 'LCP', value, rating }));
onINP(({ value, rating }) => sendToAnalytics({ metric: 'INP', value, rating }));
onCLS(({ value, rating }) => sendToAnalytics({ metric: 'CLS', value, rating }));Rendering Performance
Layout Thrashing
Occurs when you read layout properties then write, forcing multiple reflows in a loop.
// BAD - causes layout thrashing (read/write interleaved)
elements.forEach(el => {
const height = el.offsetHeight; // read (forces reflow)
el.style.height = height * 2 + 'px'; // write
});
// GOOD - batch reads then writes
const heights = elements.map(el => el.offsetHeight); // all reads
elements.forEach((el, i) => { el.style.height = heights[i] * 2 + 'px'; }); // all writesUse fastdom library to schedule reads/writes automatically.
Compositor Layers
Certain CSS properties are handled by the GPU compositor, skipping layout and paint:
transformandopacityare compositor-only - animate these instead oftop/left/widthwill-change: transformpromotes element to its own layer (use sparingly - memory cost)
/* BAD - triggers layout on every frame */
.animate { transition: left 300ms; }
/* GOOD - compositor only, smooth 60fps */
.animate { transition: transform 300ms; }requestAnimationFrame
Use for any visual updates to sync with browser paint cycle.
// BAD - may run mid-frame
setInterval(updateUI, 16);
// GOOD - synced to display refresh rate
function update() {
updateUI();
requestAnimationFrame(update);
}
requestAnimationFrame(update);content-visibility
/* Skip rendering off-screen sections entirely */
.below-fold-section {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* estimated size to avoid CLS */
}Network Optimization
HTTP/2 and HTTP/3
- HTTP/2: multiplexing eliminates head-of-line blocking; domain sharding is now anti-pattern
- HTTP/3: QUIC-based, better on lossy networks (mobile)
- With HTTP/2, bundling many small files is less critical than with HTTP/1.1
Compression
- Brotli (br): 15-25% better than gzip, supported by all modern browsers - prefer for text assets
- Gzip: universal fallback
- Always compress: HTML, CSS, JS, JSON, SVG (text-based assets)
- Never compress: already-compressed formats (JPEG, WebP, AVIF, WOFF2)
CDN Strategy
- Serve all static assets from a CDN - reduces latency via edge PoPs
- Set immutable cache headers on hashed assets
- Use CDN for API responses when appropriate (with correct Vary headers)
- Consider CDN-level image optimization (Cloudflare Images, Imgix, Cloudinary)
Connection limits
- Browsers allow 6 concurrent connections per origin (HTTP/1.1)
- HTTP/2 uses a single connection with multiplexing - no need to shard
- Third-party scripts compete for connections - audit and remove unused third parties
Frequently Asked Questions
What is frontend-developer?
Senior frontend engineering expertise for building high-quality web interfaces. Use this skill when writing, reviewing, or optimizing frontend code - HTML, CSS, JavaScript, TypeScript, components, layouts, forms, or interactive UI. Triggers on web performance optimization (Core Web Vitals, bundle size, lazy loading), accessibility audits (WCAG, ARIA, keyboard navigation, screen readers), code quality reviews, component architecture decisions, testing strategy, and modern CSS patterns. Covers the full frontend spectrum from semantic markup to production performance.
How do I install frontend-developer?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill frontend-developer in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support frontend-developer?
frontend-developer works with claude-code, gemini-cli, openai-codex, mcp. Install it once and use it across any supported AI coding agent.