Skip to content

Set up vanilla-extract with create-react-app

vanilla-extract is a CSS-in-JS library that generates static CSS files at build time. Let's integrate vanilla-extract in a React app with create-react-app.

Published
Sep 03, 2022
Updated
Oct 08, 2022
Reading time
7 min read

vanilla-extract is great CSS-in-JS library (or you could call it a CSS Modules-in-TS library). It provides a great developer experience thanks to its type-safe API and because it extracts all styles into static CSS files at build time, it has better performance than traditional CSS-in-JS libraries like styled-components or Stitches that work at runtime.

Let's create a new React app with create-react-app (CRA) and set up vanilla-extract.

React app

First create a new app (if you don't have one already) and use the TypeScript template:

npx create-react-app my-app --template typescript
cd my-app

Custom webpack config

vanilla-extract requires you to install a webpack plugin. CRA by default does not give you control over webpack (except if you run npm run eject which should be avoided) but there are tools like @craco/craco and react-app-rewired that let you customize the webpack config.

Install craco in your app. Note that at the time of writing you need to install the alpha version of craco because the stable version is not yet compatible with of CRA v5.

npm i -D @craco/craco@alpha

Then create an empty configuration file in the root of your project. We'll later add the vanilla-extract webpack plugin here.

my-app/craco.config.js
module.exports = {};

And finally, replace the react-scripts with craco in your package.json:

my-app/package.json
  "scripts": {
-   "start": "react-scripts start",
-   "build": "react-scripts build",
-   "test": "react-scripts test",
+   "start": "craco start",
+   "build": "craco build",
+   "test": "craco test",
    "eject": "react-scripts eject"
  },

Webpack Plugin

Install vanilla-extract and its webpack plugin and register it.

npm i @vanilla-extract/css
npm i -D @vanilla-extract/webpack-plugin
my-app/craco.config.js
const { VanillaExtractPlugin } = require("@vanilla-extract/webpack-plugin");

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

Theme + Styles

Now create a theme ...

my-app/src/theme.css.ts
import { createTheme } from "@vanilla-extract/css";

export const [themeClass, vars] = createTheme({
  colors: {
    primary: "blue",
  },
});

... add some styles ...

my-app/src/App.css.ts
import { style } from "@vanilla-extract/css";
import { vars } from "./theme.css";

export const title = style({
  color: vars.colors.primary,
});

... and use them in your component.

my-app/src/App.tsx
import { themeClass } from "./theme.css";
import * as css from "./App.css";

function App() {
  return (
    <div className={themeClass}>
      <h1 className={css.title}>Hello World</h1>
    </div>
  );
}

Run npm start to start your app.

Image support

You need additional configuration if you want to handle images.

When you include a relative path to an image file in your static CSS file (not vanilla-extract), the CSS Loader used by CRA will automatically adjust the URL to the hashed image file that will be copied to the build output folder. For vanilla-extract we need to disable this behavior because importing the image file in your *.css.ts file will already give you the final url and there is no need to transform it again (which would result in an error).

If you don't plan to use images or other files like fonts in static CSS files (but only in vanilla-extract *.css.ts files) you can completely disable the url handling by customizing the option via craco:

my-app/craco.config.js
module.exports = {
  style: {
    css: {
      loaderOptions: {
        url: false,
      },
    },
  },
  // ...
};

What if you need support for images/fonts in static CSS files? Now that's a bit more complex. We want to use the same loader as for any other *.css file (with all the useful configuration that CRA provides) but we need to disable the url handling. In summary, we must find the existing CSS rule in the webpack config, clone it and adjust the options.

I use the cloneDeep function of lodash to clone the existing config. Make sure to install the package via npm i -D lodash.clonedeep.

my-app/craco.config.js
const cloneDeep = require("lodash.clonedeep");
const path = require("path");

module.exports = {
  webpack: {
    // ... (plugins)

    configure: (webpackConfig) => {
      // CRA uses two rules, one with "oneOf" that handles all possible file types
      const oneOfRule = webpackConfig.module.rules.find((rule) => rule.oneOf);

      // Then we need to find the rule that handles CSS files
      const cssRule = oneOfRule.oneOf.find(
        (rule) => rule.test instanceof RegExp && rule.test.test("styles.css")
      );

      // We clone the rule before manipulating it
      const vanillaExtractRule = cloneDeep(cssRule);

      // Our new rule should only handle vanilla-extract files
      vanillaExtractRule.test = /\.vanilla\.css$/i;

      // We need to find the css-loader to adjust the options.
      // We disable url handling because that's already done when
      // importing the image file in the *.css.ts file
      const cssLoader = require.resolve("css-loader");
      const loader = vanillaExtractRule.use.find((c) => c.loader === cssLoader);
      loader.options.url = false;

      // Prepend the new rule because
      // the last rule is a generic file loader and
      // it must come before the existing CSS loader which
      // handles all CSS files.
      oneOfRule.oneOf.unshift(vanillaExtractRule);

      return webpackConfig;
    },
  },
};

The vanilla-extract docs also mention the MiniCssExtractPlugin. This plugin is already registered by CRA and you should not register it again (this would inject all your styles twice).

Now you can import images in your *.css.ts files and use them as background image:

my-app/App.css.ts
import { style } from "@vanilla-extract/css";
import image from "./my-image.png";

export const myElement = style({
  background: `url(${image})`,
});

Jest Transform

Note

The article previously mentioned the @vanilla-extract/babel-plugin. The plugin has been deprecated and replaced by the Jest transformer.

If you use Jest to test your components you need the Jest transformer. Install it and add it to your craco config.

npm i -D @vanilla-extract/jest-transform
my-app/craco.config.js
module.exports = {
  //...
  jest: {
    configure: (jestConfig) => {
      jestConfig.transform = {
        "\\.css\\.ts$": "@vanilla-extract/jest-transform",
        ...jestConfig.transform,
      };
      return jestConfig;
    },
  },
};

We use a custom config function for jest.configure (instead of the also supported object syntax) because our new transform entry must be added before any other existing entries. With the object syntax, Craco would add our transform entry at the end which would not work.

Now you can run npm run test and test your components.

Missing support for CSS Custom Props

Your vanilla-extract styles are loaded in your tests, and you can validate them (e.g. with toHaveStyle() when using testing-library) but jsdom does not yet support CSS custom properties. Theme values are not be available in your tests. There is an open pull request.

Production build

If you run your production build with npm run build you will probably get the following error:

You attempted to import node_modules/@vanilla-extract/webpack-plugin/extracted.js which falls outside of the project src/ directory. Relative imports outside of src/ are not supported.

CRA doesn't allow you to import files outside the src/ directory.

How can we fix this? You could configure a path alias in your tsconfig but CRA doesn't support path mappings by default and you would have to use something like react-app-alias. An easier solution that doesn't require another package is configuring the ModuleScopePlugin that is used by CRA to enforce the path restrictions.

Extend your existing webpack config by looking for the plugin and add the path to the vanilla-extract webpack plugin to the list of allowed paths:

my-app/craco.config.js
const ModuleScopePlugin = require("react-dev-utils/ModuleScopePlugin");

module.exports = {
  webpack: {
    configure: (webpackConfig) => {
      // ...

      const moduleScopePlugin = webpackConfig.resolve.plugins.find(
        (plugin) => plugin instanceof ModuleScopePlugin
      );
      moduleScopePlugin.allowedPaths.push(
        path.resolve(
          __dirname, 
          "node_modules/@vanilla-extract/webpack-plugin"
        )
      );

      return webpackConfig;
    },
  },
  // ...
};

That's it, you can now run your production build.

Demo

The demo project is available on GitHub:

github.com/rothsandro/vanilla-extract-cra

Summary

Setting up vanilla-extract is a bit painful as it requires to customize the webpack config and vanilla-extract does not provide any documentation specific to CRA. But once set up, you get a powerful CSS-in-JS solution that works at build time and provides a great, type-safe styling API.