« Back to work

Critical CSS in Vue 3 + Vite, without a headless browser

A small Vite plugin that extracts <style critical> blocks from Vue SFCs and aggregates them into a single inline-able payload. Per-route splitting, scoped-style aware, dev HMR. Drops Speed Index and LCP without adding Chromium to your build.

The problem

Critical CSS is the inline-<style>-in-<head> trick that lets a page paint before its main CSS bundle finishes loading. Done right, it shaves hundreds of milliseconds off LCP and Speed Index. Done wrong, you ship the same CSS twice and add network round-trips you didn't have before.

The standard tooling is some flavor of headless browser: Penthouse boots Chromium, navigates to your URL, samples the viewport, and harvests matching CSS rules. Critters (Next.js, Nuxt, Quasar) does similar work as a build step. Both are accurate. Both add 2 to 15 seconds per page to your build, require Chromium in your CI image, and get hard to debug when they decide silently that some style isn't critical.

What I wanted: mark which styles are critical at the component level, in the same SFC where the markup lives. Let the build extract them, scope them correctly, and inline them. No browser, no per-route configuration.

The fix in 30 seconds

npm i -D vite-plugin-vue-critical-css
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import critical from 'vite-plugin-vue-critical-css'

export default defineConfig({
  plugins: [vue(), critical({ output: 'assets/critical.css' })]
})
<!-- Hero.vue -->
<style scoped>
  /* Async-loaded after first paint. */
  .hero p { color: var(--c-muted); }
</style>

<style scoped critical>
  /* Inlined into <head>. Keep it tight. */
  .hero { font-size: 4rem; line-height: 1; }
</style>

Then in your SSR template:

import critical from 'virtual:critical'
// inline critical.common into <head>

Done. First paint reads the inlined styles, the rest of the bundle async-loads.

How it works

The plugin runs before @vitejs/plugin-vue (enforce: 'pre'), giving it first crack at every .vue file:

  1. Parse the SFC, find any <style critical> blocks.
  2. Compile SCSS / Sass if lang="scss" is set; run optional PostCSS plugins.
  3. Scope the CSS with the right [data-v-xxxx] attribute if scoped is set.
  4. Strip the block from the source so Vue's normal pipeline doesn't double-process it.
  5. Aggregate all extracted CSS into a virtual module the SSR template imports.

The virtual module has two shapes: content in dev (inline strings) and a manifest in prod (filenames pointing at emitted assets). The SSR template branches on manifest.type and inlines either the content directly or the file's contents read from disk.

The three gotchas worth knowing

Three things bit me while shipping this. They're all handled inside the plugin, but worth knowing because they tell you something about where the abstraction's real cost is.

1. Production scope ID drift

The plugin needs to scope critical CSS to the right [data-v-xxxx] attribute, but it runs before Vite has assigned final scope IDs. So it predicts the ID based on the file path, the same hash Vite uses internally. In dev that prediction always matches. In production, with chunking and asset-graph variations, Vite occasionally derives a different ID.

When that happens, the inlined critical CSS scopes to the wrong attribute and silently doesn't apply. Fixing it required a reconciliation pass at generateBundle(): inject a tiny marker rule into the SFC source during transform, find that marker in the bundled output to read off the actual scope ID, and rewrite all critical CSS rules to swap guessed for actual. Transparent to the user; required to ship reliably.

2. The @import URL trap with global SCSS

Most SCSS setups have a globalScssImports list: variables and mixins prepended to every <style lang="scss"> block. I added the same option here, then accidentally let it apply to plain-CSS <style critical> blocks too.

Plain CSS with a leading @import 'foo.scss' works during dev (Vite's CSS pipeline is lenient). In a production build, CleanCSS at level 2 tries to inline that @import URL, fails to resolve it, and can drop or mangle the surrounding stylesheet. Worst kind of bug: silent in dev, breaks prod, blames CleanCSS.

The fix is a one-liner: only apply globalScssImports if block.lang === 'scss' || block.lang === 'sass'. Plain-CSS blocks pass through clean.

3. The manifest placeholder's quote style

In production the plugin emits a placeholder string (__VITE_VUE_CRITICAL_CSS_MANIFEST_PLACEHOLDER__) from load(), then replaces it with the real manifest JSON at generateBundle(). The placeholder is emitted as a JavaScript string literal. esbuild's minifier sometimes converts that double-quoted literal to a single-quoted one when minifying chunks.

The original implementation used a regex matching only double-quoted placeholders. When the minifier picked single quotes, the regex missed, the wrapped string survived, and the manifest object literal got wedged inside the surviving single quotes:

// What I wanted in the bundle:
export default { type: 'manifest', common: '...', routes: { ... } }

// What I got after a quote-style mismatch:
export default '{"type":"manifest","common":"...","routes":{...}}'

The client's runtime then held a JSON string instead of the parsed manifest object. Every type check fell through. No critical CSS loaded on client-side navigation. Dev mode bypasses this code path entirely (inline content), which is why the bug only showed up in prod.

The fix is a single regex with a quote-backreference: /(["'])__VITE_VUE_CRITICAL_CSS_MANIFEST_PLACEHOLDER__\1/g. Matches either quote style atomically; produces valid JS in both cases.

Where it fits, and where it doesn't

Reach for this if:

  • You're on Vue 3 + Vite (works with Quasar SSR, vite-ssr, custom setups)
  • You want manual control over what's critical (above-the-fold layout, fonts, theme tokens)
  • You don't want a headless browser in your build pipeline
  • Your team is comfortable authoring critical declarations next to component CSS

Skip it if:

  • You'd rather have automation decide what's critical (use Critters)
  • You're not on Vue (no SFC parsing means no scope-aware extraction)
  • Your critical bundle would be larger than 10 to 15 KB (at that size you're past the TCP slow-start window and inlining isn't winning anymore)

Wrap-up

Built and battle-tested on a custom Vue 3 + Vite SSR project, then polished into a standalone package so other teams reaching for the same trick don't have to rediscover the gotchas above. If you find a selector form the scoper doesn't handle, file an issue: the test surface for Vue's CSS scoping language is bigger than it looks, and I'd rather hear about edge cases than silently miss them.