Astro Vanilla-Extract Styling: CSS in TypeScript
Tags: webdev, astro, typescript, css
Published: 2023-01-02 10:43:10

---
title: "Astro Vanilla-Extract Styling: CSS in TypeScript"
published: "true"
description: "Astro vanilla-extract: how to set up vanilla-extract in your Astro project for zero runtime overhead styling, theming, contracts and more."
tags: "webdev, astro, typescript, css"
canonical_url: "https://rodneylab.com/astro-vanilla-extract/"
cover_image: "https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dd9f3yz5d0h1ltg2qd5e.png"
---

## 🧑🏽‍🍳 What is vanilla-extract?

In this Astro vanilla-extract extract post, we start by taking a look at what vanilla-extract is.  Then we turn to some code from a working Astro vanilla-extract project. This will help us see how to set up vanilla-extract to work with Astro. As well as that we will see some vanilla-extract features, in case you are less familiar with vanilla-extract itself. Once we have the global styles set up, we see how you can use vanilla-extract in Astro components as well as Svelte ones.  Astro supports multiple component libraries and the code will also be useful if you work in Preact or some other frameworks. Anyway, the easiest way to learn about vanilla-extract is probably just to roll your sleeves up and spin up a project. With that in mind let&rsquo;s get going as quick as we can!

### What does vanilla-extract Bring to the Table?

If you already author your interactivty logic in  TypeScript rather than JavaScript, you will already know how much time the associated tooling can save you.  Writing your styles in vanilla-extract brings similar advantages; you get Intellisense autocompletion and on top if you never defined `spacing-xs` in your global styles, then the editor will flag up an error if you try to use `spacing-xs`.  This can save you a lot of time in debugging styles.

On top vanilla-extract lets you define contracts for themes. We see later that setting this up, we can stipulate that any theme needs to have certain properties defined.  Examples might be `surfaceColour` or `fontFamily`.  This is great if you tinker away on your site on the dark theme, refactoring chunks and forget to keep the light theme in step.  The editor will have your back!

## 😕 Isn't CSS-in-JS bad for Performance?

Some CSS-in-JS libraries can come with a performance hit as they have a runtime overhead. Another advantage of vanilla-extract is that although you author your styes in TypeScript, we will see Vite compiles these to vanilla CSS. That vanilla CSS is what we ship to the end-user browser.  For that reason, this criticism sometime levelled at other CSS-in-JS libraries does not apply to vanilla-extract.

## 🧱 What are we Building?

We will use example code from a newsletter page.  We won&rsquo;t build it from scratch, instead, we pick out the most important details for quickly getting up to speed with Astro vanilla-extract.  In summary we will see:

- how to use vanilla-extract in Astro and Svelte components
- an example of theme contracts,
- a way to use vanilla-extract classes to implement a dark/light theme toggle.

Enough talk! If you are still interested, let&rsquo;s start by seeing how to set up vanilla-extract in Astro.

![Astro Vanilla-Extract Styling: Screen capture shows newsletter site in neutral, light colours with a moon button in the top corner for switching to the dark theme.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2peq12kk90kq1920icpp.png)

![Astro Vanilla-Extract Styling: Screen capture shows newsletter site in neutral, dark colours with a sun button in the top corner for switching to the light theme.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4j8s36fonluyquwm5dgl.png)

## ⚙️ Getting Started: Astro vanilla-extract initial setup

There is not yet an Astro integration but the setup is far from onerous.  If you are <a aria-label="Open post on creating a new Astro app" href="https://rodneylab.com/how-to-create-new-astro-js-app/">starting from scratch, spin up a new Astro project</a> to get going.  Once you have an Astro project to work on, add the vanilla-extract packages:

```shell
pnpm add -D @vanilla-extract/css \\
  @vanilla-extract/vite-plugin
```

Next update your `astro.config.mjs` file in the project root directory to use vanilla-extract:

```javascript
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
import { defineConfig } from 'astro/config';

// https://astro.build/config
import preact from '@astrojs/preact';
import svelte from '@astrojs/svelte';

// https://astro.build/config
export default defineConfig({
	vite: {
		plugins: [vanillaExtractPlugin()],
	},
	integrations: [svelte(), preact()],
});
```

That&rsquo;s it, we&rsquo;re all set.  Before adding global styles we will look at adding a theme.

## 🎨 Astro vanilla-extract Theming

I created a `src/styles` folder for vanilla-extract global styles and themes as well as vanilla CSS for self-hosted font font-face directives.  The themes are in `src/styles/theme.css.ts`.  Remember we write vanilla-extract styles in TypeScript and Vite transpiles these to vanilla CSS for us.  Here is the theme code (`src/styles/theme.css.ts`):

```typescript

import { createTheme, createThemeContract } from '@vanilla-extract/css';
 
const colours = {
	webOrange: 'hsl(39 100% 50%)',

	azureRadiance: 'hsl(202 100% 50%)',

	astronaut: 'hsl(240 46% 31%)',
	// TRUNCATED...
};
 
const commonVars = {
	fontFamily: {
		heading: `'Work Sans', ${fallbackSansFonts}`,
		subheading: `'Roboto Slab', ${fallbackSansFonts}`,
		body: `'Source Sans Pro', ${fallbackSansFonts}`,
	},
	widths: {
		maxWidthText: '38rem',
		maxWidth3XL: '48rem',
	},
	spacing: {
		// TRUNCATED...
	}
};
 
export const theme = createThemeContract({
	colours: {
		primary: '',
		secondary: '',
		alternative: '',
		text: '',
		surface: '',
		surfaceAlt: '',
	},
	boxShadow: {
		lowElevation: '',
	},
	...commonVars,
});
 
export const lightThemeClass = createTheme(theme, {
	colours: {
		primary: colours.astronaut,
		secondary: colours.webOrange,
		alternative: colours.azureRadiance,
		text: colours.shark,
		surface: colours.solitudeTint70,
		surfaceAlt: colours.solitude,
	},
	boxShadow: {
		// CREDIT: https://www.joshwcomeau.com/shadow-palette/
		lowElevation: `-1px 1px 1.6px hsl(${colours.solitudeShadow} / 0.34), -1.7px 1.7px 2.7px -1.2px hsl(${colours.solitudeShadow} / 0.34), -4px 4px 6.4px -2.5px hsl(${colours.solitudeShadow} / 0.34)`,
	},
	...commonVars,
});
 
export const darkThemeClass = createTheme(theme, {
	colours: {
		primary: colours.solitudeShade10,
		secondary: colours.shark,
		alternative: colours.webOrange,
		text: colours.solitude,
		surface: colours.shark,
		surfaceAlt: colours.sharkTint10,
	},
	boxShadow: {
		// CREDIT: https://www.joshwcomeau.com/shadow-palette/
		lowElevation: `-1px 1px 1.4px hsl(${colours.sharkShadow} / 0.48), -1.5px 1.5px 2.1px -1.7px hsl(${colours.sharkShadow} / 0.39), -4px 4px 5.5px -3.5px hsl(${colours.sharkShadow} / 0.3)`,
	},
	...commonVars,
});
```

We mentioned earlier that the theme contract is just a way of making sure whenever we create a new theme, that we define all fields which should be defined.  To set this up, we import `createTheme` and `createThemeContract` (line `1`). Then define the contract in lines `27`-`40` by calling `createThemeContract`. Then for each theme we call `createTheme`. If you are coding along, try omitting a field in a theme you create or even adding an extra field to the contract to see the editor response!

The site we build is fairly simple and you can add `0`-`900` colour ranges instead if you are working on a more substantial project.  Of, course you can add an extra contrast or other themes, all linked to the contract.  Next we use these themes while setting up global styles.

## 🌍 Astro vanilla-extract: Global Styles

```typescript
import { globalStyle, style } from '@vanilla-extract/css';
import { theme } from '~/styles/themes.css';

globalStyle('*', {
	boxSizing: 'border-box',
	margin: 0,
});

globalStyle('html', {
	display: 'flex',
});

globalStyle('html, body', {
	fontSize: theme.fontSize.size1,
	fontFamily: theme.fontFamily.body,
});

globalStyle('body', {
	lineHeight: theme.lineHeight.normal,
	WebkitFontSmoothing: 'antialiased',
	margin: [theme.spacing.size0, 'auto'],
	transitionProperty: 'background-color',
	transitionDuration: '200ms',
});

globalStyle('img, picture, video, canvas, svg', {
	display: 'block',
	maxWidth: '100%',
});

// TRUNCATED...

globalStyle('a:hover, a:focus', {
	textDecoration: 'none',
});

// TRUNCATED...

export const screenReaderText = style({
	border: 0,
	clip: 'rect(1px, 1px, 1px, 1px)',
	clipPath: 'insert(50%)',
	height: '1px',
	margin: '-1px',
	width: '1px',
	overflow: 'hidden',
	position: 'absolute',
	wordWrap: 'normal',
});
```

So, to use the theme, first we import it (line `2`) and them we can just pull of the particular field we need. For example, in line `14`, we set use `theme.fontSize.size1` as the `font-size` for the body element.  This will pick `size1` based on whichever theme is active in the browser.  In our case it&rsquo;s the same value for light and dark themes, though we could add accessibility themes with different font sizing.  Notice if we had not defined `fontSize.size1` in our theme then we we would have an error in the editor now. With vanilla CSS or even some other CSS tooling we would be none the wiser!

In lines `33-35` you see the syntax for adding selectors.  This is equivalent to:

```css
a:hover, a:focus {
	text-decoration: none;
}
```

Finally, we can define we can export a group of styles as in lines `39`-`49`.  These are **screen reader styles** which we will use on a button.  We will see we can use the exported variable (`screenReaderText`) as a class attribute value on a DOM element. That applies this set of styles to that element. That probably sounds more complicated than it is. It should be clearer when we see the button code!

## 🏕️ BaseLayout: Defining vanilla-extract Styles for an Astro Component

Our newsletter site uses Markdown for the newsletter content.  We just have one edition 😅.  The source is at `src/pages/index.md`.  This (and future newsletters) will use the `src/layouts/BaseLayout.astro` code as a layout.  This adds the HTML head, imports styles and so on and so forth!

```astro
---
import { container, contentWrapper, footer, intro, wrapper } from '~/layouts/BaseLayout.css';
import Banner from '~components/Banner.svelte';
import Button from '~components/Button.svelte';
import '~styles/fonts.css';
import '~styles/global.css';
import { lightThemeClass } from '~styles/themes.css';

export interface Props {
	frontmatter: {
		description: string;
		title: string;
	};
}

const {
	frontmatter: { description, title },
} = Astro.props as Props;
---

<html lang="en-GB">
	<head>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width" />
		<link rel="icon" href="/favicon.ico" sizes="any" />
		<link rel="icon" href="/icon.svg" type="image/svg+xml" />
		<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
		<link rel="manifest" href="/manifest.webmanifest" />
		<title>Newsletter</title>
		<meta name="description" content={description} />
	</head>
	<body class:list={[container, lightThemeClass]}>
		<Button client:load />
		<div class:list={[wrapper]}>
			<main class:list={[contentWrapper]}>
				<h1>{title}</h1>
				<Banner />
				<p class:list={[intro]}>Here&rsquo;s the latest news&hellip;</p>
				<hr />
				<slot />
			</main>
			<footer class:list={[contentWrapper, footer]}>
				<hr />
				<p>You can <a href="#">unsubscribe here</a>.</p>
				<p>
					If someone forwarded this newsletter to you and you <a href="#"
						>like it, you can subscribe</a
					> (and then forward to your own friends)!
				</p>
			</footer>
		</div>
	</body>
</html>
```

This is a regular Astro file, so the top, frontmatter, section contains JavaScript logic we need to render the page.  Importantly, we **import `src/styles/global.css`** in line 6.  We are using import aliases and so can shorten the path to `~styles/global.css`.  We skip the `.ts` extension.  

In line `32` we  are applying a `container` class and a `lightThemeClass` to the `body` element.  The light theme is the default and can be updated from the toggle button code.  The <a aria-label="Open Astro docs on lastt list directives" href="https://docs.astro/build/en/reference/diretives-reference/#classlist">Astro `class:list` directive is syntactic sugar</a>, letting us push all the classes we want to apply into an array. More interesting though, is where do these variables come from?

![Astro Vanilla-Extract Styling: Dev tools screen capture shows body element selected in Inspector left pane. The are two class names and the second is seen in the middle pane with fonts and other styles matching the light theme.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/s8bd3wyf89uo2dhp3msr.png)

We import them in lines `2` and `7`.  In the screen capture, we have selected the `body` element in the left pane.  The two classes, `rfhp8c0` and `_1lheu5d10`, correspond exactly to the `container` (which we will see in a moment) and `lightTheme` class.  In fact, `_1lheu5d10` is highlighted on the right pane and you can see the fonts and (just above) the box shadow values.  I optimised the site build with subfont, which explains the `Work Sans` font appearing as `Work Sans__subset`.  You can learn how to do this with Astro in the <a href="https://rodneylab.com/astro-self-hosted-fonts/">video on Astro Self-hosted fonts</a>.

### BaseLayout Component styles 

As promised, next we see where we defined the `container` class styles as well as the other styles in the `BaseLayout` Astro template.  You will see the syntax is not too different from what we saw previously.

```typescript
import { style } from '@vanilla-extract/css';
import { theme } from '~/styles/themes.css';

export const container = style({
	display: 'flex',
	flexDirection: 'column',
	marginBlock: theme.spacing.size12,
	backgroundColor: theme.colours.surfaceAlt,
	color: theme.colours.text,
	maxWidth: '100%',
});

export const wrapper = style({
	paddingBlock: theme.spacing.size6,
	marginBlock: theme.spacing.size12,
	backgroundColor: theme.colours.surface,
	boxShadow: theme.boxShadow.lowElevation,
	'@media': {
		'(min-width: 48rem)': {
			width: theme.widths.maxWidth3XL,
		},
	},
});

export const footer = style({
	selectors: {
		[`${wrapper} &`]: {
			fontSize: theme.fontSize.size2,
		},
	},
});
```

Here in lines `18`-`22` you see an example of a **media query** in vanilla-extract. Then in line `26`-`30` we target the footer text and increase the font size. This looks a bit more involved, but shows how you can apply **nesting**.  So we are targetting the `footer` class nested within a `wrapper` (defined above) class. This transpiles to something equivalent to:

```css
.wrapper .footer {
  font-size: var(--font-size-2);
}
```

## ☀️ Theme Toggle Button: Defining vanilla-extract Styles for a Svelte Component

So, on the `body` element, we had a `lightThemeClass` which we said was a default.  Next we see the code where we update this initially and also when the user clicks the button.  This button component relies on JavaScript so we included it with the `client:load` directive in `BaseLayout.astro`.  Here is the button logic (`src/components/Button.svelte`):

```svelte
<script lang="ts">
	import { onMount } from 'svelte';
	import { button } from '~components/Button.css';
	import theme from '~shared/stores/theme';
	import { screenReaderText } from '~styles/global.css';
	import { darkThemeClass, lightThemeClass } from '~styles/themes.css';
	import MoonIcon from './MoonIcon.svelte';
	import SunIcon from './SunIcon.svelte';

	$: darkMode = $theme === 'dark';

	onMount(async () => {
		/* sync theme to user setting - this may cause a flash of the wrong theme and can be fixed with
		 * Edge functions: see https://www.learnwithjason.dev/blog/css-color-theme-switcher-no-flash
		 */
		if (darkMode && document.body.classList.contains(lightThemeClass)) {
			document.body.classList.replace(lightThemeClass, darkThemeClass);
		}
	});

	function handleClick() {
		theme.set(darkMode ? 'light' : 'dark');
		if (typeof window !== 'undefined') {
			if (darkMode) {
				document.body.classList.replace(darkThemeClass, lightThemeClass);
			} else {
				document.body.classList.replace(lightThemeClass, darkThemeClass);
			}
		}
	}
</script>

<button aria-pressed={darkMode} class={button} on:click={handleClick}
	><span class={screenReaderText}>{darkMode ? 'Disable dark mode' : 'Enable dark mode'}</span
	>{#if darkMode}<SunIcon />{:else}<MoonIcon />{/if}</button
>
```

We use a Svelte store to keep track of theme. This syncs to local storage and can be handy to remember the preference for the user&rsquo;s next visit to the site.  We sketch over the details here, but we see exactly this use case in the <a aria-label="Open the Svelte Local Storage video" href="https://rodneylab.com/using-local-storage-sveltekit/">Svelte Local Storage video</a>.

When the component first mounts, we check what the theme should be (lines `12`-`19`).  The default is light, so if the user prefers dark we just replace the `lightThemeClass` attribute on the `body` element with `darkThemeClass`.

The `handleClick` function essentially does this task when the user clicks the toggle button.  It swaps between `lightThemeClass` and `darkThemeClass` just depending on which theme is active.

The important **takeaway is** that we just need to swap the `lightThemeClass` for the `darkThemeClass` to change theme in the browser.

Although it is not much of an abstraction to apply theme state code to Preact or other other libraries, let me know if you would like to see a Preact working example using Signals to manage state as an example.

Finally, we see the `screenReaderText` class in action in line `34`. So all we had to do was import it from the global styles file then place it on the `button` element.

## 🙌🏽 Astro Vanilla-Extract Styling: Wrapping Up

In this post, we saw how to add Astro vanilla-extract styling.  In particular, we saw:

- how to use the **setup vanilla-extract** for Astro,
- how to create **theme contracts** for more maintainable and robust code,
- adding a **dark/light theme toggle** using vanilla-extract and local storage.

We only scratched the surface of what you can do with vanilla-extract here and there is a <a href="https://www.lekoarts.de/javascript/writing-performant-css-with-vanilla-extract">fantastic in-depth tutorial</a> by Lennart if you are hungry for more.

You can see the <a aria-label="Open the Astro vanilla extract Git Hub repo" href="https://github.com/rodneylab/astro/tree/main/demos/astro-vanilla-extract">full code for this project in the Rodney Lab GitHub repo</a>. I do hope you have found this post useful! I am keen to hear what you are doing with Astro and ideas for future projects.  Also let me know about any possible improvements to the content above.

## 🙏🏽 Astro Vanilla-Extract Styling: Feedback

Have you found the post useful? Would you prefer to see posts on another topic instead? Get in touch with ideas for new posts. Also if you like my writing style, get in touch if I can write some posts for your company site on a consultancy basis. Read on to find ways to get in touch, further below. If you want to support posts similar to this one and can spare a few dollars, euros or pounds, please <a aria-label="Support Rodney Lab via Buy me a Coffee" href="https://rodneylab.com/giving/">consider supporting me through Buy me a Coffee</a>.

Finally, feel free to share the post on your social media accounts for all your followers who will find it useful. As well as leaving a comment below, you can get in touch via <a aria-label="Reach out on Twitter" href="https://twitter.com/messages/compose?recipient_id=1323579817258831875">@askRodney</a> on Twitter, <a href="https://toot.community/@rodney">@rodney@toot.community</a> on Mastodon and also the <a aria-label="Contact Rodney Lab via Telegram" href="https://matrix.to/#/%23rodney:matrix.org">#rodney</a> Element Matrix room. Also, see <a aria-label="Get in touch with Rodney Lab" href="https://rodneylab.com/contact/">further ways to get in touch with Rodney Lab</a>. I post regularly on <a aria-label="See posts on Astro" href="https://rodneylab.com/tags/astro/">Astro</a> as well as <a aria-label="See posts on svelte kit" href="https://rodneylab.com/tags/seo/">SEO</a>. Also <a aria-label="Subscribe to the Rodney Lab newsletter" href="https://newsletter.rodneylab.com/issue/latest-issue">subscribe to the newsletter to keep up-to-date</a> with our latest projects.