Skip to main content
{/}codefunc
CSS & DesignJuly 8, 2026 · 9 min read

Vanilla Extract: Zero-Runtime CSS-in-TypeScript

A practical guide to Vanilla Extract — type-safe styles at build time, theming with CSS variables, recipes and sprinkles, and when to pick it over Tailwind or CSS Modules.

By codefunc

vanilla-extractcsstypescriptcss-modulesstylingfrontendtheming

Styled Components and Emotion solved co-location and theming, but they ship a runtime CSS engine to the browser. CSS Modules scope class names without runtime cost, but theming and variants get awkward fast. Vanilla Extract sits in the middle: you write styles in TypeScript, get type checking and autocomplete, and the build step emits plain static CSS — nothing executes in the browser.

Bottom line: Use Vanilla Extract when you want CSS Modules–style output with TypeScript safety, first-class theming, and zero runtime overhead. It fits design-system work, component libraries, and apps where bundle size and Server Component compatibility matter. Skip it for quick prototypes (Tailwind is faster to start) or when your team does not use TypeScript.

What Vanilla Extract actually does

Vanilla Extract evaluates .css.ts files at build time. The TypeScript in those files never ships to users. You export class names, theme tokens, and variables; the bundler plugin turns them into scoped CSS files.

// button.css.ts
import { style } from '@vanilla-extract/css';

export const button = style({
  padding: '0.5rem 1rem',
  borderRadius: '0.5rem',
  fontWeight: 500,
  border: 'none',
  cursor: 'pointer',
});
// Button.tsx
import { button } from './button.css.ts';

export function Button({ children }: { children: React.ReactNode }) {
  return <button className={button}>{children}</button>;
}

Generated CSS (simplified):

.button_button__x7f3k {
  padding: 0.5rem 1rem;
  border-radius: 0.5rem;
  font-weight: 500;
  border: none;
  cursor: pointer;
}

The mental model: TypeScript is your preprocessor, not your runtime. Think Sass with types, not styled-components without the engine.

Quick comparison

Feature Vanilla Extract CSS Modules Styled Components Tailwind
Runtime cost None None Yes (~10–15KB) None
Type safety Yes (CSSType) No Partial With plugins
Theming First-class Manual (CSS vars) Built-in Config tokens
Dynamic styles Limited (recipes, vars) No Yes Limited
Server Components Yes Yes Partial Yes
Learning curve Medium Low Medium Medium
Build tooling required Yes Minimal Minimal PostCSS

Setup

Install the core package and a bundler integration:

npm install @vanilla-extract/css

Vite

npm install -D @vanilla-extract/vite-plugin
// 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

npm install -D @vanilla-extract/next-plugin
// next.config.ts
import { createVanillaExtractPlugin } from '@vanilla-extract/next-plugin';

const withVanillaExtract = createVanillaExtractPlugin();

export default withVanillaExtract({
  // your Next.js config
});

Webpack

npm install -D @vanilla-extract/webpack-plugin
// webpack.config.js
const { VanillaExtractPlugin } = require('@vanilla-extract/webpack-plugin');

module.exports = {
  plugins: [new VanillaExtractPlugin()],
};

Official integrations also exist for esbuild, Parcel, Rollup, and Gatsby. Pick the one that matches your bundler — the API in your .css.ts files stays the same.

Core APIs

style — single class

The workhorse. Accepts any valid CSS property with TypeScript autocomplete via CSSType.

import { style } from '@vanilla-extract/css';

export const card = style({
  padding: '1rem',
  borderRadius: '0.5rem',
  boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
  backgroundColor: 'white',

  ':hover': {
    boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
  },

  '@media': {
    '(min-width: 768px)': {
      padding: '1.5rem',
    },
  },
});

Pseudo-classes, media queries, container queries, and nested selectors all work — it is still CSS under the hood.

styleVariants — named variants

Avoid string-concatenation for button sizes, alert types, or any prop-driven class lookup.

import { styleVariants } from '@vanilla-extract/css';

const base = {
  padding: '0.5rem 1rem',
  borderRadius: '0.5rem',
  fontWeight: 500,
};

export const button = styleVariants({
  primary: {
    ...base,
    backgroundColor: '#2563eb',
    color: 'white',
  },
  secondary: {
    ...base,
    backgroundColor: '#e5e7eb',
    color: '#111827',
  },
  danger: {
    ...base,
    backgroundColor: '#dc2626',
    color: 'white',
  },
});
<button className={button.primary}>Save</button>
<button className={button.danger}>Delete</button>

TypeScript knows the keys — typos fail at compile time, not in production.

globalStyle — unscoped rules

For resets, typography defaults, or third-party overrides:

import { globalStyle } from '@vanilla-extract/css';

globalStyle('body', {
  margin: 0,
  fontFamily: 'system-ui, sans-serif',
  lineHeight: 1.5,
});

globalStyle('*, *::before, *::after', {
  boxSizing: 'border-box',
});

keyframes and fontFace

Scoped animation names and font declarations, exported like any other style:

import { keyframes, style } from '@vanilla-extract/css';

const fadeIn = keyframes({
  '0%': { opacity: 0, transform: 'translateY(8px)' },
  '100%': { opacity: 1, transform: 'translateY(0)' },
});

export const animated = style({
  animation: `${fadeIn} 300ms ease-out`,
});

Theming with createTheme

This is where Vanilla Extract pulls ahead of plain CSS Modules. createTheme generates a scoped class and a matching set of CSS custom properties with a type-safe contract.

// theme.css.ts
import { createTheme } from '@vanilla-extract/css';

export const [themeClass, vars] = createTheme({
  color: {
    background: '#ffffff',
    foreground: '#111827',
    primary: '#2563eb',
    primaryForeground: '#ffffff',
  },
  space: {
    sm: '0.5rem',
    md: '1rem',
    lg: '1.5rem',
  },
  radius: {
    md: '0.5rem',
  },
});
// button.css.ts
import { style } from '@vanilla-extract/css';
import { vars } from './theme.css.ts';

export const button = style({
  padding: `${vars.space.sm} ${vars.space.md}`,
  borderRadius: vars.radius.md,
  backgroundColor: vars.color.primary,
  color: vars.color.primaryForeground,
});
// App.tsx
import { themeClass } from './theme.css.ts';

export function App({ children }) {
  return <div className={themeClass}>{children}</div>;
}

Multiple themes

Swap themes by applying a different theme class — no runtime style injection:

import { createTheme, createThemeContract } from '@vanilla-extract/css';

export const vars = createThemeContract({
  color: {
    background: null,
    foreground: null,
    primary: null,
  },
});

export const lightTheme = createTheme(vars, {
  color: {
    background: '#ffffff',
    foreground: '#111827',
    primary: '#2563eb',
  },
});

export const darkTheme = createTheme(vars, {
  color: {
    background: '#111827',
    foreground: '#f9fafb',
    primary: '#3b82f6',
  },
});
<div className={isDark ? darkTheme : lightTheme}>
  <Content />
</div>

Components reference vars.color.primary — the value resolves from whichever theme class is active on an ancestor.

Recipes and Sprinkles

Vanilla Extract ships higher-level packages for common patterns.

Recipes — multi-variant components

When a component has several independent variant axes (size × color × outline):

import { recipe } from '@vanilla-extract/recipes';

export const button = recipe({
  base: {
    borderRadius: '0.5rem',
    fontWeight: 500,
    cursor: 'pointer',
  },
  variants: {
    size: {
      sm: { padding: '0.25rem 0.75rem', fontSize: '0.875rem' },
      md: { padding: '0.5rem 1rem', fontSize: '1rem' },
      lg: { padding: '0.75rem 1.5rem', fontSize: '1.125rem' },
    },
    intent: {
      primary: { backgroundColor: '#2563eb', color: 'white' },
      ghost: { backgroundColor: 'transparent', color: '#2563eb' },
    },
  },
  defaultVariants: {
    size: 'md',
    intent: 'primary',
  },
});
<button className={button({ size: 'lg', intent: 'ghost' })}>Cancel</button>

Sprinkles — atomic utility classes

Sprinkles is Vanilla Extract's answer to Tailwind: responsive, conditional utility classes generated at build time with zero runtime.

import { defineProperties, createSprinkles } from '@vanilla-extract/sprinkles';

const responsiveProperties = defineProperties({
  properties: {
    display: ['none', 'flex', 'block'],
    paddingTop: ['0', '4px', '8px', '16px'],
    gap: ['0', '4px', '8px', '16px'],
  },
  shorthands: {
    paddingY: ['paddingTop', 'paddingBottom'],
  },
});

export const sprinkles = createSprinkles(responsiveProperties);

Use Sprinkles for layout primitives; use style and recipe for component-specific styles. Many teams combine both.

Dynamic styles — what works and what does not

Vanilla Extract is build-time first. You cannot do style({ width: props.width }) with arbitrary runtime values the way Styled Components allows.

Works at build time:

  • styleVariants and recipe for known variant sets
  • CSS variables for values that change at runtime (set via inline style or a parent theme class)
  • assignInlineVars for scoped variable overrides on a single element
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { vars } from './theme.css.ts';

// In component:
<div
  className={progressBar}
  style={assignInlineVars({
    [vars.color.primary]: customColor,
  })}
/>

Does not work:

  • Generating entirely new style rules from arbitrary user input at runtime
  • Prop-driven styles where the value space is unbounded (e.g. any hex color from a color picker)

For those cases, inline styles or a small runtime layer (@vanilla-extract/dynamic) are the escape hatches. Plan your token contracts upfront.

Vanilla Extract vs the alternatives

vs CSS Modules

CSS Modules give you scoping. Vanilla Extract gives you scoping plus typed themes, variants, and keyframes — without maintaining parallel .module.css files. The tradeoff is build plugin setup and a .css.ts convention your team must adopt.

vs Styled Components / Emotion

Runtime CSS-in-JS libraries excel at arbitrary dynamic styles. Vanilla Extract wins on bundle size, SSR/RSC compatibility, and performance — there is no style recalculation on re-render. If your theming is token-based rather than prop-per-style, Vanilla Extract is usually the better fit in 2026.

vs Tailwind

Tailwind optimizes for speed of iteration in markup. Vanilla Extract optimizes for design-system rigor in TypeScript. Tailwind's class strings can grow long; Vanilla Extract keeps JSX clean with semantic class exports. Some teams use Sprinkles for utilities and recipes for components — a hybrid that stays zero-runtime.

Project structure

A typical layout:

src/
  styles/
    theme.css.ts        # tokens and theme classes
    reset.css.ts        # globalStyle rules
    sprinkles.css.ts    # utility definitions
  components/
    Button/
      Button.tsx
      button.css.ts     # component styles
    Card/
      Card.tsx
      card.css.ts

Rules that keep things maintainable:

  • One .css.ts file per component (or feature folder)
  • Centralize tokens in theme.css.ts — never hardcode colors in component styles
  • Use recipe when you have more than two variant dimensions
  • Keep .css.ts files free of React imports — styles are a separate layer

Debugging and tooling

Build errors usually mean invalid CSS values — TypeScript catches most of these, but shorthand mistakes can slip through.

Finding generated class names: Inspect the element in DevTools. Scoped names look like button_primary__abc123.

Dev mode: Vanilla Extract supports a runtime dev version for faster feedback in tests. Production builds always emit static CSS.

For inspecting output CSS before deploy, run your production build and check the emitted stylesheets — or paste chunks into the CSS Formatter to read them more easily.

Decision guide

Need arbitrary runtime styles from props?
├── Yes → Styled Components / Emotion, or inline styles
└── No → Want utility-first markup speed?
    ├── Yes → Tailwind (or Sprinkles on top of Vanilla Extract)
    └── No → Need typed themes and zero runtime?
        ├── Yes → Vanilla Extract
        └── No → CSS Modules

Use these codefunc tools alongside this guide:

Practical takeaway

Vanilla Extract is the strongest option when you want type-safe, token-driven styles without shipping a CSS runtime. Set up the bundler plugin once, define your theme contract, and build components with style, styleVariants, or recipe. Use CSS variables and theme classes for dark mode and brand switching. Reach for Sprinkles when you miss utility classes. Skip it if your team is not on TypeScript or you need fully dynamic per-prop styling.

Sources

Try it on codefunc

Related articles

3

Search tools

Find a developer tool