Skip to content

Introduction to Vanilla Extract for CSS

Vanilla Extract is a CSS framework that lets you create class styles with TypeScript. It combines the utility class approach of something like Tailwind with the type-safety of TypeScript, allowing you to create your own custom yet consistent styles. Styles generated by Vanilla Extract are locally scoped, and compile to a single stylesheet at build time.

This introduction will show you what it looks like to use Vanilla Extract in a React app, but it's a framework-agnostic library. Anywhere you can include a class name, it should work just fine. We'll begin with very simple styles, and work our way through some of the more complex features until you've got a foundational understanding of how to utilize Vanilla Extract in your own projects.

🔗Styles

To start with Vanilla Extract, you first create a style. The style method accepts either a style object, or an array of style objects. Here is a simple example using React. Here, we create a style, then use it in our component by passing the variable name to className. When the app builds, a stylesheet will be generated, and our exampleStyle will get a hashed class name which will be passed into the component.

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

export const exampleStyle = style({
  backgroundColor: 'blue',
  color: 'white',
  padding: '1.5rem'
});
// ExampleComponent.tsx
import React from 'react';
import { exampleStyle } from './style.css'

export const ExampleComponent = () => {
  return (
    <div className={exampleStyle}>
      This component will have a blue background, white text color, and padding of 1.5 rem.
    </div>
  );
}

When this app is built, exampleStyle gets compiled to a CSS class in a static stylesheet. That class name is then given to the React component.

<div class="style_exampleStyle__1d9bkuq0">
This component will have a blue background, white text color, and padding of 1.5 rem.
</div>
/* main.css */

.style_exampleStyle__1d9bkuq0 {
  background-color: blue;
  color: white;
  padding: 1.5rem;
}

🔗Themes

If you want to make sure you're using consistent values across your styles, you can create a theme to make those styles available throughout your app. Using themes lets you use known names for things like standardized spacing or color palette names, allowing you to define them once upfront, and get type safety when using them later.

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

export const [themeClass, vars] = createTheme({
  color: {
    background: "white",
    text: "black",
    primary: "blue",
    secondary: "red",
  },
  spacing: {
    small: "4px",
    medium: "8px",
    large: "16px",
    xlarge: "32px"
  }
})
// style.css.ts
import { style } from '@vanilla-extract/css';
import { vars } from './theme.css'

export const bodyStyle = style({
  backgroundColor: vars.color.background,
  color: vars.color.text,
});

export const buttonStyle = style({
  backgroundColor: vars.color.primary,
  color: vars.color.background,
  padding: vars.sizing.large
})

Because we're using TypeScript, you can get type safety and intellisense auto-completion. If you make a typo when writing out a theme variable, your editor will warn you about it.

🔗Variants

Sometimes you have styles that are nearly, but not quite the same. In these cases, it's sometimes best to define variants. In this example, I've created styles for two button variants: one for the primary brand colors, and one for secondary colors.

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

const colors = {
  background: "#fefefe",
  text: "#333",
  primary: "#9C19E0",
  secondary: "#32C1CD"
}

// Here is the style shared by all buttons
const baseButtonStyle = style({
  border: "none",
  borderRadius: "4px",
  fontSize: "18px"
})

// Here, we define variants of button. In this case, each key is an array containing all styles relevant to that variant. We're only combining the base style and the unique color style object.
export const buttonStyle = styleVariants({
  primary: [baseButtonStyle, {
    backgroundColor: colors.primary,
    color: colors.background
  }],
  secondary: [baseButtonStyle, {
    backgroundColor: colors.secondary,
    color: colors.text
  }]
})
// ExampleComponent.tsx
import React from 'react';
import { buttonStyle } from './style.css'

export const ExampleComponent = () => {
  return (
    <>
      <button className={buttonStyle["primary"]}>
        Primary Button
      </button>
      <button className={buttonStyle.secondary}>
        Secondary Button
      </button>
    </>
  );
}

🔗Sprinkles

Sprinkles allow you to create easy-to-reuse utility classes with Vanilla Extract. Sprinkles allow you to define conditions, and the properties that apply under each condition, and generate all the utility classes that would be necessary to satisfy all of those potential conditions. In this example, we use defineProperties to outline some conditions and acceptable property values around colors and spacing. Then, we combine these using createSprinkles to give us a single way to use them.

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

// You could also use a theme here!
const colors = {
  red: "#FF5151",
  blue: "#88E0EF",
  green: "#17D7A0"
};

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

const space = {
  small: "4px",
  medium: "8px",
  large: "16px",
  xlarge: "32px"
};

const spaceProperties = defineProperties({
  conditions: {
    mobile: {},
    desktop: { "@media": "screen and (min-width: 1024px)" }
  },
  defaultCondition: "mobile",
  properties: {
    margin: space,
    padding: space
  }
});

export const sprinkles = createSprinkles(colorProperties, spaceProperties);
import React from "react";
import { sprinkles } from "./sprinkles.css";

export const ExampleComponent2 = () => {
  return (
    <div
      className={sprinkles({
        backgroundColor: {
          lightMode: "blue",
          darkMode: "red"
        },
        padding: {
          mobile: "small",
          desktop: "xlarge"
        }
      })}
    >
      This component uses sprinkles to display constrained styles based on conditions. The background color will be either #88E0EF for light mode users or #FF5151 for dark mode. And padding will default to 4px, but jump to 32px for users with a viewport min-width of 1024px.
    </div>
  );
};

Once built, the various sprinkles permutations are compiled as CSS utility classes in our stylesheet, and applied to the relevant elements like this:

<div class="
sprinkles_backgroundColor_blue_lightMode__1qjyzon8
sprinkles_backgroundColor_red_darkMode__1qjyzon7
sprinkles_padding_small_mobile__1qjyzonk
sprinkles_padding_xlarge_desktop__1qjyzonr
">
  This component uses sprinkles to display constrained styles based on conditions.
</div>

You can see, in the style names, how our conditions and properties have combined to create unique utility classes for each possibility, such as padding_small_mobile and padding_xlarge_desktop. Like with any utility class approach, be careful not to define more conditions and properties than you will actually use— you could create a very large stylesheet of mostly unused CSS that way.

If you want to combine Sprinkles utility classes with some styles unique to the element you're working on, you can combine them in an array passed to style.

export const combineStyle = style([
  sprinkles({
    // ...your sprinkles here...
  }), {
    // ...your custom styles here...
  }
])

🔗Recipes

Recipes give you an easy way to combine base styles, multiple variants, and combinations of variants. In this example, let's revisit making different types of buttons.

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

export const button = recipe({
  // First, we define a base set of styles that apply by default.
  base: {
    border: 0,
    borderRadius: 4,
    color: "white"
  },
  // Next, we define the different variants we may want to mix-and-match.
  variants: {
    color: {
      primary: { backgroundColor: "darkslateblue" },
      secondary: { backgroundColor: "teal" },
      destructive: { backgroundColor: "tomato" }
    },
    size: {
      small: { fontSize: 10, padding: 10 },
      medium: { fontSize: 16, padding: 14 },
      large: { fontSize: 20, padding: 18 }
    }
  },
  // Optionally, we can define styles that only apply when we have specific combinations of variants. Here, we apply a different color to the text, increase font weight, and add a border for large destrucive buttons.
  compoundVariants: [
    {
      variants: {
        color: "destructive",
        size: "large"
      },
      style: {
        borderColor: "darkred",
        borderStyle: "solid",
        borderWidth: 2,
        color: "darkred",
        fontWeight: 700
      }
    }
  ],
  // Finally, we define default variants in case we don't specify any.
  defaultVariants: {
    color: "primary",
    size: "medium"
  }
});
import React from "react";
import { button } from "./style.css";

export const ExampleComponent3 = () => {
  return (
    <>
      <button className={button({
        color: "secondary",
        size: "small"
      })}>
        Secondary Small Button
      </button>
      <button className={button({
        color: "destructive",
        size: "large"
      })}>
        Destructive Large Button with Special Styles due to compoundVariants.
      </button>
    </>
  );
};

Once again, our recipe and defined variants get transformed into class names. All of the combinations necessary to get the different variants and compount variants to work have been created in the static CSS file.

<button class="style_button__1d9bkuq4 style_button_color_secondary__1d9bkuq6 style_button_size_small__1d9bkuq8">
  Secondary Small Button
</button>
<button class="style_button__1d9bkuq4 style_button_color_destructive__1d9bkuq7 style_button_size_large__1d9bkuqa style_button_compound_0__1d9bkuqb">
  Destructive Large Button
</button>
/* main.css */
.style_button__1d9bkuq4 {
  border: 0;
  border-radius: 4px;
  color: white;
}
.style_button_color_primary__1d9bkuq5 {
  background-color: darkslateblue;
}
.style_button_color_secondary__1d9bkuq6 {
  background-color: teal;
}
.style_button_color_destructive__1d9bkuq7 {
  background-color: tomato;
}
.style_button_size_small__1d9bkuq8 {
  font-size: 10px;
  padding: 10px;
}
.style_button_size_medium__1d9bkuq9 {
  font-size: 16px;
  padding: 14px;
}
.style_button_size_large__1d9bkuqa {
  font-size: 20px;
  padding: 18px;
}
.style_button_compound_0__1d9bkuqb {
  border-color: darkred;
  border-style: solid;
  border-width: 2px;
  color: darkred;
  font-weight: 700;
}

This is a basic introduction to Vanilla Extract. It's a very powerful library for creating styles, and you'll find much more information in the documentation. You can also play with all of my examples on CodeSandbox.


This Dot Labs is a development consultancy focused on providing staff augmentation, architectural guidance, and consulting to companies.

We help implement and teach modern web best practices with technologies such as React, Angular, Vue, Web Components, GraphQL, Node, and more.

You might also like

ReactJS

Taming Forms With react-hook-form

Javascript

Getting Started with RxJS

General

Semantic HTML: Why it matters and top tips on how to apply it

ReactJS

React.js 17: No New Features and New JSX Transform