Skip to content

How to Write Theme-Aware Styles with vanilla-extract


Created Nov 24, 2022 – Last Updated Nov 25, 2022

JavaScriptReact
Digital Garden

While rewriting my personal site (the one you’re currently on) from Chakra UI to vanilla-extract I had the need to create a utility for myself that allows me to write theme-aware styles. Chakra UI calls this conditional tokens but naming things is hard so here’s a concrete example of what I mean:

ts
{
color: {
default: 'gray.900',
_dark: 'gray.50',
}
}

I also used a similar pattern in my post How to Build an Advanced Multipart Component with Chakra UI for the component styles:

ts
import { mode } from "@chakra-ui/theme-tools"
// Stuff...
color: mode(`gray.900`, `gray.50`)(props),

In summary, I wanted to continue writing my styles in a way of having the light/dark mode under each CSS property instead of having separate style objects per theme. In this short post I want to share my utility function with you!

#Prerequisites

I won’t go into details on how to set up your project to be able to use this utility function. You can check out my post Writing Performant CSS with vanilla-extract for an introduction. You should have two themes created in your site.

#themeAwareStyles utility function

Here’s the theme-aware utility function ready for copy/paste:

ts
import type { CSSProperties } from "@vanilla-extract/css"
import type { CSSVarFunction } from "@vanilla-extract/private"
import type { Properties } from "csstype"
export const THEMES = ["light", "dark"] as const
const isObject = (value: unknown) =>
!!(value && typeof value === `object` && !Array.isArray(value))
const isString = (value: unknown) => typeof value === `string`
type CSSTypeProperties = Properties<number | (string & Record<string, unknown>)>
type CSSPropertiesWithModes<Modes extends string> = {
[Property in keyof CSSTypeProperties]:
| CSSTypeProperties[Property]
| CSSVarFunction
| Array<CSSVarFunction | CSSTypeProperties[Property]>
| Record<
Modes,
| CSSTypeProperties[Property]
| CSSVarFunction
| Array<CSSVarFunction | CSSTypeProperties[Property]>
>
}
type CSSPropertiesWithVars = CSSProperties & {
vars?: {
[key: string]: string
}
}
export type SelectorMap<Modes extends string = typeof THEMES[number]> = {
[selector: string]: CSSProperties | CSSPropertiesWithModes<Modes>
}
interface IThemeAwareStylesProps {
selectorMap: SelectorMap
defaultTheme: typeof THEMES[number]
alternateThemeClass: string
rootClass?: string
}
export const themeAwareStyles = ({
selectorMap,
defaultTheme,
alternateThemeClass,
rootClass = "",
}) => {
const selectors: Record<string, CSSPropertiesWithVars> = {}
const r = rootClass ? `${rootClass} ` : ""
const alternate = defaultTheme === `light` ? THEMES[1] : THEMES[0]
Object.entries(selectorMap).forEach(([selector, selectorStyle]) => {
Object.entries(selectorStyle).forEach(([property, cssOrObject]) => {
if (isObject(cssOrObject)) {
selectors[`${r}${selector}`] = {
...selectors[`${r}${selector}`],
[property]: cssOrObject[defaultTheme],
}
selectors[`html.${alternateThemeClass} ${r}${selector}`] = {
...selectors[`html.${alternateThemeClass} ${r}${selector}`],
[property]: cssOrObject[alternate],
}
} else if (isString(cssOrObject)) {
selectors[`${r}${selector}`] = {
...selectors[`${r}${selector}`],
[property]: cssOrObject,
}
}
})
})
return selectors
}

A few things to point out here:

  • If your theme names should be something different, you’ll want to change export const THEMES = ["light", "dark"] as const. You can also optionally change this to an enum, I’m using a const array as I’m using THEMES also in other functions.
  • You can use the SelectorMap type on the object you’ll use for the selectorMap argument.
  • CSSPropertiesWithModes is defining a lot of helper types as you can’t reach that deep into the vanilla-extract types. Maybe this changes in the future.
  • This function assumes that you place your theme classes onto the <html> DOM element (thus the html. CSS selector).

An explanation on the arguments:

  • selectorMap: Object of your styles
  • defaultTheme: Name of your default theme (in this case it can either be light or dark)
  • alternateThemeClass: The generated CSS class from vanilla-extract (e.g. const darkThemeClass = createTheme()) that should be used for the alternative theme
  • rootClass: This is optional. This way you can further scope the CSS styles

#Playground

You can play around with the function and its output in the box below:

themeAwareStyles
import { themeAwareStyles } from "./utils"

const defaultTheme = "light"
const darkThemeClass = "dark"
const selectorMap = { "&.active": { background: { light: "red", dark: "blue" } } }

export const output = themeAwareStyles({ 
  selectorMap,
  defaultTheme,
  alternateThemeClass: darkThemeClass,
  // rootClass: ".root",
})
Result

#Usage

You can use the themeAwareStyles utility function in the selectors key or with globalStyle. First, here’s an example with selectors:

ts
import { style } from "@vanilla-extract/css"
import { darkThemeClass } from "./themes/dark.css"
import { SelectorMap, themeAwareStyles } from "./utils"
const badgeStyles: SelectorMap = {
"&[data-lang='js']": {
background: `rgba(247, 223, 30, 0.5)`,
color: { light: `black`, dark: `rgb(247, 223, 30)` },
},
}
export const languageDisplayStyle = style({
fontWeight: 500,
selectors: {
...themeAwareStyles({
selectorMap: badgeStyles,
defaultTheme: `light`,
alternateThemeClass: darkThemeClass,
}),
},
})

Secondly, here’s the globalStyle example:

ts
import { globalStyle } from "@vanilla-extract/css"
import { darkThemeClass } from "./themes/dark.css"
import { SelectorMap, themeAwareStyles } from "./utils"
const proseBaseStyle: SelectorMap = {
strong: {
color: {
light: `red`,
dark: `blue`,
},
},
}
const preparedBaseStyles = themeAwareStyles({
selectorMap: proseBaseStyle,
defaultTheme: `light`,
alternateThemeClass: darkThemeClass,
})
Object.entries(preparedBaseStyles).forEach(([selector, selectorStyle]) => {
globalStyle(selector, selectorStyle)
})

#Where to go from here

Make the utility function your own or create a library out of it!

So far the function only supports two themes and its naming mostly assumes light/dark mode. An improved version could support n amount of themes for example.


Want to learn more? Browse my Digital Garden