Skip to content

2.5 — CSS and the critical path

Module 2 · Lesson 5 · 🟡 Intermediate · ~30 min

What you'll learn

  • Why CSS blocks rendering, and how much that costs on a real commerce page
  • The true cost of runtime CSS‑in‑JS in a React/RSC app, and migration paths
  • content-visibility and containment for long PLPs
  • Animation performance: what runs on the compositor and what doesn't

CSS is render‑blocking, by design

The browser will not paint until CSS in <head> is downloaded and parsed. This is correct — you don't want a flash of unstyled content — but it means your stylesheet size is directly on the LCP critical path.

HTML arrives 1270ms ──▶ parser finds <link rel=stylesheet>
                        ├─ CSS download   180ms (68KB gz over 4G)
                        ├─ CSS parse      45ms  (main thread, mid-tier device)
                        └─ style + layout 60ms
                        ▶ first paint possible at 1555ms

Budget: ≤ 50 KB compressed of render‑blocking CSS for a commerce page (1.5). Most sites that have never looked are at 120–300 KB, usually because of an unpurged utility framework or one giant global stylesheet serving every page type.

Measure yours:

curl -s -H 'Accept-Encoding: br' https://www.auroramarket.com \
  | grep -o '/_next/static/css/[^"]*\.css' | sort -u | while read -r css; do
    size=$(curl -s -H 'Accept-Encoding: br' "https://www.auroramarket.com$css" | wc -c)
    echo "$((size / 1024)) KB  $css"
  done

And find how much of it is used: DevTools → Coverage panel (Ctrl+Shift+P → "Show Coverage") → reload. Anything above ~60% unused on a given page is a code‑splitting problem.


How Next.js splits CSS

The App Router automatically splits CSS by route: styles imported in a route segment load only for that route, and shared styles land in a common file. This works well if your imports are structured to allow it.

app/
├── layout.tsx        imports globals.css  → loaded on every page (keep it SMALL)
├── page.tsx          imports home.module.css
├── p/[slug]/
│   ├── page.tsx      imports pdp.module.css      → only on PDP
│   └── gallery.tsx   imports gallery.module.css
└── checkout/
    └── page.tsx      imports checkout.module.css → only on checkout

The common failure: a design system barrel that imports every component's CSS.

// ❌ packages/ui/index.ts — importing anything pulls in all the CSS
export * from './button';    // + button.css
export * from './modal';     // + modal.css
export * from './carousel';  // + carousel.css
export * from './datepicker';// + datepicker.css  (used on ONE page)
// … 60 more
// ✅ Deep imports keep CSS with the components that are actually used
import { Button } from '@aurora/ui/button';
import { Modal } from '@aurora/ui/modal';

Configure exports in the package so deep imports are the supported path, and add optimizePackageImports for libraries you can't restructure:

// next.config.ts
const config: NextConfig = {
  experimental: {
    optimizePackageImports: ['@aurora/ui', 'lucide-react', 'date-fns'],
  },
};

More on this in 4.3 Dependency diet.


Runtime CSS‑in‑JS: the honest accounting

Libraries that generate and inject styles at runtime (styled‑components, Emotion in its runtime mode) cost you in four places:

Cost Magnitude
Runtime bundle 12–20 KB gz
Style serialization + injection on every render 5–40 ms per render on mid‑tier mobile
Extra work during hydration (rehydrating the style sheet) 30–150 ms
They force 'use client' The big one — a styled component can't be a Server Component

That last row is what makes this a rendering‑architecture problem, not a micro‑optimization. If your <Button> is a styled‑component, every page that uses a button has a client boundary around it. At Aurora, the design system's use of runtime CSS‑in‑JS was directly responsible for ~180 KB of the shared client chunk and prevented most of the RSC migration.

The options

Approach Runtime cost RSC‑compatible Migration effort
Tailwind 0 Medium (rewrite styles)
CSS Modules 0 Medium
Vanilla Extract / Panda / StyleX 0 (build‑time) Medium — keeps a TS‑authoring DX
Emotion/styled‑components + compiler plugin Reduced ⚠️ Partly Low
Runtime CSS‑in‑JS as‑is High

For a large design system, the pragmatic migration is strangler‑style, not a big bang:

// Step 1: freeze — no new runtime-styled components; ESLint rule enforces it.
// Step 2: migrate leaf components first (Button, Badge, Price) — highest reuse,
//         lowest risk, and each one removed unlocks Server Component usage.
// Step 3: migrate containers.
// Step 4: delete the runtime.

// Before — forces 'use client' everywhere it's used
import styled from 'styled-components';
const Price = styled.span<{ $sale: boolean }>`
  font-variant-numeric: tabular-nums;
  color: ${(p) => (p.$sale ? p.theme.colors.sale : p.theme.colors.text)};
`;

// After — zero runtime, works in a Server Component
export function Price({ sale, children }: { sale?: boolean; children: React.ReactNode }) {
  return (
    <span className={cn('tabular-nums', sale ? 'text-red-600' : 'text-neutral-900')}>
      {children}
    </span>
  );
}

Track the migration with a metric everyone can see: number of files containing 'use client', and the size of the shared client chunk. Both should fall monotonically.

# Put this in CI as a ratchet — the count may go down, never up
rg -l "^'use client'" app components | wc -l

Critical CSS: usually not worth it

Inlining above‑the‑fold CSS and deferring the rest is a classic technique. In a Next.js app it's usually not worth the complexity because route‑level CSS splitting already gets you most of the benefit, and inlined CSS can't be cached across navigations.

Consider it only when: your render‑blocking CSS exceeds ~80 KB after purging and you can't split it further and TTFB is already good (so CSS download is genuinely the bottleneck).

// next.config.ts — Next.js has an experimental inliner (needs the `critters`/`beasties` package)
const config: NextConfig = {
  experimental: {
    optimizeCss: true,   // inlines critical CSS, defers the rest
  },
};

Measure before and after; it can regress repeat visits by making the HTML larger and uncacheable.

What is almost always worth it: purging unused CSS. Tailwind does this by default via content scanning — just make sure your content globs actually cover where classes appear, including any .mdx, CMS‑derived class allowlists, and packages in a monorepo.

// tailwind.config.ts
export default {
  content: [
    './app/**/*.{ts,tsx,mdx}',
    './components/**/*.{ts,tsx}',
    './node_modules/@aurora/ui/dist/**/*.js',   // monorepo package — easy to forget
  ],
  // Classes constructed dynamically from CMS data must be safelisted,
  // or purged away and mysteriously broken in production only
  safelist: [{ pattern: /^(bg|text)-(brand|sale|sold-out)$/ }],
};

The dynamic class trap: className={\text-${color}-600`}cannot be statically detected and will be purged. Always write full class names, or map through an object:const COLORS = { sale: 'text-red-600', new: 'text-green-600' }`.


content-visibility: cheap wins on long pages

content-visibility: auto tells the browser to skip rendering work (style, layout, paint) for off‑screen content until it's near the viewport. On a PLP with 60 product cards or a PDP with a long reviews list, this cuts initial layout time substantially.

.product-card,
.review-item {
  content-visibility: auto;
  /* REQUIRED: an estimate of the element's size, or you get scrollbar jumping
     and CLS as elements are rendered and their real size becomes known */
  contain-intrinsic-size: auto 420px;
}

Rules:

  • Never on above‑the‑fold content — it can delay LCP, because the browser skips rendering the element that would have been your LCP candidate.
  • Always pair with contain-intrinsic-size. Without it, scroll height changes as content renders, producing scrollbar jumps and broken anchor links.
  • Use auto 420px (the auto keyword remembers the real size after first render, which stops the jumping on scroll‑back).
  • Measure. On short pages the overhead isn't worth it; on a 60‑tile grid it's typically −80 to −250 ms of initial layout on mid‑tier mobile.

Related, for components you know are self‑contained:

/* Isolate an element's layout/paint so changes inside it don't invalidate the whole page */
.mini-cart-panel { contain: layout paint; }

This is particularly effective for overlays, drawers, and sticky headers that re‑render often.


Animation performance

Only two properties can be animated entirely on the compositor thread, without touching layout or paint: transform and opacity. Everything else forces work on the main thread, every frame.

/* ❌ Animating `left` triggers layout on every frame → jank, and it competes
      with React rendering for the main thread */
.drawer { transition: left 300ms ease; }
.drawer.open { left: 0; }

/* ✅ transform runs on the compositor */
.drawer {
  transform: translateX(100%);
  transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
  will-change: transform;   /* use sparingly — each hint costs memory */
}
.drawer.open { transform: translateX(0); }
Property Triggers Cost
transform, opacity Composite only 🟢 Cheap
background-color, box-shadow, border-radius, filter Paint 🟠 Moderate
width, height, top, left, margin, padding, font-size Layout + paint 🔴 Expensive

will-change discipline: it promotes an element to its own compositor layer, which costs GPU memory. Applying it to every product card on a PLP can consume hundreds of MB and cause the browser to drop layers on low‑memory devices — making things slower. Apply it just before the animation (on hover/focus) and remove it after.

Also watch for forced synchronous layout ("layout thrashing"), which shows up in traces as a purple "Recalculate Style / Layout" bar inside a scripting task:

// ❌ Read → write → read → write forces layout recalculation on every iteration
items.forEach((el) => {
  const h = el.offsetHeight;         // read (forces layout)
  el.style.height = `${h * 2}px`;    // write (invalidates layout)
});

// ✅ Batch all reads, then all writes
const heights = items.map((el) => el.offsetHeight);   // all reads
items.forEach((el, i) => {                             // all writes
  el.style.height = `${heights[i] * 2}px`;
});

Selector and DOM size

Modern engines handle selectors well; this is rarely your bottleneck. Two things do matter:

DOM size. Style recalculation cost scales with node count. A PLP rendering 200 product cards × 25 nodes each = 5,000 nodes, and every state change in an ancestor re‑evaluates styles across them. Lighthouse warns above ~1,400 nodes. Fix with virtualization (5.4), not with clever selectors.

Expensive selectors in hot paths. :has(), deep descendant combinators, and universal selectors applied to large subtrees can show up in traces. If "Recalculate Style" is a visible cost in your profile, look here — otherwise don't.


Common mistakes

Mistake Cost
One global stylesheet for all page types 100–300 KB render‑blocking on every page
Design‑system barrel imports pulling all CSS Unused CSS everywhere
Runtime CSS‑in‑JS in a design system Forces 'use client'; blocks RSC adoption
Dynamic Tailwind class names Purged in production; works in dev, breaks live
content-visibility above the fold Delays LCP
content-visibility without contain-intrinsic-size Scrollbar jumping, CLS
Animating layout properties Jank; competes with React for the main thread
will-change on hundreds of elements GPU memory exhaustion; slower, not faster
Read/write layout interleaving Forced synchronous layout in every frame

Lab 2.5 — CSS audit

  1. Size: measure render‑blocking CSS per page type with the curl snippet. Compare to the 50 KB budget.
  2. Coverage: DevTools Coverage panel on your PDP. If >60% is unused, find the barrel import responsible.
  3. 'use client' census: run the rg count. Establish it as a ratchet in CI.
  4. Runtime CSS‑in‑JS: if present, measure the injection cost — record a trace during a filter interaction and look for time in the styling library in the Bottom‑Up view.
  5. content-visibility: apply to below‑fold product cards and review items with contain-intrinsic-size. Measure layout time before/after in the Performance panel.
  6. Animations: record a trace during your drawer/modal animation. If you see purple Layout bars per frame, convert to transform.

Checklist

  • Render‑blocking CSS ≤ 50 KB compressed per page type
  • CSS split per route; no barrel imports pulling unused component styles
  • Unused CSS purged; Tailwind content globs cover monorepo packages
  • No dynamically constructed class names (or they're safelisted)
  • Zero runtime CSS‑in‑JS on Server Component paths (or a dated migration plan)
  • content-visibility below the fold only, always with contain-intrinsic-size
  • Animations use transform/opacity only
  • will-change applied on demand, not statically to many elements
  • No read/write layout interleaving in hot code

Next: Module 3 — Rendering architecture