Skip to content

Chainable class names for CSS Modules in React

React accepts a single string as className. What if you want to set multiple classes or based on some condition? Let's build a utility function to make class names chainable, conditional and type-safe.

Published
Jan 31, 2021
Updated
Oct 22, 2021
Reading time
11 min read

What's the problem?

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.

const className = ["a", isAdmin ? "b" : ""].join(" ");
return <button className={className}>...</button>;

It works but is not very handy. There are third party utility functions like classnames that make this easier. But we can do it better!

What we'll build

We will build our own utility function that works with chainable class names that can be conditionally applied. That is how it will look like:

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

This will add the classes paragraph, center and - if isLarge is truthy - the class large. The utility function will work with CSS Modules, so the actual class names are hashed.

What are CSS Modules?

If you are familiar with CSS Modules you can skip this section.

CSS Modules lets you write CSS that is scoped locally to your component. It makes your class names unique by adding a hash value to them.

Transformation of CSS Modules

Further it generates a mapping between your class name and the final class name that includes the hash. When you import the CSS file in your JSX file you will get that mapping as a JS object. You can then use that in your component to get the actual class name.

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

function MyButton() {
  // css.Button returns the unique class name "Button__a3mdu"
  return <button className={css.Button}>...</button>;
}

Existing solutions

Working with multiple classes and conditional classes in React is not pretty nice out of the box as we saw at the beginning of this article.

Utility functions are useful to make this task a bit easier, like the npm package classnames. 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 type-safety that you otherwise have with tools like typescript-plugin-css-modules.

Solution overview

This is what we will build:

import css from "component.module.css";

// Our utility function ccn (chainable class names)
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.

The magic: 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 names utility is the JavaScript Proxy object. The proxy object lets you intercept operations on 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
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
type CssModuleOutput = { $: string };
type CssModuleFnOutput = { (): string };

Next we need types for the object itself. This is a 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
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)
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
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.
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 I guess it's not really a use case to use Object.keys on a chained class (or the in operator), so 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 result 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 of 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.$); // one three
console.log(y.$); // two

This will output "one three" and "two" which is correct. This only works because of the separated classNames 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 always create three proxy objects and one classNames Set, no matter how many chains 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 full code on GitHub.

Summary

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.