What This Template Covers
Critical CSS is the minimum CSS required to render above-the-fold content without a Flash of Unstyled Content (FOUC). By inlining it in the HTML <head>, you eliminate render-blocking stylesheet requests and cut First Contentful Paint by 200-500ms on typical sites.
This template provides working configurations for the most common build tools and frameworks.
Section 1: Identify Critical CSS
Manual identification
- Open Chrome DevTools → Coverage tab (Ctrl+Shift+P → "Show Coverage")
- Reload the page
- Scroll to the first viewport boundary (above-fold content)
- Click on CSS files in the Coverage report
- Blue-highlighted rules are used; red are unused on this viewport
Above-fold typically includes: header, navigation, hero section, and the first content block.
Automated identification
Tools like Penthouse and Critical analyze a page at a specific viewport size and extract only the CSS needed:
# Using the critical npm package
npx critical https://yoursite.com --base ./dist --inline
Size target
Critical CSS should be under 14KB (compressed). This is the maximum payload that fits in the first TCP congestion window, meaning it arrives in a single round trip. Larger critical CSS requires additional round trips, reducing the benefit.
Section 2: Extract Critical CSS
Using the critical npm package
// scripts/extract-critical.js
const critical = require("critical")
critical.generate({
base: "dist/",
src: "index.html",
css: ["dist/styles.css"],
width: 1300,
height: 900,
inline: true,
target: {
html: "dist/index.html",
css: "dist/critical.css"
}
})
Using Penthouse (more control)
const penthouse = require("penthouse")
async function extractCritical() {
const criticalCss = await penthouse({
url: "http://localhost:3000",
cssString: fullCss,
width: 1300,
height: 900,
forceInclude: [".header", ".nav", ".hero"]
})
return criticalCss
}
The forceInclude option ensures specific selectors are always included, even if the headless browser doesn't detect them as above-fold (useful for interactive elements that appear on hover/click).
Section 3: Inline Critical CSS
HTML structure
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Page Title</title>
<!-- Critical CSS: inlined for instant rendering -->
<style>
:root {
--bg: #fff;
--text: #111;
--primary: #2563eb;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, sans-serif;
color: var(--text);
background: var(--bg);
}
.header {
height: 64px;
display: flex;
align-items: center;
padding: 0 24px;
border-bottom: 1px solid #eee;
}
.hero {
min-height: 60vh;
display: grid;
place-items: center;
padding: 48px 24px;
}
h1 {
font-size: clamp(2rem, 5vw, 3.5rem);
line-height: 1.1;
max-width: 800px;
}
</style>
<!-- Full stylesheet: loaded without blocking render -->
<link
rel="stylesheet"
href="/styles.a1b2c3.css"
media="print"
onload="this.media='all'"
/>
<noscript><link rel="stylesheet" href="/styles.a1b2c3.css" /></noscript>
</head>
<body>
<!-- Page content -->
</body>
</html>
How the async loading works
media="print"— Browser downloads the stylesheet but doesn't apply it (print-only)onload="this.media='all'"— When download completes, switch to apply for all media<noscript>fallback — Loads stylesheet normally for users with JavaScript disabled
This pattern is recommended by Google and supported by all modern browsers.
Section 4: Build Tool Configurations
webpack with Critters
// webpack.config.js
const Critters = require("critters-webpack-plugin")
module.exports = {
plugins: [
new Critters({
// Inline critical CSS, async load the rest
preload: "swap",
// Include CSS for these selectors even if not detected
additionalStylesheets: [],
// Don't inline styles larger than 20KB
inlineThreshold: 20000
})
]
}
Critters runs at build time:
- Renders each HTML page
- Identifies CSS used by visible elements
- Inlines critical CSS as
<style>tags - Converts
<link>to async loading
Vite with vite-plugin-critical
// vite.config.js
import critical from "vite-plugin-critical"
export default {
plugins: [
critical({
criticalUrl: "http://localhost:5173",
criticalBase: "./dist",
criticalPages: [
{ uri: "/", template: "index" },
{ uri: "/about", template: "about" }
],
criticalConfig: {
width: 1300,
height: 900
}
})
]
}
Next.js
Next.js includes built-in CSS optimization:
// next.config.js
module.exports = {
experimental: {
optimizeCss: true // Uses Critters internally
}
}
For more control, use next/script to load non-critical CSS:
import Script from "next/script"
// In your layout
;<Script id="load-extra-css" strategy="afterInteractive">
{`
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/extra-styles.css';
document.head.appendChild(link);
`}
</Script>
Section 5: Validation and Testing
Lighthouse audit
Run Lighthouse and check:
- "Eliminate render-blocking resources" — should show no CSS files (they're now inlined or async)
- First Contentful Paint — should improve by 200-500ms
- Largest Contentful Paint — should improve if LCP element was delayed by CSS
Visual regression testing
Critical CSS extraction can miss selectors, causing FOUC. Test by:
- Disable JavaScript in browser settings
- Load the page — above-fold content should be fully styled from the inlined CSS
- Compare against the fully-styled version
Slow network simulation
- DevTools → Network → Slow 3G
- Reload the page
- Above-fold content should render immediately (from inlined CSS)
- Full styles should apply as the async stylesheet loads
Automated CI check
# Verify critical CSS is present in HTML
curl -s https://yoursite.com | grep -q '<style>' && echo "Critical CSS found" || echo "MISSING"
# Verify no render-blocking CSS in head
curl -s https://yoursite.com | grep '<link rel="stylesheet"' | grep -v 'media="print"' && echo "WARNING: blocking CSS found"
Maintaining Critical CSS
Per-page vs. shared critical CSS
- Per-page: Each page type has its own critical CSS (most precise, best performance)
- Shared: One critical CSS covers all page templates (simpler, slightly larger)
For content sites with a consistent layout, shared critical CSS is practical. For sites with diverse page layouts (marketing + dashboard + checkout), per-page extraction is worth the complexity.
Updating critical CSS
Re-extract critical CSS when:
- Page layout changes (new header, redesigned hero)
- Global CSS changes (new CSS variables, updated base styles)
- New page templates are added
Automate extraction in your CI pipeline to keep critical CSS in sync with layout changes.
Frequently Asked Questions
How much performance improvement should I expect?
Sites with large CSS bundles (150KB+) typically see a 200-500ms FCP improvement. Sites with already-small CSS (< 30KB) see smaller gains. The improvement is most noticeable on mobile connections.
Can critical CSS cause FOUC?
If extracted incorrectly, yes. If the critical CSS misses important above-fold rules, those elements appear unstyled until the full stylesheet loads. Always validate by testing with JavaScript disabled and slow network conditions.
Should I inline critical CSS for every page?
For pages where performance matters (landing pages, key content pages, e-commerce product pages), yes. For low-traffic admin pages, the complexity may not be worth it.
Does critical CSS work with CSS-in-JS?
For libraries with server-side extraction (styled-components with SSR, Emotion with SSR), critical CSS is handled by the SSR process. For runtime-only CSS-in-JS, you need a separate critical CSS tool or should migrate to static CSS extraction.
Related Resources
- Optimize CSS Delivery Guide — Broader CSS optimization strategies
- Critical Rendering Path — How browsers build pages
- Render-Blocking Resources — Understanding render blockers
- Eliminate Render-Blocking Resources — Step-by-step guide