Fix: Vanilla Extract Not Working — Styles Not Applied, TypeScript Errors, or Build Failing
Part of: React & Frontend Errors
Quick Answer
How to fix Vanilla Extract issues — .css.ts file setup, style and recipe APIs, sprinkles for utility classes, theme tokens, dynamic styles, and integration with Next.js, Vite, and Remix.
The Problem
Styles defined in a .css.ts file don’t appear on the page:
// styles.css.ts
import { style } from '@vanilla-extract/css';
export const button = style({
backgroundColor: 'blue',
color: 'white',
padding: '12px 24px',
});// Button.tsx
import { button } from './styles.css';
function Button() {
return <button className={button}>Click me</button>;
}
// Button renders but has no stylesOr the import fails:
Module not found: Can't resolve './styles.css'Or TypeScript throws errors on style definitions:
Type '{ backgroundColor: string; }' is not assignable to type 'StyleRule'Why This Happens
Vanilla Extract processes .css.ts files at build time and outputs standard CSS. The style functions are replaced by class name strings at runtime, which means every constraint comes from the bundler step rather than the browser.
The .css.ts extension is the trigger that tells the framework plugin to evaluate the file at build time. Only files with that exact extension are processed. Writing styles in a regular .ts file looks identical in the editor but does not work because the plugin never sees it, and the imported style references end up as undefined at runtime. The bundler plugin itself is also mandatory — Vanilla Extract requires a framework-specific plugin for Vite, Webpack, Next.js, esbuild, Astro, or Remix. Without the matching plugin, .css.ts imports fail because the bundler does not know how to handle them and treats them as ordinary TypeScript.
Because evaluation happens at build time, everything inside a .css.ts file runs in Node — not in the browser. You cannot reference React state, props, useEffect values, or anything else that exists only at runtime inside style(). Dynamic styling has to go through styleVariants, recipes, or assignInlineVars so the build can still produce static class names while runtime values flow through CSS custom properties. The last common surprise is the import path: when importing in your component you use import { button } from './styles.css' (without .ts). The build plugin rewrites the resolution from .css to the matching .css.ts source file, which is why dropping the extension entirely fails too.
Platform and Environment Differences
Vanilla Extract behaves the same conceptually on every bundler, but the integration surface is where most issues come from. On Vite, the plugin is the cleanest path because Vanilla Extract was designed around Vite’s transform pipeline — dev mode injects styles via <style> tags and production extracts to CSS files with no extra config. On Webpack, the plugin emits real CSS files mid-pipeline, so it must run before any css-loader chain that processes the output. Get the order wrong and you see “Unexpected token” errors on the emitted CSS.
On Next.js App Router, support landed via @vanilla-extract/next-plugin, but the App Router treats CSS imports differently in Server Components versus Client Components. Class hashes are identical across both bundles only when the build runs from the same absolute path. Inside Astro, no first-party integration exists — you piggyback on the Vite plugin via astro.config.mjs. SSR class extraction works because Astro emits CSS into the route bundle, but you must add the plugin to the Vitest config separately if you test components. On Remix Vite, the same Vite plugin works, but the classic Remix compiler had a dedicated package that no longer ships, so legacy projects must migrate before adopting Vanilla Extract.
Monorepos add another layer. In Turborepo or pnpm workspace setups, shared .css.ts files in a package must be processed by the consumer’s bundler — pre-building to dist/ and importing the pre-built copy produces double styles and divergent class hashes. The safe pattern is to keep shared style files as raw TypeScript that the consumer transforms at build time.
Fix 1: Set Up Vanilla Extract
npm install @vanilla-extract/css
# Framework plugins — install ONE
npm install -D @vanilla-extract/vite-plugin # Vite
npm install -D @vanilla-extract/next-plugin # Next.js
npm install -D @vanilla-extract/webpack-plugin # Webpack
npm install -D @vanilla-extract/esbuild-plugin # esbuildVite:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
export default defineConfig({
plugins: [react(), vanillaExtractPlugin()],
});Next.js:
// next.config.mjs
import { createVanillaExtractPlugin } from '@vanilla-extract/next-plugin';
const withVanillaExtract = createVanillaExtractPlugin();
export default withVanillaExtract({
// Next.js config
});// styles/button.css.ts — define styles
import { style, globalStyle } from '@vanilla-extract/css';
export const container = style({
maxWidth: '1200px',
margin: '0 auto',
padding: '0 16px',
});
export const button = style({
backgroundColor: '#3b82f6',
color: 'white',
padding: '12px 24px',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
fontSize: '16px',
fontWeight: 600,
transition: 'background-color 0.2s',
':hover': {
backgroundColor: '#2563eb',
},
':active': {
backgroundColor: '#1d4ed8',
},
':disabled': {
opacity: 0.5,
cursor: 'not-allowed',
},
});
// Responsive styles — use @media
export const card = style({
padding: '16px',
'@media': {
'(min-width: 768px)': {
padding: '24px',
},
'(min-width: 1024px)': {
padding: '32px',
},
},
});
// Global styles (use sparingly)
globalStyle('html, body', {
margin: 0,
padding: 0,
fontFamily: 'Inter, sans-serif',
});
globalStyle(`${container} > *`, {
marginBottom: '16px',
});// Button.tsx — import with .css extension (not .css.ts)
import { button } from './styles/button.css';
function Button({ children }: { children: React.ReactNode }) {
return <button className={button}>{children}</button>;
}Fix 2: Variants with styleVariants and Recipes
// styles/button.css.ts
import { style, styleVariants } from '@vanilla-extract/css';
// Base style
const buttonBase = style({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '8px',
fontWeight: 600,
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
});
// styleVariants — map of variant name to additional styles
export const buttonVariant = styleVariants({
primary: [buttonBase, { backgroundColor: '#3b82f6', color: 'white' }],
secondary: [buttonBase, { backgroundColor: '#e5e7eb', color: '#374151' }],
danger: [buttonBase, { backgroundColor: '#ef4444', color: 'white' }],
ghost: [buttonBase, { backgroundColor: 'transparent', color: '#3b82f6' }],
});
export const buttonSize = styleVariants({
sm: { padding: '6px 12px', fontSize: '14px' },
md: { padding: '10px 20px', fontSize: '16px' },
lg: { padding: '14px 28px', fontSize: '18px' },
});// Button.tsx
import { buttonVariant, buttonSize } from './styles/button.css';
import clsx from 'clsx';
type ButtonProps = {
variant?: keyof typeof buttonVariant;
size?: keyof typeof buttonSize;
children: React.ReactNode;
};
function Button({ variant = 'primary', size = 'md', children }: ButtonProps) {
return (
<button className={clsx(buttonVariant[variant], buttonSize[size])}>
{children}
</button>
);
}Recipes — multi-variant component API (like CVA):
npm install @vanilla-extract/recipes// styles/button.css.ts
import { recipe, type RecipeVariants } from '@vanilla-extract/recipes';
export const button = recipe({
base: {
display: 'inline-flex',
alignItems: 'center',
borderRadius: '8px',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.2s',
},
variants: {
variant: {
primary: { backgroundColor: '#3b82f6', color: 'white' },
secondary: { backgroundColor: '#e5e7eb', color: '#374151' },
ghost: { backgroundColor: 'transparent', color: '#3b82f6' },
},
size: {
sm: { padding: '6px 12px', fontSize: '14px' },
md: { padding: '10px 20px', fontSize: '16px' },
lg: { padding: '14px 28px', fontSize: '18px' },
},
rounded: {
true: { borderRadius: '9999px' },
},
},
compoundVariants: [
{
variants: { variant: 'primary', size: 'lg' },
style: { boxShadow: '0 4px 6px rgba(59, 130, 246, 0.3)' },
},
],
defaultVariants: {
variant: 'primary',
size: 'md',
},
});
export type ButtonVariants = RecipeVariants<typeof button>;// Button.tsx
import { button, type ButtonVariants } from './styles/button.css';
type ButtonProps = ButtonVariants & {
children: React.ReactNode;
};
function Button({ variant, size, rounded, children }: ButtonProps) {
return (
<button className={button({ variant, size, rounded })}>
{children}
</button>
);
}
// Usage
<Button variant="primary" size="lg" rounded>
Submit
</Button>Fix 3: Theme Tokens with createTheme
// styles/theme.css.ts
import { createTheme, createThemeContract } from '@vanilla-extract/css';
// Define the shape of your theme (contract)
export const vars = createThemeContract({
color: {
brand: null,
text: null,
textMuted: null,
bg: null,
bgSurface: null,
border: null,
},
space: {
sm: null,
md: null,
lg: null,
xl: null,
},
font: {
body: null,
heading: null,
},
radius: {
sm: null,
md: null,
lg: null,
},
});
// Light theme — fills in the contract values
export const lightTheme = createTheme(vars, {
color: {
brand: '#3b82f6',
text: '#111827',
textMuted: '#6b7280',
bg: '#ffffff',
bgSurface: '#f9fafb',
border: '#e5e7eb',
},
space: { sm: '8px', md: '16px', lg: '24px', xl: '48px' },
font: { body: 'Inter, sans-serif', heading: 'Cal Sans, sans-serif' },
radius: { sm: '4px', md: '8px', lg: '16px' },
});
// Dark theme — same contract, different values
export const darkTheme = createTheme(vars, {
color: {
brand: '#60a5fa',
text: '#f9fafb',
textMuted: '#9ca3af',
bg: '#111827',
bgSurface: '#1f2937',
border: '#374151',
},
space: { sm: '8px', md: '16px', lg: '24px', xl: '48px' },
font: { body: 'Inter, sans-serif', heading: 'Cal Sans, sans-serif' },
radius: { sm: '4px', md: '8px', lg: '16px' },
});// styles/components.css.ts — use theme tokens
import { style } from '@vanilla-extract/css';
import { vars } from './theme.css';
export const card = style({
backgroundColor: vars.color.bgSurface,
border: `1px solid ${vars.color.border}`,
borderRadius: vars.radius.md,
padding: vars.space.lg,
color: vars.color.text,
});
export const heading = style({
fontFamily: vars.font.heading,
color: vars.color.text,
marginBottom: vars.space.md,
});// App.tsx — apply theme class to root
import { lightTheme, darkTheme } from './styles/theme.css';
function App() {
const [isDark, setIsDark] = useState(false);
return (
<div className={isDark ? darkTheme : lightTheme}>
{/* All children use the theme tokens */}
</div>
);
}Fix 4: Sprinkles — Utility-First Styling
npm install @vanilla-extract/sprinkles// styles/sprinkles.css.ts
import { defineProperties, createSprinkles } from '@vanilla-extract/sprinkles';
const responsiveProperties = defineProperties({
conditions: {
mobile: {},
tablet: { '@media': '(min-width: 768px)' },
desktop: { '@media': '(min-width: 1024px)' },
},
defaultCondition: 'mobile',
properties: {
display: ['none', 'flex', 'block', 'grid', 'inline-flex'],
flexDirection: ['row', 'column'],
alignItems: ['stretch', 'center', 'flex-start', 'flex-end'],
justifyContent: ['flex-start', 'center', 'flex-end', 'space-between'],
gap: { 0: '0', 1: '4px', 2: '8px', 3: '12px', 4: '16px', 6: '24px', 8: '32px' },
padding: { 0: '0', 1: '4px', 2: '8px', 3: '12px', 4: '16px', 6: '24px', 8: '32px' },
margin: { 0: '0', auto: 'auto', 1: '4px', 2: '8px', 4: '16px' },
width: { full: '100%', auto: 'auto', '1/2': '50%', '1/3': '33.333%' },
fontSize: { sm: '14px', md: '16px', lg: '18px', xl: '20px', '2xl': '24px' },
fontWeight: { normal: '400', medium: '500', semibold: '600', bold: '700' },
borderRadius: { none: '0', sm: '4px', md: '8px', lg: '16px', full: '9999px' },
},
shorthands: {
p: ['padding'],
m: ['margin'],
w: ['width'],
},
});
const colorProperties = defineProperties({
conditions: {
light: {},
dark: { '@media': '(prefers-color-scheme: dark)' },
},
defaultCondition: 'light',
properties: {
color: {
text: '#111827',
muted: '#6b7280',
brand: '#3b82f6',
white: '#ffffff',
danger: '#ef4444',
},
backgroundColor: {
white: '#ffffff',
surface: '#f9fafb',
brand: '#3b82f6',
danger: '#ef4444',
transparent: 'transparent',
},
},
});
export const sprinkles = createSprinkles(responsiveProperties, colorProperties);
export type Sprinkles = Parameters<typeof sprinkles>[0];// Usage — Tailwind-like utility classes, but type-safe
import { sprinkles } from './styles/sprinkles.css';
function Header() {
return (
<header
className={sprinkles({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 4,
backgroundColor: 'white',
})}
>
<h1 className={sprinkles({ fontSize: { mobile: 'lg', desktop: '2xl' }, fontWeight: 'bold' })}>
Logo
</h1>
</header>
);
}Fix 5: Dynamic Styles with CSS Variables
// styles/dynamic.css.ts
import { style, createVar, fallbackVar } from '@vanilla-extract/css';
// Define a CSS custom property
export const accentColor = createVar();
export const progressWidth = createVar();
export const progressBar = style({
height: '8px',
borderRadius: '4px',
backgroundColor: '#e5e7eb',
overflow: 'hidden',
'::after': {
content: '""',
display: 'block',
height: '100%',
width: progressWidth, // Dynamic value
backgroundColor: fallbackVar(accentColor, '#3b82f6'), // With fallback
transition: 'width 0.3s ease',
},
});// ProgressBar.tsx
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { progressBar, accentColor, progressWidth } from './styles/dynamic.css';
function ProgressBar({ percent, color }: { percent: number; color?: string }) {
return (
<div
className={progressBar}
style={assignInlineVars({
[progressWidth]: `${percent}%`,
[accentColor]: color ?? '#3b82f6',
})}
/>
);
}
// Usage — truly dynamic values at runtime
<ProgressBar percent={75} color="#10b981" />Fix 6: Platform-Specific Setup — Vite, Webpack, Next.js, Astro, Remix
Vanilla Extract ships a different plugin per bundler, and the gotchas differ in each environment. Pick the row that matches your stack before debugging anything else.
Vite (the simplest case). @vanilla-extract/vite-plugin plugs straight into the dev server. Styles are injected via <style> tags in dev and extracted to CSS files in production. The only common gotcha is plugin order in Vitest or Storybook configs that share vite.config.ts — the VE plugin must be present in those test/preview contexts too, otherwise .css.ts imports fall back to TypeScript and produce undefined exports.
Webpack (Create React App ejected, RSPack). Use @vanilla-extract/webpack-plugin. The plugin must run before any CSS loader in the chain because it emits real .css files that the loaders then process. If you see “Module parse failed: Unexpected token” on the generated CSS, your css-loader/MiniCssExtractPlugin chain is not picking up the emitted file — add it to the test pattern explicitly.
Next.js App Router. @vanilla-extract/next-plugin is required and configured by wrapping the Next config. Server Components can import .css.ts directly; class names are stable across server and client because the build-time hash is the same in both bundles. The App Router caveat is that Vanilla Extract is still officially marked experimental for the App Router in older Next versions — if styles vanish only in production, check that your Next version supports the plugin and that you are not also using next/font with a conflicting CSS-in-JS adapter. For Pages Router the plugin is stable and works out of the box.
Astro. No first-party plugin exists, but the Vite plugin works because Astro is Vite-based. Add vanillaExtractPlugin() to the vite.plugins array in astro.config.mjs. Styles applied in .astro files via class={button} work; styles applied inside a React island work because the island carries its own bundle. For setup details that affect SSR class extraction, see Fix: Astro Content Collections Not Working.
Remix. Use the Vite plugin (Remix Vite is now the default). The classic Remix compiler version had a dedicated integration that no longer ships. If you are still on the classic compiler, migrate to Remix Vite before adding Vanilla Extract. Loader/action code is not affected — styles compile into route CSS bundles that Remix’s links() function picks up automatically. See Fix: Remix Not Working for general Remix Vite migration steps.
Monorepo aliases. In a Turborepo or pnpm workspace, the .css.ts files in a shared package must be processed by the consumer’s bundler, not pre-built. Either keep the package as raw TypeScript (no dist/) or ship pre-extracted CSS plus a stable class name contract. Mixing the two — pre-extracted CSS in the shared package and .css.ts imports in the app — produces double styles and version skew on the class hashes.
// astro.config.mjs — Vanilla Extract in Astro via the Vite plugin
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
export default defineConfig({
integrations: [react()],
vite: {
plugins: [vanillaExtractPlugin()],
},
});Still Not Working?
Cannot find module './styles.css' — check that you have the correct bundler plugin installed and configured. Vanilla Extract needs @vanilla-extract/vite-plugin for Vite, @vanilla-extract/next-plugin for Next.js, etc. Also verify the import path uses .css not .css.ts.
Styles exist but aren’t visible — Vanilla Extract generates class names but the CSS must be injected into the page. In Vite dev mode, this happens automatically via <style> tags. In production, CSS is extracted to files. Check that your bundler config doesn’t exclude .css files from processing.
TypeError: style is not a function at build time — your .css.ts file is being imported as a regular module instead of being processed by Vanilla Extract. This usually means the plugin isn’t running. Check plugin order in your config — some frameworks need the VE plugin before other plugins.
Styles bleed between components — Vanilla Extract scopes all style() calls by default (unique class names). If styles are leaking, you probably have globalStyle() calls that are too broad. Scope global styles by combining them with a parent selector: globalStyle(\${container} p`, { … })`.
Class names differ between SSR and the client — Vanilla Extract hashes class names from the file path and identifier. If your server and client builds use different working directories (common in Docker multi-stage builds and pnpm symlinks), the hashes diverge and React hydration mismatches. Pin the working directory by building from the same absolute path, or set identifiers: 'short' in the plugin options so the hash depends only on local identifiers.
Monorepo: changing a shared .css.ts file does not update consumers in dev — the consuming app is reading a pre-built dist/ copy of the package. Either delete the dist/ and source-link the package, or rebuild on every change. For pnpm workspaces and Turborepo pipelines that orchestrate this correctly, see Fix: pnpm Workspace Not Working and Fix: Turborepo Not Working.
Next.js App Router: styles flash unstyled then re-render styled — the App Router emits a <link rel="stylesheet"> for the page CSS chunk, but during streaming the head can render before the chunk URL is known. Either render critical styles via globalStyle so they ship in the initial HTML, or move the affected components into a non-streamed boundary.
Recipes regenerate class names on every save in dev — this is normal but breaks visual regression tests that hash class names. Pin identifiers: 'short' (Vite plugin option) or identifiers: 'debug' so snapshots stay stable across machines.
Sprinkles output explodes the CSS bundle past 200KB — every combination of property × condition × variant is generated at build time. Reduce the surface area by removing rarely-used conditions or splitting sprinkles into multiple createSprinkles calls per route group.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Mantine Not Working — Styles Not Loading, Theme Not Applying, or Components Broken After Upgrade
How to fix Mantine UI issues — MantineProvider setup, PostCSS configuration, theme customization, dark mode, form validation with useForm, and Next.js App Router integration.
Fix: Panda CSS Not Working — Styles Not Applying, Tokens Not Resolving, or Build Errors
How to fix Panda CSS issues — PostCSS setup, panda.config.ts token system, recipe and pattern definitions, conditional styles, responsive design, and integration with Next.js and Vite.
Fix: UnoCSS Not Working — Classes Not Generating, Presets Missing, or Attributify Mode Broken
How to fix UnoCSS issues — Vite plugin setup, preset configuration, attributify mode, icons preset, shortcuts, custom rules, and integration with Next.js, Nuxt, and Astro.
Fix: CSS Container Query Not Working — @container and container-type Issues
How to fix CSS container queries not working — setting container-type correctly, understanding containment scope, fixing @container syntax, and handling browser support and specificity issues.