Critical CSS
Critical CSS is the technique of identifying and inlining the minimum CSS required to render the above-the-fold content of a page. The remaining styles are loaded asynchronously after the initial render.
The goal is to eliminate render-blocking CSS requests. When a browser encounters a <link rel="stylesheet">, it pauses rendering until the stylesheet is downloaded and parsed. By inlining critical styles directly in the <head>, the page renders immediately, dramatically improving perceived performance and Core Web Vitals scores.
| 1 | <!DOCTYPE html> |
| 2 | <html> |
| 3 | <head> |
| 4 | <!-- Critical CSS — inlined, renders immediately --> |
| 5 | <style> |
| 6 | body { font-family: monospace; margin: 0; background: #0D0D0D; color: #E0E0E0; } |
| 7 | .header { display: flex; padding: 16px; background: #1A1A2E; } |
| 8 | .hero { min-height: 60vh; display: flex; align-items: center; justify-content: center; } |
| 9 | .hero h1 { font-size: clamp(2rem, 5vw, 4rem); color: #00FF41; } |
| 10 | </style> |
| 11 | |
| 12 | <!-- Non-critical CSS — loaded async --> |
| 13 | <link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'"> |
| 14 | <noscript><link rel="stylesheet" href="styles.css"></noscript> |
| 15 | </head> |
| 16 | <body> |
| 17 | <header class="header">...</header> |
| 18 | <section class="hero"> |
| 19 | <h1>Above the fold content</h1> |
| 20 | </section> |
| 21 | </body> |
| 22 | </html> |
"Above the fold" refers to the portion of the page visible without scrolling. The exact content varies by device — a 1920px desktop screen shows more above the fold than a 375px mobile phone. Critical CSS must account for multiple viewport sizes.
| 1 | /* Critical CSS for mobile (375px) */ |
| 2 | @media (max-width: 767px) { |
| 3 | .hero { min-height: 100vh; padding: 16px; } |
| 4 | .hero h1 { font-size: 1.75rem; } |
| 5 | .nav { display: flex; justify-content: space-between; } |
| 6 | } |
| 7 | |
| 8 | /* Critical CSS for tablet (768px) */ |
| 9 | @media (min-width: 768px) and (max-width: 1023px) { |
| 10 | .hero { min-height: 80vh; padding: 32px; } |
| 11 | .hero h1 { font-size: 2.5rem; } |
| 12 | } |
| 13 | |
| 14 | /* Critical CSS for desktop (1024px+) */ |
| 15 | @media (min-width: 1024px) { |
| 16 | .hero { min-height: 60vh; padding: 64px; } |
| 17 | .hero h1 { font-size: 4rem; } |
| 18 | } |
| 19 | |
| 20 | /* These are the only styles needed for initial render. |
| 21 | Everything else (footer, comments, sidebar, modals) |
| 22 | can be loaded asynchronously. */ |
best practice
Manual extraction does not scale for real projects. Automated tools analyze your rendered HTML against your CSS to determine which rules apply above the fold. The process typically involves:
| 1 | // Critical CSS extraction with Puppeteer |
| 2 | const puppeteer = require('puppeteer'); |
| 3 | const { critical } = require('critical'); |
| 4 | |
| 5 | async function generateCriticalCSS(url) { |
| 6 | const result = await critical.generate({ |
| 7 | base: '/path/to/site', |
| 8 | src: url, |
| 9 | targets: { |
| 10 | critical: 'critical.css', |
| 11 | stylesheet: 'styles.css', // remaining styles |
| 12 | }, |
| 13 | width: [375, 768, 1280], // viewports to target |
| 14 | height: [667, 1024, 800], |
| 15 | inline: true, // inline critical CSS |
| 16 | minify: true, |
| 17 | }); |
| 18 | |
| 19 | return result; |
| 20 | } |
| 21 | |
| 22 | // Programmatic usage |
| 23 | critical.generate({ |
| 24 | inline: true, |
| 25 | base: 'dist/', |
| 26 | src: 'index.html', |
| 27 | target: 'index-critical.html', |
| 28 | width: [375, 768, 1280], |
| 29 | height: [667, 1024, 800], |
| 30 | }); |
Inlined CSS is embedded directly in the <style> tag within the <head>. This eliminates network latency for the initial render — the browser has the styles immediately.
| 1 | <head> |
| 2 | <!-- Inlined critical CSS — no network request needed --> |
| 3 | <style> |
| 4 | /* Critical styles for hero, nav, above-fold content */ |
| 5 | *,*::before,*::after{box-sizing:border-box} |
| 6 | body{margin:0;font-family:system-ui;background:#0D0D0D;color:#E0E0E0} |
| 7 | .header{display:flex;align-items:center;padding:0 24px;height:64px;background:#1A1A2E} |
| 8 | .hero{min-height:70vh;display:flex;align-items:center;justify-content:center;flex-direction:column;padding:24px;text-align:center} |
| 9 | .hero h1{font-size:clamp(2rem,5vw,4rem);color:#00FF41;margin:0 0 16px} |
| 10 | @media(max-width:767px){.hero h1{font-size:1.75rem;}} |
| 11 | .hero p{font-size:clamp(0.875rem,2vw,1.25rem);color:#A0A0A0;max-width:600px} |
| 12 | </style> |
| 13 | |
| 14 | <!-- Preload non-critical CSS, switch to stylesheet when loaded --> |
| 15 | <link rel="preload" href="/styles/main.css" as="style" |
| 16 | onload="this.onload=null;this.rel='stylesheet'"> |
| 17 | <noscript><link rel="stylesheet" href="/styles/main.css"></noscript> |
| 18 | </head> |
note
The remaining CSS should be loaded without blocking the initial render. Several techniques achieve this, each with different trade-offs.
Preload + onload
| 1 | <!-- Most common technique — preload, then swap --> |
| 2 | <link rel="preload" href="styles.css" as="style" |
| 3 | onload="this.onload=null;this.rel='stylesheet'"> |
| 4 | <noscript><link rel="stylesheet" href="styles.css"></noscript> |
Media Query Trick
| 1 | <!-- Load with non-matching media query, then switch --> |
| 2 | <link rel="stylesheet" href="styles.css" media="print" |
| 3 | onload="this.media='all'"> |
| 4 | <noscript><link rel="stylesheet" href="styles.css"></noscript> |
JavaScript Load
| 1 | // Load CSS dynamically |
| 2 | function loadCSS(href) { |
| 3 | const link = document.createElement('link'); |
| 4 | link.rel = 'stylesheet'; |
| 5 | link.href = href; |
| 6 | document.head.appendChild(link); |
| 7 | } |
| 8 | |
| 9 | // Intersection Observer — load when below-fold content is near viewport |
| 10 | const observer = new IntersectionObserver((entries) => { |
| 11 | entries.forEach(entry => { |
| 12 | if (entry.isIntersecting) { |
| 13 | loadCSS('/styles/comments.css'); |
| 14 | observer.unobserve(entry.target); |
| 15 | } |
| 16 | }); |
| 17 | }); |
| 18 | |
| 19 | observer.observe(document.querySelector('.comments-section')); |
Several tools automate the critical CSS workflow. Each integrates differently with your build pipeline.
| Tool | Type | Best For |
|---|---|---|
| Critical | Node.js library | Full control, build pipeline integration |
| PurgeCSS | CSS optimizer | Removing unused CSS from production builds |
| Penthouse | Node.js library | Puppeteer-based extraction with viewport control |
| webpack-critical | Webpack plugin | Automatic integration with webpack builds |
| vite-plugin-critical | Vite plugin | Vite-based projects |
| next-css-optimize | Next.js plugin | Next.js applications |
Critical CLI Usage
| 1 | # Install |
| 2 | npm install --save-dev critical |
| 3 | |
| 4 | # CLI usage |
| 5 | npx critical --base dist --src index.html --target index.html --width 375 768 1280 --height 667 1024 800 |
| 6 | |
| 7 | # Inline mode — modifies HTML in place |
| 8 | npx critical --base dist --src about.html --target about.html --inline |
PurgeCSS
| 1 | // purgecss.config.js |
| 2 | module.exports = { |
| 3 | content: ['./src/**/*.html', './src/**/*.jsx', './src/**/*.tsx'], |
| 4 | css: ['./src/**/*.css'], |
| 5 | safelist: { |
| 6 | standard: [/^theme-/, /^is-/], // keep dynamic classes |
| 7 | deep: [/modal$/], |
| 8 | }, |
| 9 | }; |
Critical CSS directly impacts two Core Web Vitals metrics: First Contentful Paint (FCP) and Largest Contentful Paint (LCP).
| Metric | What it Measures | Impact of Critical CSS |
|---|---|---|
| FCP | Time when first text or image appears | Eliminates render-blocking CSS, content appears earlier |
| LCP | Time when largest content element renders | Hero images and headings style immediately, no flash |
| TBT | Total Blocking Time (main thread) | Reduced CSS parsing overhead on the main thread |
| CLS | Cumulative Layout Shift | Proper critical CSS prevents layout shifts from async CSS |
info
Critical CSS pairs naturally with CSS code splitting. Rather than one monolithic stylesheet, split CSS by route, component, or feature, and load each chunk only when needed.
| 1 | /* Route-based splitting */ |
| 2 | /* home.css — critical path */ |
| 3 | .hero { /* ... */ } |
| 4 | .features { /* ... */ } |
| 5 | |
| 6 | /* dashboard.css — lazy loaded */ |
| 7 | .chart { /* ... */ } |
| 8 | .data-table { /* ... */ } |
| 9 | |
| 10 | /* Component-based splitting */ |
| 11 | /* button.css */ |
| 12 | .btn { /* ... */ } |
| 13 | .btn--primary { /* ... */ } |
| 14 | |
| 15 | /* modal.css — only loaded when modal opens */ |
| 16 | .modal-overlay { /* ... */ } |
| 17 | .modal-content { /* ... */ } |
Dynamic Import Pattern
| 1 | // React — lazy load component CSS |
| 2 | import { lazy, Suspense } from 'react'; |
| 3 | |
| 4 | const Dashboard = lazy(() => import('./Dashboard')); |
| 5 | |
| 6 | // Dashboard.jsx — CSS imported with component |
| 7 | import './Dashboard.css'; |
| 8 | |
| 9 | // Vite — CSS code splitting automatic with dynamic imports |
| 10 | const AdminPanel = lazy(() => import('./AdminPanel')); |
| 11 | |
| 12 | // Next.js — CSS Modules scoped per page |
| 13 | // Each page.tsx imports its own .module.css |
| 14 | // Next.js automatically extracts critical CSS per route |
A production-grade critical CSS workflow integrates extraction, inlining, and fallback loading into your build pipeline.
| 1 | // build.js — example build pipeline |
| 2 | const { build } = require('vite'); |
| 3 | const critical = require('critical'); |
| 4 | const { PurgeCSS } = require('purgecss'); |
| 5 | |
| 6 | async function buildWithCriticalCSS() { |
| 7 | // 1. Build the application |
| 8 | await build(); |
| 9 | |
| 10 | // 2. Purge unused CSS |
| 11 | const purged = await new PurgeCSS().purge({ |
| 12 | content: ['dist/**/*.html'], |
| 13 | css: ['dist/**/*.css'], |
| 14 | }); |
| 15 | |
| 16 | // 3. Generate critical CSS for each page |
| 17 | const pages = ['index', 'about', 'docs', 'pricing']; |
| 18 | |
| 19 | for (const page of pages) { |
| 20 | await critical.generate({ |
| 21 | base: 'dist', |
| 22 | src: `${page}.html`, |
| 23 | target: `${page}.html`, |
| 24 | inline: true, |
| 25 | width: [375, 768, 1280], |
| 26 | height: [667, 1024, 800], |
| 27 | minify: true, |
| 28 | }); |
| 29 | } |
| 30 | |
| 31 | // 4. Result: each HTML file has inlined critical CSS |
| 32 | // with async-loaded full stylesheet |
| 33 | console.log('Critical CSS generated for all pages'); |
| 34 | } |
| 35 | |
| 36 | buildWithCriticalCSS(); |
Verifying the Result
| 1 | # Check if CSS is render-blocking |
| 2 | curl -s https://your-site.com | grep -o '<link[^>]*stylesheet[^>]*>' |
| 3 | |
| 4 | # Verify critical CSS is inlined |
| 5 | curl -s https://your-site.com | grep -o '<style>.*</style>' | head -c 100 |
| 6 | |
| 7 | # Lighthouse CI check |
| 8 | npx lighthouse https://your-site.com --quiet --output=json | jq '.categories.performance.score' |
| 9 | |
| 10 | # WebPageTest |
| 11 | # https://www.webpagetest.org/ — check "render-blocking resources" |
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.