14
.
11
.
2023
24
.
04
.
2018
Ruby on Rails
Tutorial
Backend
Frontend

CSS Modules in Rails

Adam Król
Frontend Developer

One of the biggest changes in rails 5.1 was the introduction of new Webpacker gem. It add possibility to starts new app with full webpack 2 and yarn support by adding just -webpack option. After that we have out of the box integration with some modern javascript goodies like React, Angular and Vue. Not too bad, hm? I really encourage you too check webpacker docs if you haven't done it yet. This gem has a lots of cool features that makes writing and managing app-like javascript modules in Rails easy. One of those is support for CSS Modules.

Why should I care?

If you ever wrote some bigger app you probably realized how hard it is to manage CSS when the list of views grows very fast. We have some helping tools like preprocessors or postcss and usually, you try to separate your CSS into smaller files to keep them organized and easy to read. But there is one big problem with CSS - it scopes everything globally. It is easy to create conflicts. Let's say you have some javascript plugin that uses the same .header class name. This plugin's style overwrites your style and things are getting messy. You always have to think about possible conflicts. What happened if I remove this class? Does it break something? I'm sure most of you have this kind of question before. CSS Modules tries to deal with this problems by creating class names separately for each file containing style for one element. I don't want to elaborate how CSS Modules works in details if you want you can read more about it here.

CSS Modules in Rails

CSS Modules were created to work with JS. It's easy to use it with javascript modules. But what about static pages or apps with server-side rendering? You can use PostCSS-modules plugin to do this, but I found it not so elegant as CSS Modules itself is. Fortunately, we have webpacker now and after some small addings, we can make it work. So let's start.

1. Enable CSS Modules in webpack

For css files webpacker uses css-loader which support CSS Modules - you can enable it by adding modules: true option to css-loader.

If you use webpacker below version 3.0

Webpacker has all loaders preconfigure out of the box. You can find them in config/webpack/loaders folder.

module.exports = {
  test: /^((?!\.global).)*(scss|sass|css)$/,
  use: ExtractTextPlugin.extract({
    fallback: 'style-loader',
    use: [
      { loader: 'css-loader',
        options: {
          minimize: env.NODE_ENV === 'production',
          modules: true,
        }
      },
    ]
  })
}

If you use webpacker 3.0 or greater

The majority of webpacker related config was moved to @rails/webpacker npm package. We no longer have config/webpack/loaders folder. According to readme if you'd like to specify additional or different options for a loader, you should edit config/webpack/environment.js and provide options object to override. You can find the complete solution in webpacker readme. I'm just pasting interesting part below.

const { environment } = require('@rails/webpacker');
const merge = require('webpack-merge');

const myCssLoaderOptions = {
  modules: true,
  sourceMap: true,
};

const CSSLoader = environment.loaders.get('sass').use.find(el => el.loader === 'css-loader');

CSSLoader.options = merge(CSSLoader.options, myCssLoaderOptions);

module.exports = environment;

When you check your CSS source files in browser dev tools, you will see that all class names have been replaced by hashes similar to this .fHTtBTE5DH7Ixp5lrXDS_

2. Configure css-loader

Those "hasherized" names are created via css-loader using interpolateName method from webpack's loaders-utils and then can be used on JS side by CSS Modules. To use it on Rails side we need to find a way to create our own hash structure and replace classes in rails views with this hash. Luckily, css-loader has getLocalIdent option which is a function to generate custom class name based on a different schema. It looks like this:

getLocalIdent: (context, localIdentName, localName, options) => {
  return 'whatever_random_class_name';
}

Inside getLocalIdent we can return another function called generateScopedName:

const generateScopedName = (localName, resourcePath) => {
  const componentName = resourcePath.split('/').pop().replace('.scss', '');

  return Buffer.from(componentName).toString('base64').replace(/\W/, '') + '__' + localName;
};

generateScopedName takes two arguments localName (css selector) and resourcePath (path to stylesheet in which this selector is used) and then mix this two in our new selector like this: [styleSheetNameEncodedWithBase64]__cssSelector. So if you have stylesheet call header and selector called .list it will generate: aGVhZGVy__list

The final configuration file should look like this:

For webpacker < 3.0

const generateScopedName = (localName, resourcePath) => {
  const componentName = resourcePath.split('/').pop().replace('.scss', '');

  return Buffer.from(componentName).toString('base64').replace(/\W/, '') + '__' + localName;
};

module.exports = {
  test: /^((?!\.global).)*(scss|sass|css)$/,
  use: ExtractTextPlugin.extract({
    fallback: 'style-loader',
    use: [
      { loader: 'css-loader',
        options: {
          minimize: env.NODE_ENV === 'production',
          modules: true,
          getLocalIdent: (context, localIdentName, localName, options) => {
            return generateScopedName(localName, context.resourcePath );
          },
        }
      },
    ]
  })
}

and for webpacker 3.0 or greater

const { environment } = require('@rails/webpacker');
const merge = require('webpack-merge');

const generateScopedName = (localName, resourcePath) => {
  const componentName = resourcePath.split('/').pop().replace('.scss', '');

  return Buffer.from(componentName).toString('base64').replace(/\W/, '') + '__' + localName;
};

const myCssLoaderOptions = {
  modules: true,
  sourceMap: true,
  getLocalIdent: (context, localIdentName, localName, options) => {
    return generateScopedName(localName, context.resourcePath );
  },
};

const CSSLoader = environment.loaders.get('sass').use.find(el => el.loader === 'css-loader');

CSSLoader.options = merge(CSSLoader.options, myCssLoaderOptions);

module.exports = environment;

3.Create Rails helper

Ok, so we have our stylesheets hashed, but we still need to change our views to use those new class names. We can use rails helper for that:

module CssModulesHelper
  def css_module_class(name, selector)
    "#{encode_selector_name(selector)}__#{name}"
  end

  def encode_selector_name(name)
    Base64.encode64(name).gsub(/\W/, "")
  end
end

It basicaly does the similar things like getLocalIdent. You can use it in your view like this:

<div class="<%= css_module_class("header", "list") %>">

and it renders as: html <div class="aGVhZGVy__list"> Component here is your stylesheet name and selectors are arguments for m.style method. Now your class names in html generated by rails should be equal to those in your stylesheets.

Summary

Please keep in mind that above solution is just the starting point, probably you will need to change it a little bit to use it in the project. But I also encourage you to play with CSS modules as it can really help you keep your stylesheets clean and simple.

Adam Król
Frontend Developer

Check my Twitter

Check my Linkedin

Did you like it? 

Sign up To VIsuality newsletter

READ ALSO

How to become a Ruby Certified Programmer Title image

How to become a Ruby Certified Programmer

14
.
11
.
2023
Michał Łęcicki
Ruby
Visuality
Vector Search in Ruby - Paweł Strzałkowski

Vector Search in Ruby

17
.
03
.
2024
Paweł Strzałkowski
ChatGPT
Embeddings
Postgresql
Ruby
Ruby on Rails
LLM Embeddings in Ruby - Paweł Strzałkowski

LLM Embeddings in Ruby

17
.
03
.
2024
Paweł Strzałkowski
Ruby
LLM
Embeddings
ChatGPT
Ollama
Handling Errors in Concurrent Ruby, Michał Łęcicki

Handling Errors in Concurrent Ruby

14
.
11
.
2023
Michał Łęcicki
Ruby
Ruby on Rails
Tutorial
Recap of Friendly.rb 2024 conference

Insights and Inspiration from Friendly.rb: A Ruby Conference Recap

02
.
10
.
2024
Kaja Witek
Conferences
Ruby on Rails

Covering indexes - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Ruby on Rails
Postgresql
Backend
Ula Sołogub - SQL Injection in Ruby on Rails

The Deadly Sins in RoR security - SQL Injection

14
.
11
.
2023
Urszula Sołogub
Backend
Ruby on Rails
Software
Michal - Highlights from Ruby Unconf 2024

Highlights from Ruby Unconf 2024

14
.
11
.
2023
Michał Łęcicki
Conferences
Visuality
Cezary Kłos - Optimizing Cloud Infrastructure by $40 000 Annually

Optimizing Cloud Infrastructure by $40 000 Annually

14
.
11
.
2023
Cezary Kłos
Backend
Ruby on Rails

Smooth Concurrent Updates with Hotwire Stimulus

14
.
11
.
2023
Michał Łęcicki
Hotwire
Ruby on Rails
Software
Tutorial

Freelancers vs Software house

02
.
10
.
2024
Michał Krochecki
Visuality
Business

Table partitioning in Rails, part 2 - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Backend
Postgresql
Ruby on Rails

N+1 in Ruby on Rails

14
.
11
.
2023
Katarzyna Melon-Markowska
Ruby on Rails
Ruby
Backend

Turbo Streams and current user

29
.
11
.
2023
Mateusz Bilski
Hotwire
Ruby on Rails
Backend
Frontend

Showing progress of background jobs with Turbo

14
.
11
.
2023
Michał Łęcicki
Ruby on Rails
Ruby
Hotwire
Frontend
Backend

Table partitioning in Rails, part 1 - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Postgresql
Backend
Ruby on Rails

Table partitioning types - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Postgresql
Backend

Indexing partitioned table - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Backend
Postgresql
SQL Views in Ruby on Rails

SQL views in Ruby on Rails

14
.
11
.
2023
Jan Grela
Backend
Ruby
Ruby on Rails
Postgresql
Design your bathroom in React

Design your bathroom in React

14
.
11
.
2023
Bartosz Bazański
Frontend
React
Lazy Attributes in Ruby - Krzysztof Wawer

Lazy attributes in Ruby

14
.
11
.
2023
Krzysztof Wawer
Ruby
Software

Exporting CSV files using COPY - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Postgresql
Ruby
Ruby on Rails
Michał Łęcicki - From Celluloid to Concurrent Ruby

From Celluloid to Concurrent Ruby: Practical Examples Of Multithreading Calls

14
.
11
.
2023
Michał Łęcicki
Backend
Ruby
Ruby on Rails
Software