Skip to main content
Technology & EngineeringCss Styling Services353 lines

Vanilla Extract

"vanilla-extract: zero-runtime CSS-in-TypeScript, sprinkles (utility classes), recipes (variants), themes, type-safe styles"

Quick Summary25 lines
vanilla-extract is a zero-runtime CSS-in-TypeScript library. All styles are written in TypeScript files (`.css.ts`), evaluated at build time, and emitted as static CSS with locally scoped class names. There is no runtime style injection, which means zero performance cost in the browser. The TypeScript authoring surface gives full type safety, autocompletion, and the ability to share values (tokens, breakpoints, conditions) between styles and application code. The library provides three layers of abstraction: low-level `style()`, utility-class generation via Sprinkles, and variant-driven component APIs via Recipes.

## Key Points

1. **Co-locate style files with components.** Place `component.css.ts` next to `Component.tsx` for easy navigation and clear ownership.
2. **Use theme contracts for all shared values.** Never hardcode colors or spacing in individual style files; always reference `vars` so themes can be swapped.
3. **Prefer recipes for component variants.** Recipes produce a clean API surface (`intent`, `size`) and compose better than manual conditional class concatenation.
4. **Use sprinkles for layout primitives.** Reserve sprinkles for spacing, display, and alignment; keep visual styling (colors, shadows, borders) in `style()` or recipes.
5. **Export types alongside styles.** Export `RecipeVariants` and `Sprinkles` types so consuming components get full autocompletion and type checking.
6. **Keep style files pure.** Style files (`.css.ts`) should only contain style definitions and token references. Do not import React or runtime dependencies into them.
1. **Importing `.css.ts` files in non-component code.** Style files are evaluated at build time. Importing them dynamically or conditionally causes bundler errors or missing styles.
2. **Overusing `globalStyle`.** Global styles bypass scoping and can cause conflicts. Use them only for CSS resets or styling third-party markup you cannot add classes to.
3. **Duplicating tokens across files.** Define tokens once in a theme contract. Scattering raw values across style files leads to inconsistency and makes theming impossible.
4. **Creating deeply nested style compositions.** Flat, composable styles are easier to read and override. Avoid `styleVariants` with dozens of entries when a recipe would be clearer.
5. **Mixing vanilla-extract with runtime CSS-in-JS.** Combining it with styled-components or Emotion negates the zero-runtime benefit and doubles the CSS pipeline complexity.

## Quick Example

```bash
npm install @vanilla-extract/css @vanilla-extract/recipes @vanilla-extract/sprinkles
# Vite plugin
npm install -D @vanilla-extract/vite-plugin
```
skilldb get css-styling-services-skills/Vanilla ExtractFull skill: 353 lines
Paste into your CLAUDE.md or agent config

vanilla-extract

Core Philosophy

vanilla-extract is a zero-runtime CSS-in-TypeScript library. All styles are written in TypeScript files (.css.ts), evaluated at build time, and emitted as static CSS with locally scoped class names. There is no runtime style injection, which means zero performance cost in the browser. The TypeScript authoring surface gives full type safety, autocompletion, and the ability to share values (tokens, breakpoints, conditions) between styles and application code. The library provides three layers of abstraction: low-level style(), utility-class generation via Sprinkles, and variant-driven component APIs via Recipes.

Setup

Installation

npm install @vanilla-extract/css @vanilla-extract/recipes @vanilla-extract/sprinkles
# Vite plugin
npm install -D @vanilla-extract/vite-plugin

Vite Configuration

// vite.config.ts
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react(), vanillaExtractPlugin()],
});

Next.js Configuration

// next.config.ts
import { createVanillaExtractPlugin } from "@vanilla-extract/next-plugin";

const withVanillaExtract = createVanillaExtractPlugin();

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

Key Techniques

Basic Style Definitions

// card.css.ts
import { style, globalStyle } from "@vanilla-extract/css";
import { vars } from "./theme.css";

export const card = style({
  backgroundColor: vars.color.surface,
  borderRadius: vars.radius.lg,
  padding: vars.space[6],
  boxShadow: vars.shadow.sm,
  transition: "box-shadow 0.2s ease, transform 0.2s ease",
  ":hover": {
    boxShadow: vars.shadow.md,
    transform: "translateY(-2px)",
  },
  ":focus-within": {
    outline: `2px solid ${vars.color.brand}`,
    outlineOffset: "2px",
  },
});

export const cardTitle = style({
  fontSize: vars.fontSize.xl,
  fontWeight: vars.fontWeight.semibold,
  color: vars.color.textPrimary,
  marginBottom: vars.space[2],
});

export const cardBody = style({
  color: vars.color.textSecondary,
  lineHeight: 1.6,
});

// Global style scoped to a parent
globalStyle(`${card} a`, {
  color: vars.color.brand,
  textDecoration: "underline",
});

Theme Contracts and Tokens

// theme.css.ts
import {
  createTheme,
  createThemeContract,
  style,
} from "@vanilla-extract/css";

export const vars = createThemeContract({
  color: {
    brand: null,
    brandDark: null,
    surface: null,
    textPrimary: null,
    textSecondary: null,
    border: null,
  },
  space: {
    1: null, 2: null, 3: null, 4: null, 6: null, 8: null,
  },
  radius: { sm: null, md: null, lg: null, full: null },
  fontSize: { sm: null, base: null, lg: null, xl: null, "2xl": null },
  fontWeight: { normal: null, medium: null, semibold: null, bold: null },
  shadow: { sm: null, md: null, lg: null },
});

export const lightTheme = createTheme(vars, {
  color: {
    brand: "#3b82f6",
    brandDark: "#1d4ed8",
    surface: "#ffffff",
    textPrimary: "#111827",
    textSecondary: "#6b7280",
    border: "#e5e7eb",
  },
  space: {
    1: "0.25rem", 2: "0.5rem", 3: "0.75rem",
    4: "1rem", 6: "1.5rem", 8: "2rem",
  },
  radius: { sm: "0.25rem", md: "0.5rem", lg: "0.75rem", full: "9999px" },
  fontSize: {
    sm: "0.875rem", base: "1rem", lg: "1.125rem",
    xl: "1.25rem", "2xl": "1.5rem",
  },
  fontWeight: { normal: "400", medium: "500", semibold: "600", bold: "700" },
  shadow: {
    sm: "0 1px 2px rgba(0,0,0,0.05)",
    md: "0 4px 6px rgba(0,0,0,0.07)",
    lg: "0 10px 15px rgba(0,0,0,0.1)",
  },
});

export const darkTheme = createTheme(vars, {
  color: {
    brand: "#60a5fa",
    brandDark: "#3b82f6",
    surface: "#1f2937",
    textPrimary: "#f9fafb",
    textSecondary: "#9ca3af",
    border: "#374151",
  },
  space: {
    1: "0.25rem", 2: "0.5rem", 3: "0.75rem",
    4: "1rem", 6: "1.5rem", 8: "2rem",
  },
  radius: { sm: "0.25rem", md: "0.5rem", lg: "0.75rem", full: "9999px" },
  fontSize: {
    sm: "0.875rem", base: "1rem", lg: "1.125rem",
    xl: "1.25rem", "2xl": "1.5rem",
  },
  fontWeight: { normal: "400", medium: "500", semibold: "600", bold: "700" },
  shadow: {
    sm: "0 1px 2px rgba(0,0,0,0.3)",
    md: "0 4px 6px rgba(0,0,0,0.4)",
    lg: "0 10px 15px rgba(0,0,0,0.5)",
  },
});

Recipes for Variant-Driven Components

// button.css.ts
import { recipe, type RecipeVariants } from "@vanilla-extract/recipes";
import { vars } from "./theme.css";

export const button = recipe({
  base: {
    display: "inline-flex",
    alignItems: "center",
    justifyContent: "center",
    fontWeight: vars.fontWeight.medium,
    borderRadius: vars.radius.md,
    transition: "background-color 0.15s, box-shadow 0.15s",
    cursor: "pointer",
    border: "none",
    ":focus-visible": {
      outline: `2px solid ${vars.color.brand}`,
      outlineOffset: "2px",
    },
    ":disabled": {
      opacity: 0.5,
      cursor: "not-allowed",
    },
  },
  variants: {
    intent: {
      primary: {
        backgroundColor: vars.color.brand,
        color: "#fff",
        ":hover": { backgroundColor: vars.color.brandDark },
      },
      secondary: {
        backgroundColor: "transparent",
        color: vars.color.textPrimary,
        border: `1px solid ${vars.color.border}`,
        ":hover": { backgroundColor: vars.color.surface },
      },
      ghost: {
        backgroundColor: "transparent",
        color: vars.color.textSecondary,
        ":hover": { color: vars.color.textPrimary },
      },
    },
    size: {
      sm: { fontSize: vars.fontSize.sm, padding: `${vars.space[1]} ${vars.space[3]}` },
      md: { fontSize: vars.fontSize.base, padding: `${vars.space[2]} ${vars.space[4]}` },
      lg: { fontSize: vars.fontSize.lg, padding: `${vars.space[3]} ${vars.space[6]}` },
    },
  },
  defaultVariants: {
    intent: "primary",
    size: "md",
  },
});

export type ButtonVariants = RecipeVariants<typeof button>;

Using Recipes in Components

// Button.tsx
import { button, type ButtonVariants } from "./button.css";

type ButtonProps = ButtonVariants &
  React.ButtonHTMLAttributes<HTMLButtonElement>;

export function Button({
  intent,
  size,
  className,
  children,
  ...props
}: ButtonProps) {
  return (
    <button className={button({ intent, size })} {...props}>
      {children}
    </button>
  );
}

Sprinkles for Utility Classes

// sprinkles.css.ts
import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles";
import { vars } from "./theme.css";

const responsiveProperties = defineProperties({
  conditions: {
    mobile: {},
    tablet: { "@media": "screen and (min-width: 768px)" },
    desktop: { "@media": "screen and (min-width: 1024px)" },
  },
  defaultCondition: "mobile",
  properties: {
    display: ["none", "flex", "block", "grid", "inline-flex"],
    flexDirection: ["row", "column"],
    alignItems: ["stretch", "center", "flex-start", "flex-end"],
    justifyContent: ["center", "space-between", "flex-start", "flex-end"],
    gap: vars.space,
    padding: vars.space,
    paddingX: vars.space,
    paddingY: vars.space,
    fontSize: vars.fontSize,
  },
  shorthands: {
    px: ["paddingX"],
    py: ["paddingY"],
    p: ["padding"],
  },
});

const colorProperties = defineProperties({
  conditions: {
    light: {},
    dark: { "@media": "(prefers-color-scheme: dark)" },
  },
  defaultCondition: "light",
  properties: {
    color: vars.color,
    backgroundColor: vars.color,
  },
});

export const sprinkles = createSprinkles(responsiveProperties, colorProperties);
export type Sprinkles = Parameters<typeof sprinkles>[0];

Using Sprinkles in a Layout Component

// Stack.tsx
import { sprinkles, type Sprinkles } from "./sprinkles.css";

interface StackProps extends Pick<Sprinkles, "gap" | "alignItems"> {
  children: React.ReactNode;
  as?: React.ElementType;
}

export function Stack({
  gap = 4,
  alignItems = "stretch",
  as: Tag = "div",
  children,
}: StackProps) {
  return (
    <Tag
      className={sprinkles({
        display: "flex",
        flexDirection: "column",
        gap,
        alignItems,
      })}
    >
      {children}
    </Tag>
  );
}

Best Practices

  1. Co-locate style files with components. Place component.css.ts next to Component.tsx for easy navigation and clear ownership.
  2. Use theme contracts for all shared values. Never hardcode colors or spacing in individual style files; always reference vars so themes can be swapped.
  3. Prefer recipes for component variants. Recipes produce a clean API surface (intent, size) and compose better than manual conditional class concatenation.
  4. Use sprinkles for layout primitives. Reserve sprinkles for spacing, display, and alignment; keep visual styling (colors, shadows, borders) in style() or recipes.
  5. Export types alongside styles. Export RecipeVariants and Sprinkles types so consuming components get full autocompletion and type checking.
  6. Keep style files pure. Style files (.css.ts) should only contain style definitions and token references. Do not import React or runtime dependencies into them.

Anti-Patterns

  1. Importing .css.ts files in non-component code. Style files are evaluated at build time. Importing them dynamically or conditionally causes bundler errors or missing styles.
  2. Overusing globalStyle. Global styles bypass scoping and can cause conflicts. Use them only for CSS resets or styling third-party markup you cannot add classes to.
  3. Duplicating tokens across files. Define tokens once in a theme contract. Scattering raw values across style files leads to inconsistency and makes theming impossible.
  4. Creating deeply nested style compositions. Flat, composable styles are easier to read and override. Avoid styleVariants with dozens of entries when a recipe would be clearer.
  5. Mixing vanilla-extract with runtime CSS-in-JS. Combining it with styled-components or Emotion negates the zero-runtime benefit and doubles the CSS pipeline complexity.

Install this skill directly: skilldb add css-styling-services-skills

Get CLI access →