Skip to content

Fix: Vanilla Extract Not Working — Styles Not Applied, TypeScript Errors, or Build Failing

FixDevs ·

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 styles

Or 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:

  • .css.ts files are mandatory — only files with the .css.ts extension are processed by the Vanilla Extract compiler. Writing styles in a regular .ts file doesn’t work because the build plugin doesn’t process it.
  • The bundler plugin must be installed — Vanilla Extract requires a framework-specific plugin (Vite, webpack, esbuild, Next.js). Without it, .css.ts imports fail because the bundler doesn’t know how to handle them.
  • Styles are evaluated at build time — everything in a .css.ts file runs during the build, not in the browser. You can’t reference runtime values (React state, props) inside style(). Dynamic styling requires styleVariants, recipes, or assignInlineVars.
  • Import paths use .css not .css.ts — when importing in your component, use import { button } from './styles.css' (without .ts). The build plugin handles the resolution.

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   # esbuild

Vite:

// 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" />

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`, { … })`.

For related CSS-in-JS issues, see Fix: styled-components Not Working and Fix: Tailwind CSS Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles