Sandro Roth

Chainable class names for CSS Modules in React

Published on

The className property in React accepts only a single string value. If you need multiple classes and conditional classes (add class x if y is true) you need to create an array, add class names depending on the condition and join the names together. There are third party utility functions like classnames that make this easier.

Just for fun, let's build our own utility function that works with chainable class names and CSS Modules. That is how it will look like:

<p className={$.paragraph.center.large(isLarge)}>nice!</p>

CSS Modules

What are CSS Modules?

CSS Modules lets you write CSS that is scoped locally to your component. This will make your CSS rules predictable and easier to maintain. CSS Modules are supported in React with create-react-app and in Vue 3 out of the box.

In CSS modules all your class names will be transformed into unique class names. You can choose short names without worrying about name collisions. All your component styles are scoped locally and don't affect other components.

Transformation of CSS Modules

A JS object will be generated that maps each readable class name (e.g. Paragraph) to its unique class name (e.g. App__Paragraph__a3mdu). This object can then be used in the component:

import css from "./component.module.css"; // That will import the object

function Component() {
  return <p className={css.Paragraph}></p>;
}

Using classnames utility

Working with multiple classes and conditional classes in React is not pretty nice out of the box. You need to do something like this:

function Component({ isLarge }) {
  const className = [css.Paragraph];
  isLarge && className.push(css.large);
  return <p className={className.join(" ")}></p>;
}

A utility function like classnames makes it easier. It's function that accepts multiple class names, arrays and objects for conditional classes.

In the following example the paragraph will have the class Paragraph (or the transformed unique name for that class respectively) and, if isLarge is truthy, the class large.

import cn from "classnames";
import css from "./styles.module.css";

function Component({ isLarge }) {
  return <p className={cn(css.Paragraph, { [css.large]: isLarge })}></p>
}

Even with the classnames utility function we have some overhead of creating the final class name. The classnames package supports a bind version which results in less code but removes any autocomplete suggestions that you otherwise have with tools like typescript-plugin-css-modules.

Chainable class names

This is what we will build:

import css from "component.module.css";

// Our utility function ccn (chainable class name)
const $ = ccn(css); // Bind your class names (sorry jQuery)

function Component({ isLarge }) {
  return <p className={$.Paragraph.large(isLarge).center()}>nice!</p>;
}

You can chain multiple classes together just with a dot, you can add conditional classes by calling the class name with an argument and it works with typescript-plugin-css-modules.

Let's see how we can implement this.

JavaScript Proxy

The main problem that we have is that we need to transform a flat object - the CSS Module object is just a key-value object, mapping each class name to their unique class name - into a nested object where each combination of class names is supported. Both, $.foo.bar() and $.bar.foo() should work.

The magic behind the chainable class name utility is the JavaScript Proxy object. The proxy object lets you intercept operations for an object. This allows you to do crazy things. You can manipulate values, do validation or use it for reactivity as done in Vue.

With this we don't have to create a real object with a deeply nested structure for all possible permutations. Instead we just proxy the CSS Module object and we pretend that the object is nested.

Implementation

API

This is the API of the utility function:

// Just one class
// For the output (final class name string) you can either
// - call the name as a function without arguments
// - use the property "$"
$.Paragraph();
$.Paragraph.$;

// Multiple classes
$.Paragraph.large();
$.Paragraph.large.$;

// Conditional classes
$.Paragraph.large(isLarge)();
$.Paragraph.large(isLarge).$;

Here is a graphical representation of the structure.

Structure of the chainable class names utility

When using the object $. you can first add any of the available class names, e.g. $.Paragraph. Then you can get the output $.Paragraph.$ (or $.Paragraph()), add another class $.Paragraph.large or make the previous class conditional by calling it as a function $.Paragraph(condition).

TypeScript

Our utility function should be type-safe. So let's use TypeScript and create some types:

// That's our CSS Module object
declare type CssModuleObj = Record<string, string>;

// There are two different ways to get the final class name
// Use "$" or call it as a function without arguments
declare type CssModuleOutput = { $: string };
declare type CssModuleFnOutput = { (): string };

Next we need types for the object itself. This is a little bit more complex.

// The type for conditional classes. The function requires
// the condition as argument and returns an object to add more
// classes or to get the output
declare type CssModuleCondition<M extends CssModuleObj> = 
  (condition: unknown) => CssModuleChain<M>;

// An object with all classes as keys. Each class maps to
// the callable chain. That means it can either be called
// for conditional classes ($.foo(...)) or you can just add another
// class if it's not conditional ($.foo.bar)
declare type CssModuleClasses<M extends CssModuleObj> = { 
  [key in keyof M]: CssModuleCallableChain<M> 
};

// The chain combines the types above (an object with 
// all class names as key) and the output types
declare type CssModuleChain<M extends CssModuleObj> = 
  CssModuleClasses<M> &
  CssModuleOutput &
  CssModuleFnOutput;

// This extends the type above. In addition to all class names
// and the output property / function, the object itself
// is callable to support conditional classes.
declare type CssModuleCallableChain<M extends CssModuleObj> = 
  CssModuleChain<M> & CssModuleCondition<M>;

Let's take the previous graphic and combine it with these types:

Structure of the chainable class names with their types

Utility function

Now that we have all types we can create our utility function. It accepts a CSS Module as argument and returns an object of type CssModuleClasses.

Our function returns a proxy with a handler that implements the get function. When you now access the first class it will call the init function.

function ccn<M extends CssModuleObj>(mod: M): CssModuleClasses<M> {
  function init(initialClass: string): CssModuleCallableChain<M> {
    // ...
  }

  return new Proxy<CssModuleClasses<M>>(mod as any, {
    get: (_, prop: string) => init(prop),
  });
}

Now let's implement the init function. We first create a Set to store all chained classes. We also implement a buildFinalClassName function that maps each chained class to their unique class name and joins them together.

function init(initialClass: string): CssModuleCallableChain<M> {
  const classNames = new Set<string>();
  let latestClassName: string = initialClass;

  function buildFinalClassName() {
    latestClassName && classNames.add(latestClassName);
    return Array.from(classNames)
      .map((cn) => mod[cn])
      .filter((cn) => !!cn)
      .join(" ");
  }
}

The classNames set is created inside the init function. This ensures that two separate sets are created when you access $.foo and $.bar.

Next we create two proxy objects. The first one is callableChainProxy. This proxy supports conditional classes meaning that you can call it with a condition to decide if the class you accessed just now should be added or not. In addition you can call it without arguments to get the final class name, access $ which also returns the final class name or chain another class.

The second proxy is chainProxy. This proxy is similar to the first one but does not support conditional classes, meaning you cannot call it with an argument (but you can call it without arguments to get the final class name).

function conditionFn(condition?: unknown) {
  // ...
};

const handler: ProxyHandler<CssModuleCallableChain<M>> = {
  get(_, prop: string) {
    // ...
  }
}
  
const callableChainProxy = new Proxy<CssModuleCallableChain<M>>(
  conditionFn as any,
  handler
);

const chainProxy = new Proxy<CssModuleChain<M>>(
  buildFinalClassName as any,
  handler
);

return callableChainProxy;

Let's implement the conditionFn. The function first checks if it has received any arguments. If not, we return the final class name (usage: $.example()). If there is an argument, we check if it's truthy or not and either add or remove the class name. That class that you accessed is stored in the latestClassName variable. We then return the chainProxy to let you add another class.

function conditionFn(condition?: unknown) {
  if (arguments.length === 0) {
    return buildFinalClassName()
  };

  condition
    ? classNames.add(latestClassName) 
    : classNames.delete(latestClassName);
  latestClassName = '';

  return chainProxy;
}

And here is the implementation of the proxy handler. If you access $ it returns the final class name. Otherwise it stores the class name in a variable named latestClassName and returns the callableChainProxy to let you either add another class or to call the current class with a condition (that's why we store the class name in the variable which will be used by the conditionFn function).

const handler: ProxyHandler<CssModuleCallableChain<M>> = {
  get(_, prop: string) {
    if (prop === "$") return buildFinalClassName();
    latestClassName = prop;
    return callableChainProxy;
  },
};

Proxy improvements

The root proxy has your CSS Module object as target. If you call Object.keys($) you will get an array with all available class names. In contrast, Object.keys($.Paragraph) will return an empty list. Why? Because the other two proxy objects have a function as target and Object.key(function() {}) also returns an empty array.

We could improve our proxy handler and implement has, ownKeys and getOwnPropertyDescriptor. But since I think it's not really a use case to use Object.keys on a chained class (or the in operator) we stick with the simple version we have.

Performance

Each time when you use $ you get the same root proxy. When you then access a class, the init function will be called and it creates two proxy objects. In a component with a lot of elements this will results in many proxy objects.

This behavior ensures that every chain you build has its own classNames set and its own proxy objects. Every chain is independent from each other and you could do something like this:

const $ = ccn({ one: "one", two: "two", three: "three" });

const x = $.one; // Start a chain
const y = $.two; // Start another chain

x.three(true); // Extend the first chain

console.log(x.$);
console.log(y.$);

This will output "one three" and "two" which is correct. This only works because of the separated className sets. Is this really a use case we need to support? I don't think so. In reality you will build the chain directly on the element, like this:

<div className={$.one.three(true)}>
  <p className={$.two()}></p>
</div>

With this restriction in mind we can improve the performance by reusing the classNames set and proxy objects. We remove the init function and clean the classNames set whenever a new chain is created.

function ccn<M extends CssModuleObj>(mod: M): CssModuleClasses<M> {
  // We removed the init function
  // We start with an empty set
  const classNames = new Set<string>();
  let latestClassName: string = "";

  const handler: ProxyHandler<CssModuleCallableChain<M>> = {
    get(_, prop: string) {
      if (prop === "$") return buildFinalClassName();

      // Add the previous class to the list when chaining another class
      if (latestClassName) classNames.add(latestClassName);
      latestClassName = prop;
      return callableChainProxy;
    },
  };

  return new Proxy<CssModuleClasses<M>>(mod as any, {
    get: (_, prop: string) => {
      // Clear the list when a new chain is created
      classNames.clear();
      latestClassName = prop;
      return callableChainProxy;
    },
  });
}

With this solution, we create always three proxy objects and one classNames set, no matter how many chaines you create. We just need to keep in mind that a chain needs to be completed before a new one can be created.

We can also implement a check and throw an error in this case:

function buildFinalClassName() {
    latestClassName && classNames.add(latestClassName);
    const finalName = Array.from(classNames).map(cn => mod[cn]).filter(cn => !!cn).join(' ');

    // Chain completed => clean up
    classNames.clear();
    latestClassName = '';
    return finalName;
}

return new Proxy<CssModuleClasses<M>>(mod as any, {
  get: (_, prop: string) => {
    // Check if there is an uncompleted chain
    if (classNames.size > 0 || latestClassName) {
      throw new Error('Uncompleted class name chain');
    }

    latestClassName = prop;
    return callableChainProxy;
  }
});

Final Solution

That's it. We can now use our utility function:

const $ = ccn(css);

function Component({ isLarge }) {
  return <p className={$.Paragraph.center.large(isLarge)}> </p>;
}

And you still get autocomplete suggestions: Autocompletion in VS Code

Demo

You can find a demo and the complete code on GitHub.

Conclusion

CSS Modules are a great way for locally scoped styling as an alternative to the various CSS-in-JS solutions. As React only accepts a string as class name it's recommended to use a utility function that supports multiple classes and conditionals.

We built a small utility function for chaining class names. It provides a short and easy-to-use syntax, supports conditional classes and does not affect type safety as with other solutions.