CSS modules are a means of organizing styles that allows structuring them parallel to component modules. Several modern frameworks such as Next.js, Vue/Nuxt, and Astro even include built-in support. In this post, we'll be looking at how we can make CSS modules into an even more powerful tool in conjunction with more accurate TypeScript typings.
CSS Module Usage Basics
If you haven't seen CSS modules before, the main premise is that they allow you to define code in CSS files with the .module.css
extension. Each module will be compiled into uniquely-prefixed class names to provide isolation from other CSS resources.
.myClass {
/* Styles here */
}
The resulting prefixed class names can then be referenced by importing the CSS module within JavaScript or TypeScript:
import styles from "./Foo.css";
export const Foo = () => (
<div className={styles.myClass}>Hello world</div>
);
Note: Camel-case class names are recommended in order to reference them easily within JS/TS.
Many frameworks also optionally support Sass, allowing you to define modules in .module.scss
files as well. Note that CSS modules and Sass' own module system are independent from one another, and you can take advantage of both simultaneously.
Here are some resources with more information about CSS modules:
- General information about CSS modules
- CSS modules in Next.js
- CSS modules in Astro
- CSS modules in Vue single-file components (SFCs)
CSS Modules and TypeScript
Unfortunately, in my experience with Next.js at the time of writing, CSS modules come with a vague { readonly [key: string]: string }
type out of the box. This is enough to make them workable in TypeScript, but doesn't provide any particular benefits.
If we were able to get more specific typings based on the contents of our CSS modules, we could catch typos and get autocomplete when consuming them in JS/TS modules, and perhaps even do far more powerful things with them...
Fortunately, there are a few projects that may help with this:
- typings-for-css-modules-loader (webpack loader)
- typed-css-modules (CLI)
- typed-scss-modules (CLI for SCSS, inspired by typed-css-modules)
There are undoubtedly multiple ways to get this working; I opted for typed-scss-modules
, since I was planning to take advantage of some Sass features such as mixins, and I'm using Next.js which already configures CSS modules itself by default.
Running typed-scss-modules
with Next's Dev Server
I used an approach akin to webpack-shell-plugin-next
to run typed-scss-modules
along with next dev
, by adding the following to my next.config.js
:
// Prevent running typed-scss-modules more than once
let isTapped = false;
class TypedScssModulesPlugin {
apply(compiler) {
if (isTapped) return;
compiler.hooks.afterPlugins.tap("TypedScssModulesPlugin", () => {
require("child_process").spawn(
"npm", ["run", "tsm:watch"], { stdio: "inherit" }
);
});
isTapped = true;
}
}
const nextConfig = {
// ...
webpack(config, { dev, webpack }) {
if (dev) {
config.plugins.push(
new TypedScssModulesPlugin(),
new webpack.WatchIgnorePlugin({
paths: [/scss\.d\.ts$/]
})
);
}
// ...
return config;
},
};
module.exports = nextConfig;
The above config relies on a scripts
entry in package.json
with the following definition:
"tsm:watch": "typed-scss-modules components --includePaths styles --exportType default --watch"
In the above arguments, components
is the folder where all of my .module.scss
files reside, and styles
is where I have common Sass modules that I want to be able to @use
without specifying a path.
Now when you run next dev
, you should see messages about .module.scss
file changes being detected and .module.scss.d.ts
files being generated. After they've been generated, you should see improvements to intellisense for CSS module imports - namely, it should show you exactly what class names are available, and report nonexistent class name references as errors.
Building Component Props from CSS Module Typings
Working autocomplete and typo prevention are a great start, but the use case that sent me down this path was more ambitious. I was working on building up a UI library to replace a runtime CSS-in-JS solution to reduce overhead, but wanted to replicate some of its concepts, such as color schemes, sizes, and variants (different styles of the same component, e.g. solid vs. outlined).
I wondered, could I get to the point where typings for each separate prop are generated based on the SCSS with no manual repetition required across TS and SCSS files? Could I maybe even define this all within a single CSS module, in order to guarantee that each "dimension" out-specifies base styles?
Fortunately, with some digging and experimenting, the answer was yes!
The Component Design
Let's say we want to design a button component that allows simultaneously specifying the following:
- Color scheme, either blue or red
- Size, either small or medium
- Variant, either solid or outline
The SCSS
We'll start with the following class names. Note that all dimensions' styles are nested to guarantee they out-specify base styles - in my experience with Next.js at least, this can end up being necessary because builds can scramble CSS selector declaration order.
.button {
/* Base styles */
&.variantSolid {
/* Common styles for variant */
&.colorBlue {
/* Color-scheme-specific styles for variant */
}
&.colorRed {
/* Color-scheme-specific styles for variant */
}
}
&.variantOutline {
/* Repeat the same structure as above */
}
&.sizeSm {
/* Styles for small size */
}
&.sizeMd {
/* Styles for medium size */
}
}
The Prop Typings
With these styles in place and their typings generated, we want to define props for each dimension. Ideally, we want to build their types based on the generated CSS module typings, in such a way that we don't have to manually keep them in sync across TypeScript and SCSS.
Notice that I used a common prefix for all of the class names for each dimension. This will enable TypeScript to do all the work of picking out the appropriate values for us, by combining a few very useful but possibly-obscure features:
- String template literal type inference
-
infer
withinextends
for template string types (the most specific docs for this I could find are from one of TypeScript's "What's New" articles, not a handbook page, unfortunately) - The
Uncapitalize
utility type
The following type allows us to pass it an entire styles
type and get back a string union type with only the keys having a particular prefix, with the prefix removed and the first letter of the remaining string changed to lowercase:
export type PrefixedKeys<Map extends {}, Prefix extends string> =
Extract<
keyof Map,
`${Prefix}${string}`
> extends `${Prefix}${infer S}`
? Uncapitalize<S>
: never;
This allows us to define our dimensions as follows:
import styles from "./Button.module.scss";
type ButtonColorScheme =
PrefixedKeys<typeof styles, "color">;
type ButtonSize =
PrefixedKeys<typeof styles, "size">;
type ButtonVariant =
PrefixedKeys<typeof styles, "variant">;
export interface ButtonProps {
colorScheme?: ButtonColorScheme;
size?: ButtonSize;
variant?: ButtonVariant;
}
export const Button = ({
colorScheme = "blue",
size = "md",
variant = "solid"
}) => (...);
This will allow us to optionally specify these dimensions when creating instances of the component:
<Button colorScheme="red" variant="outline">
Remove
</Button>
The Implementation
Hopefully this seems pretty exciting already, but there's one remaining hurdle: due to how specific the typings in styles
are, if you attempt to casually reattach the provided string to the prefix, you're likely to get TypeScript errors. How do we implement the actual bindings of these values to members of styles
in a type-safe way?
Fortunately, we can define another function to help reconstruct the typings for this:
export const prefixValue =
<P extends string, S extends string>(prefix: P, value: S) =>
`${prefix}${value[0].toUpperCase()}${value.slice(1)}` as `${P}${Capitalize<S>}`;
When we pass the prefix and value to this function, it explains to TypeScript that the result is the specified prefix followed by the specified string with its first letter capitalized, effectively accomplishing the reverse of how we picked the class names out of styles
to create our dimensions' typings before.
We can use this function to implement our component's className
prop as follows (for simplicity's sake, this assumes usage of the classnames package):
return (
<button
className={classNames(
styles.button,
styles[prefixValue("color", props.colorScheme)],
styles[prefixValue("size", props.size)],
styles[prefixValue("variant", props.variant)],
props.className // Allow passing through more classes
)}
>
{children}
</button>
);
Conclusion
In this post, I aimed to demonstrate how with the right tools, CSS modules and TypeScript can be used as a basis for configurable core UI components, with no runtime CSS-in-JS required. With developers becoming increasingly-conscious of the performance penalties incurred by runtime CSS-in-JS, this is one potential alternative worth considering.
Top comments (1)
Thank you for your great article!
I'm trying to use
sassOptions.prependData
in mynext.config.js
file to automatically import Sass files into my modules. However, when I run the npm command, I receive an error indicating that certain variables are undefined. I've added the plugin toafterPlugins
hook, but it hasn't resolved the issue.