CSS in TypeScript with vanilla-extract

Avatar of Hugh Haworth
Hugh Haworth on

📣 Freelancers, Developers, and Part-Time Agency Owners: Kickstart Your Own Digital Agency with UACADEMY Launch by UGURUS 📣

vanilla-extract is a new framework-agnostic CSS-in-TypeScript library. It’s a lightweight, robust, and intuitive way to write your styles. vanilla-extract isn’t a prescriptive CSS framework, but a flexible piece of developer tooling. CSS tooling has been a relatively stable space over the last few years with PostCSS, Sass, CSS Modules, and styled-components all coming out before 2017 (some long before that) and they remain popular today. Tailwind is one of a few tools that has shaken things up in CSS tooling over the last few years.

vanilla-extract aims to shake things up again. It was released this year and has the benefit of being able to leverage some recent trends, including:

  • JavaScript developers switching to TypeScript
  • Browser support for CSS custom properties
  • Utility-first styling

There are a whole bunch of clever innovations in vanilla-extract that I think make it a big deal.

Zero runtime

CSS-in-JS libraries usually inject styles into the document at runtime. This has benefits, including critical CSS extraction and dynamic styling.

But as a general rule of thumb, a separate CSS file is going to be more performant. That’s because JavaScript code needs to go through more expensive parsing/compilation, whereas a separate CSS file can be cached while the HTTP2 protocol lowers the cost of the extra request. Also, custom properties can now provide a lot of dynamic styling for free.

So, instead of injecting styles at runtime, vanilla-extract takes after Linaria and astroturf. These libraries let you author styles using JavaScript functions that get ripped out at build time and used to construct a CSS file. Although you write vanilla-extract in TypeScript, it doesn’t affect the overall size of your production JavaScript bundle.

TypeScript

A big vanilla-extract value proposition is that you get typing. If it’s important enough to keep the rest of your codebase type-safe, then why not do the same with your styles?

TypeScript provides a number of benefits. First, there’s autocomplete. If you type “fo” then, in a TypeScript-friendly editor, you get a list of font options in a drop down — fontFamily, fontKerning, fontWeight, or whatever else matches — to choose from. This makes CSS properties discoverable from the comfort of your editor. If you can’t remember the name of fontVariant but know it’s going to start with the word “font” you type it and scroll through the options. In VS Code, you don’t need to download any additional tooling to get this to happen.

This really speeds up the authoring of styles:

It also means your editor is watching over your shoulder to make sure you aren’t making any spelling mistakes that could cause frustrating bugs.

vanilla-extract types also provide an explanation of the syntax in their type definition and a link to the MDN documentation for the CSS property you’re editing. This removes a step of frantically Googling when styles are behaving unexpectedly.

Image of VSCode with cursor hovering over fontKerning property and a pop up describing what the property does with a link to the Mozilla documentation for the property

Writing in TypeScript means you’re using camel-case names for CSS properties, like backgroundColor. This might be a bit of a change for developers who are used regular CSS syntax, like background-color.

Integrations

vanilla-extract provides first-class integrations for all the newest bundlers. Here’s a full list of integrations it currently supports:

  • webpack
  • esbuild
  • Vite
  • Snowpack
  • NextJS
  • Gatsby

It’s also completely framework-agnostic. All you need to do is import class names from vanilla-Extract, which get converted into a string at build time.

Usage

To use vanilla-Extract, you write up a .css.ts file that your components can import. Calls to these functions get converted to hashed and scoped class name strings in the build step. This might sound similar to CSS Modules, and this isn’t by coincidence: one of the creators of vanilla-Extract, Mark Dalgleish, is also co-creator of CSS Modules.

style()

You can create an automatically scoped CSS class using the style() function. You pass in the element’s styles, then export the returned value. Import this value somewhere in your user code, and it’s converted into a scoped class name.

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

export const titleStyle = style({
  backgroundColor: "hsl(210deg,30%,90%)",
  fontFamily: "helvetica, Sans-Serif",
  color: "hsl(210deg,60%,25%)",
  padding: 30,
  borderRadius: 20,
});
// title.ts
import {titleStyle} from "./title.css";

document.getElementById("root").innerHTML = `<h1 class="${titleStyle}">Vanilla Extract</h1>`;

Media queries and pseudo selectors can be included inside style declarations, too:

// title.css.ts
backgroundColor: "hsl(210deg,30%,90%)",
fontFamily: "helvetica, Sans-Serif",
color: "hsl(210deg,60%,25%)",
padding: 30,
borderRadius: 20,
"@media": {
  "screen and (max-width: 700px)": {
    padding: 10
  }
},
":hover":{
  backgroundColor: "hsl(210deg,70%,80%)"
}

These style function calls are a thin abstraction over CSS — all of the property names and values map to the CSS properties and values you’re familiar with. One change to get used to is that values can sometimes be declared as a number (e.g. padding: 30) which defaults to a pixel unit value, while some values need to be declared as a string (e.g. padding: "10px 20px 15px 15px").

The properties that go inside the style function can only affect a single HTML node. This means you can’t use nesting to declare styles for the children of an element — something you might be used to in Sass or PostCSS. Instead, you need to style children separately. If a child element needs different styles based on the parent, you can use the selectors property to add styles that are dependent on the parent:

// title.css.ts
export const innerSpan = style({
  selectors:{[`${titleStyle} &`]:{
    color: "hsl(190deg,90%,25%)",
    fontStyle: "italic",
    textDecoration: "underline"
  }}
});
// title.ts
import {titleStyle,innerSpan} from "./title.css";
document.getElementById("root").innerHTML = 
`<h1 class="${titleStyle}">Vanilla <span class="${innerSpan}">Extract</span></h1>
<span class="${innerSpan}">Unstyled</span>`;

Or you can also use the Theming API (which we’ll get to next) to create custom properties in the parent element that are consumed by the child nodes. This might sound restrictive, but it’s intentionally been left this way to increase maintainability in larger codebases. It means that you’ll know exactly where the styles have been declared for each element in your project.

Theming

You can use the createTheme function to build out variables in a TypeScript object:

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

// Creating the theme
export const [mainTheme,vars] = createTheme({
  color:{
    text: "hsl(210deg,60%,25%)",
    background: "hsl(210deg,30%,90%)"
  },
  lengths:{
    mediumGap: "30px"
  }
})

// Using the theme
export const titleStyle = style({
  backgroundColor:vars.color.background,
  color: vars.color.text,
  fontFamily: "helvetica, Sans-Serif",
  padding: vars.lengths.mediumGap,
  borderRadius: 20,
});

Then vanilla-extract allows you to make a variant of your theme. TypeScript helps it ensure that your variant uses all the same property names, so you get a warning if you forget to add the background property to the theme.

Image of VS Code where showing a theme being declared but missing the background property causing a large amount of red squiggly lines to warn that the property’s been forgotten

This is how you might create a regular theme and a dark mode:

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

export const [mainTheme,vars] = createTheme({
  color:{
    text: "hsl(210deg,60%,25%)",
    background: "hsl(210deg,30%,90%)"
  },
  lengths:{
    mediumGap: "30px"
  }
})
// Theme variant - note this part does not use the array syntax
export const darkMode = createTheme(vars,{
  color:{
    text:"hsl(210deg,60%,80%)",
    background: "hsl(210deg,30%,7%)",
  },
  lengths:{
    mediumGap: "30px"
  }
})
// Consuming the theme 
export const titleStyle = style({
  backgroundColor: vars.color.background,
  color: vars.color.text,
  fontFamily: "helvetica, Sans-Serif",
  padding: vars.lengths.mediumGap,
  borderRadius: 20,
});

Then, using JavaScript, you can dynamically apply the class names returned by vanilla-extract to switch themes:

// title.ts
import {titleStyle,mainTheme,darkMode} from "./title.css";

document.getElementById("root").innerHTML = 
`<div class="${mainTheme}" id="wrapper">
  <h1 class="${titleStyle}">Vanilla Extract</h1>
  <button onClick="document.getElementById('wrapper').className='${darkMode}'">Dark mode</button>
</div>`

How does this work under the hood? The objects you declare in the createTheme function are turned into CSS custom properties attached to the element’s class. These custom properties are hashed to prevent conflicts. The output CSS for our mainTheme example looks like this:

.src__ohrzop0 {
  --color-brand__ohrzop1: hsl(210deg,80%,25%);
  --color-text__ohrzop2: hsl(210deg,60%,25%);
  --color-background__ohrzop3: hsl(210deg,30%,90%);
  --lengths-mediumGap__ohrzop4: 30px;
}

And the CSS output of our darkMode theme looks like this:

.src__ohrzop5 {
  --color-brand__ohrzop1: hsl(210deg,80%,60%);
  --color-text__ohrzop2: hsl(210deg,60%,80%);
  --color-background__ohrzop3: hsl(210deg,30%,10%);
  --lengths-mediumGap__ohrzop4: 30px;
}

So, all we need to change in our user code is the class name. Apply the darkmode class name to the parent element, and the mainTheme custom properties get swapped out for darkMode ones.

Recipes API

The style and createTheme functions provide enough power to style a website on their own, but vanilla-extract provides a few extra APIs to promote reusability. The Recipes API allows you to create a bunch of variants for an element, which you can choose from in your markup or user code.

First, it needs to be separately installed:

npm install @vanilla-extract/recipes

Here’s how it works. You import the recipe function and pass in an object with the properties base and variants:

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

export const buttonStyles = recipe({
  base:{
    // Styles that get applied to ALL buttons go in here
  },
  variants:{
    // Styles that we choose from go in here
  }
});

Inside base, you can declare the styles that will be applied to all variants. Inside variants, you can provide different ways to customize the element:

// button.css.ts
import { recipe } from '@vanilla-extract/recipes';
export const buttonStyles = recipe({
  base: {
    fontWeight: "bold",
  },
  variants: {
    color: {
      normal: {
        backgroundColor: "hsl(210deg,30%,90%)",
      },
      callToAction: {
        backgroundColor: "hsl(210deg,80%,65%)",
      },
    },
    size: {
      large: {
        padding: 30,
      },
      medium: {
        padding: 15,
      },
    },
  },
});

Then you can declare which variant you want to use in the markup:

// button.ts
import { buttonStyles } from "./button.css";

<button class=`${buttonStyles({color: "normal",size: "medium",})}`>Click me</button>

And vanilla-extract leverages TypeScript giving autocomplete for your own variant names!

You can name your variants whatever you like, and put whatever properties you want in them, like so:

// button.css.ts
export const buttonStyles = recipe({
  variants: {
    animal: {
      dog: {
        backgroundImage: 'url("./dog.png")',
      },
      cat: {
        backgroundImage: 'url("./cat.png")',
      },
      rabbit: {
        backgroundImage: 'url("./rabbit.png")',
      },
    },
  },
});

You can see how this would be incredibly useful for building a design system, as you can create reusable components and control the ways they vary. These variations become easily discoverable with TypeScript — all you need to type is CMD/CTRL + Space (on most editors) and you get a dropdown list of the different ways to customize your component.

Utility-first with Sprinkles

Sprinkles is a utility-first framework built on top of vanilla-extract. This is how the vanilla-extract docs describe it:

Basically, it’s like building your own zero-runtime, type-safe version of Tailwind, Styled System, etc.

So if you’re not a fan of naming things (we all have nightmares of creating an outer-wrapper div then realising we need to wrap it with an . . . outer-outer-wrapper ) Sprinkles might be your preferred way to use vanilla-extract.

The Sprinkles API also needs to be separately installed:

npm install @vanilla-extract/sprinkles

Now we can create some building blocks for our utility functions to use. Let’s create a list of colors and lengths by declaring a couple of objects. The JavaScript key names can be whatever we want. The values will need to be valid CSS values for the CSS properties we plan to use them for:

// sprinkles.css.ts
const colors = {
  blue100: "hsl(210deg,70%,15%)",
  blue200: "hsl(210deg,60%,25%)",
  blue300: "hsl(210deg,55%,35%)",
  blue400: "hsl(210deg,50%,45%)",
  blue500: "hsl(210deg,45%,55%)",
  blue600: "hsl(210deg,50%,65%)",
  blue700: "hsl(207deg,55%,75%)",
  blue800: "hsl(205deg,60%,80%)",
  blue900: "hsl(203deg,70%,85%)",
};

const lengths = {
  small: "4px",
  medium: "8px",
  large: "16px",
  humungous: "64px"
};

We can declare which CSS properties these values are going to apply to by using the defineProperties function:

  • Pass it an object with a properties property.
  • In properties, we declare an object where the keys are the CSS properties the user can set (these need to be valid CSS properties) and the values are the objects we created earlier (our lists of colors and lengths).
// sprinkles.css.ts
import { defineProperties } from "@vanilla-extract/sprinkles";

const colors = {
  blue100: "hsl(210deg,70%,15%)"
  // etc.
}

const lengths = {
  small: "4px",
  // etc.
}

const properties = defineProperties({
  properties: {
    // The keys of this object need to be valid CSS properties
    // The values are the options we provide the user
    color: colors,
    backgroundColor: colors,
    padding: lengths,
  },
});

Then the final step is to pass the return value of defineProperties to the createSprinkles function, and export the returned value:

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

const colors = {
  blue100: "hsl(210deg,70%,15%)"
  // etc.
}

const lengths = {
  small: "4px",
  // etc. 
}

const properties = defineProperties({
  properties: {
    color: colors,
    // etc. 
  },
});
export const sprinkles = createSprinkles(properties);

Then we can start styling inside our components inline by calling the sprinkles function in the class attribute and choosing which options we want for each element.

// index.ts
import { sprinkles } from "./sprinkles.css";
document.getElementById("root").innerHTML = `<button class="${sprinkles({
  color: "blue200",
  backgroundColor: "blue800",
  padding: "large",
})}">Click me</button>
</div>`;

The JavaScript output holds a class name string for each style property. These class names match a single rule in the output CSS file.

<button class="src_color_blue200__ohrzop1 src_backgroundColor_blue800__ohrzopg src_padding_large__ohrzopk">Click me</button>

As you can see, this API allows you to style elements inside your markup using a set of pre-defined constraints. You also avoid the difficult task of coming up with names of classes for every element. The result is something that feels a lot like Tailwind, but also benefits from all the infrastructure that has been built around TypeScript.

The Sprinkles API also allows you to write conditions and shorthands to create responsive styles using utility classes.

Wrapping up

vanilla-extract feels like a big new step in CSS tooling. A lot of thought has been put into building it into an intuitive, robust solution for styling that utilizes all of the power that static typing provides.

Further reading