skills/hubeiqiao/skills/nextjs-font-css-variable-collision

nextjs-font-css-variable-collision

Installation
SKILL.md

next/font CSS Variable Name Collision

Problem

When next/font configuration uses the same CSS custom property names as your design system (e.g., both define --font-display), the CSS cascade determines which value wins based on source order in the compiled CSS bundle. This creates a latent bug that can appear or disappear when unrelated changes (like adding new font imports) cause webpack to reorder CSS chunks.

The result: @font-face registers fonts under hashed internal names (e.g., '__Instrument_Serif_315a98'), but the CSS variable resolves to the literal name ('Instrument Serif') which has no matching @font-face rule. The browser falls back to the next font in the stack (e.g., Georgia).

Context / Trigger Conditions

  • Next.js app using next/font/google or next/font/local
  • Design system in globals.css or similar file defines CSS custom properties with the same names as next/font's variable option (e.g., --font-display, --font-body, --font-heading)
  • Fonts suddenly look wrong after adding or removing font imports
  • DevTools Computed tab shows fallback fonts (Georgia, system-ui) instead of the loaded web font
  • Inspecting the <html> element shows CSS variables holding literal font names instead of hashed __FontName_hash values

Root Cause

Both systems try to set the same CSS custom property:

/* next/font generates a class (specificity 0,1,0): */
.__variable_315a98 {
  --font-display: '__Instrument_Serif_315a98', '__Instrument_Serif_Fallback_315a98';
}

/* globals.css :root block (specificity 0,1,0): */
:root {
  --font-display: 'Instrument Serif', Georgia, serif;
}

Both have specificity (0,1,0). The later rule in the CSS bundle wins.

  • If next/font's class appears after :root → hashed name wins (fonts work)
  • If :root appears after next/font's class → literal name wins (fonts break)

Why this is latent: CSS chunk ordering in webpack/turbopack is non-deterministic and changes when you add/remove imports, modify next.config.js, or update dependencies. The collision exists from day one but only manifests when ordering flips.

Solution

Step 1: Give next/font unique variable names

Rename the variable option in each font declaration to a name that won't collide with your design system:

// BEFORE (collision-prone):
const instrumentSerif = Instrument_Serif({
  variable: "--font-display",        // collides with globals.css :root
  ...
});

// AFTER (collision-free):
const instrumentSerif = Instrument_Serif({
  variable: "--font-instrument-serif",  // unique source variable
  ...
});

Naming convention: Use --font-{font-family-name} for the source variable. This describes which font, not what role it plays.

Step 2: Reference source variables in the design system

Update your :root design tokens to consume the source variables via var():

/* BEFORE: */
:root {
  --font-display: 'Instrument Serif', Georgia, serif;
  --font-body: 'Satoshi', 'SF Pro Display', system-ui, sans-serif;
  --font-handwriting: 'Caveat', cursive;
}

/* AFTER: */
:root {
  --font-display: var(--font-instrument-serif), Georgia, serif;
  --font-body: var(--font-plus-jakarta), 'SF Pro Display', system-ui, sans-serif;
  --font-handwriting: var(--font-caveat), cursive;
}

Step 3: Leave all component code unchanged

Components continue using semantic tokens like var(--font-display). The indirection is invisible to consumers. CJK :lang() overrides that set --font-display directly still work because they override the semantic variable, not the source variable.

Architecture: Two-Layer Variable Pattern

Layer 1 (Source):     --font-instrument-serif  ← set by next/font class
                           ↓ var()
Layer 2 (Semantic):   --font-display           ← set by :root, consumed by components
                           ↓ var()
Components:           font-family: var(--font-display)
  • Source variables (--font-instrument-serif) describe WHICH font
  • Semantic variables (--font-display) describe WHAT ROLE the font plays
  • Components only reference semantic variables
  • CJK locale overrides set semantic variables directly

Verification

  1. Build: npm run build — zero errors
  2. DevTools: Inspect <html> element:
    • --font-instrument-serif → hashed name like '__Instrument_Serif_315a98'
    • --font-display → resolves to the hashed name via var()
  3. Computed font: Select a heading → Computed tab → font-family shows the loaded web font, not the fallback
  4. All locales: Verify English, CJK, and other locale pages render correctly

Example

Complete layout.tsx font configuration:

import { Instrument_Serif, Plus_Jakarta_Sans, Caveat } from "next/font/google";

const instrumentSerif = Instrument_Serif({
  subsets: ["latin"],
  weight: ["400"],
  variable: "--font-instrument-serif",  // unique, no collision
  display: "swap",
});

const plusJakarta = Plus_Jakarta_Sans({
  subsets: ["latin"],
  weight: ["400", "500", "600", "700"],
  variable: "--font-plus-jakarta",      // unique, no collision
  display: "swap",
});

const caveat = Caveat({
  subsets: ["latin"],
  weight: ["400", "500", "600", "700"],
  variable: "--font-caveat",            // unique, no collision
  display: "swap",
});

// In JSX — className applies the generated CSS classes:
<html className={`${instrumentSerif.variable} ${plusJakarta.variable} ${caveat.variable}`}>

Notes

  • This pattern also works with next/font/local — same variable option
  • The .style.fontFamily property on font objects is unaffected by the variable rename — it always returns the hashed name and can be used for inline styles
  • If you use Tailwind's fontFamily config to reference CSS variables, ensure it references the semantic variables (e.g., --font-display), not the source variables
  • This is not specific to any font — it can happen with ANY next/font where the variable name matches a CSS custom property in your stylesheets

References

Weekly Installs
1
First Seen
7 days ago