How to Write Theme-Aware Styles with vanilla-extract
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:
{ 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:
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:
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 anenum
, I’m using aconst
array as I’m usingTHEMES
also in other functions. - You can use the
SelectorMap
type on the object you’ll use for theselectorMap
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 thehtml.
CSS selector).
An explanation on the arguments:
selectorMap
: Object of your stylesdefaultTheme
: Name of your default theme (in this case it can either belight
ordark
)alternateThemeClass
: The generated CSS class from vanilla-extract (e.g.const darkThemeClass = createTheme()
) that should be used for the alternative themerootClass
: 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:
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", })
#Usage
You can use the themeAwareStyles
utility function in the selectors
key or with globalStyle
. First, here’s an example with selectors
:
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:
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.