Lightweight CSS Module theming.

When I was building SunkenAtlas.com I soon got a feature request for a less garish colour scheme. This is a perfect use-case for adding theme support. My first instinct was to use next-themes to switch the class attribute on the html element, and then my individual components' CSS files could simply say:

.page { background: (some garish gradient); } .plain .page { background: none; }

This didn't work because I'm using CSS modules, which will convert .plain into a nice namespaced selector for that component's use only - but because the html class is applied above that component in the hierarchy, the code applying .plain to html would have no idea what prefix/namespace to use. Indeed, every component that used the theme would have a different namespace. So that whole approach just doesn't work with CSS modules.

Instead, the official way to do theming is to pass a different CSS module into the component depending on the theme. This is nice because every theme exists in its own file, which is good for separation of concerns. Your component's layout can be defined in component.module.css, and then you can write a bunch of theme files like component-dark.module.css and component-garish.module.css with individual overrides. Simply import all the themes in the parent component, and then only pass the currently active theme into the component itself.

However in my use-case, I didn't feel that putting my theme overrides into a whole other file was really justified. We're talking about just 2 or 3 alternative styles, not a whole theme ecosystem. Moreover, from a readability point of view I find it's nicer to have the styles for the default theme and the alternative theme in the same file. Also it feels weird to have to import the themes in the parent. To be clear, though, if you're going to have 15 different themes, each with lots of style rules in them, then separate theme CSS files is definitely the way to go. It just seems overkill for a few tiny overrides.

What I wanted was a way to keep using CSS modules, not have messy theme switching logic peppered into all my components, and just define a few theme overrides in the same file as the rest of the component's styles.

For this, I wrote a helper function called applyTheme which takes the namespaced classNames from the CSS module, and rewrites their values depending on the theme:

// utils/applyTheme.js const applyTheme = (theme, classNames) => { if (!theme) { return classNames; } const themed = {...classNames}; Object.keys(classNames).forEach(className => { const themedClass = classNames[`${theme}_${className}`]; if (themedClass) { themed[className] += ` ${themedClass}`; } }); return themed; };

To understand how this works, it's useful to quickly recap on how CSS modules work. When you import styles from a standard CSS file your code gets access to the literal class names in that file:

// main.css .main { width: 100%; } // SomeComponent.js import classes from 'main.css'; const SomeComponent = () => { return <div className={classes.main}>I am main</div>; // ^ gets the class 'main' };

With CSS modules, the class name gets magically rewritten to 'ComponentName_main_uniqueString' instead, which prevents collisions with classNames from other components.

// main.module.css .main { width: 100%; } // SomeComponent.js import classes from 'main.module.css'; const SomeComponent = () => { return <div className={classes.main}>I am main</div>; // ^ gets the class 'SomeComponent_main_R4ND0M' // .main is also rewritten in the CSS file // so it gets the right styles. };

What applyTheme does is rewrite the values but not the keys of those imported classes, to take into account the current theme. This way our component logic carries on using classes.main everywhere, but when you change theme, the value of that property gets appended with a theme-specific version:

// main.module.css .main { width: 100%; border: 1px solid red; } .dark_main { border: 1px solid black; } // SomeComponent.js import classNames from 'main.module.css'; import applyTheme from '@utils/applyTheme'; const SomeComponent = () => { const [theme, setTheme] = useTheme(); const classes = applyTheme(theme, classNames); return <div className={classes.main}>I am main</div>; // ^ when theme is 'dark', gets the classes; // 'SomeComponent_main_R4ND0M SomeComponent_dark_main_R4ND0M' // If .dark_main was not found in the CSS file, or if theme is unset; // just gets 'SomeComponent_main_R4ND0M' }

Now we get all the main styles, and tack on the dark theme when active. The only caveat is that next-themes doesn't support a blank/empty theme, so I set the default theme to a single space instead, this way I don't have to prefix .main as .default_main and I can just setTheme(' '); to remove the dark theme.

I hope you found this helpful. A repo with full example code is available on github. I've kept the commits nicely separated, so you can see how we go from empty repo -> basic Next app -> install next-themes -> add applyTheme.

PS: You might be more familiar with import styles from 'some-file.css'; and <div className={styles.something} /> but during developing this code, I realised that this is a poor choice of name, as the styles variable doesn't contain styles at all, it contains classNames. So I prefer to call it classes.