Nowadays, Atomic CSS is gaining more and more attention. Compared to the traditional CSS writing method where each component has one CSS class. With Atomic CSS, each CSS class uniquely corresponds to a separate CSS rule. As the number of components grows, more and more CSS rules can be reused. The final CSS product is much smaller, allowing for a quantum leap in page load speed.

Atomic CSS-in-JS

History of CSS authoring methods

Before we introduce Atomic CSS, let’s review the evolution of how CSS is written.

SMACSS

SMACSS (Scalable & Modular Architecture for CSS), is a theory of CSS proposed by Jonathan Snook. Its main principles are 3.

  • Categorizing CSS Rules (for CSS categorization)
  • Naming Rules
  • Minimizing the Depth of Applicability

Rules Classification

SMACSS classifies rules into five categories: Base, Layout, Module, State, and Theme.

Base rules hold the default styles. These default styles are basically element selectors, but can also contain attribute selectors, pseudo-class selectors, child selectors, and sibling selectors. Essentially, a base style defines how an element should look at any location on the page.

Layout rules split the page into sections, each of which may have one or more modules. As the name implies, this category is mainly used for the layout of the page as a whole or one of its areas. Modules are the reusable, modular parts of our design. Illustrations, sidebars, article lists, etc. are all modules. State rule defines how our module or layout should appear in a particular state. It may define how a module or layout should be displayed on different displays. It may also define how a module may look like on different pages (e.g. home page and inner page). Theme rules are similar to status rules and define the appearance of a module or layout. This is how many websites implement features such as “dark mode” and “switching themes”.

Naming Rules

After separating the rules into five categories, a naming convention is needed. Naming conventions make it easy to immediately know which category a style belongs to and what role it plays in the overall page. In a large project, we may split a style into several files, and naming conventions make it easier to know which file the style belongs to.

It is recommended to use prefixes to distinguish layout, module, state, etc. rules. For example, using the layout- prefix for layout rules and the is- prefix for state rules is a good choice.

Minimize adaptation depth

Try not to rely on the structure of the document tree to write styles. This will make our styles more flexible and easier to maintain.

BEM

BEM (Block Element Modifier) is a front-end CSS naming methodology proposed by the Yandex team. It is a simple yet very useful naming convention. It makes front-end code easier to read and understand, easier to collaborate with, easier to control, more robust and clear, and tighter.

The pattern of the BEM naming convention is as follows.

1
2
3
4
5
6
7
8
.block {
}

.block__element {
}

.block--modifier {
}
  • block represents a block, which is used for the component body.
  • element represents an element (also called a sub-component) of the block, which is the main member of the block composition.
  • modifier represents a modifier of the block, indicating different states and versions. The distinction is made using -- for “blocks” and “elements”, which are called “block modifiers” and “element modifiers” respectively.

The reason why __ and -- are used to separate different parts of a name is that if more than one word appears in a part it needs to be separated by -, so as to avoid confusion.

CSS Modules

As the number of CSS class names in a large front-end project grows, it is inevitable that there will be class name conflicts, which is why CSS Modules were created - to prevent conflicts by adding hashes to CSS class names and so on to produce unique names.

CSS Modules is not an official CSS standard, nor is it a browser feature, but rather a way of scoping CSS class names and selectors using some build tools such as Webpack.

Utility-First CSS

At a time when most of the CSS methodologies used in traditional large-scale projects are the above-mentioned OOCSS, SMACSS, BEM, and other “semantic CSS” solutions that focus on “separation of concerns”, the Utility-First CSS concept is gaining attention from the community. The most well-known and typical of these is Tailwind CSS.

Instead of putting component styles in a class like Semantic CSS, Utility-First CSS provides us with a toolbox of different functional classes that we can mix together and apply to page elements. This has the following benefits.

  • not having to get hung up on naming class names.
  • the simpler the function of the class, the higher the reuse rate, which can reduce the final package size.
  • No global style pollution issues.
  • and so on.

However, there are some shortcomings.

  • The content of the class attribute is too long.
  • Problems related to the order of CSS rule insertion.
  • the role of the component cannot be known by the semantic class name.
  • The build product is too large without compression.

A new era is here - Atomic CSS-in-JS

Taking the Utility-First CSS introduced in the previous article a step further, Atomic CSS has come to the forefront.

The idea behind Atomic CSS is the opposite of the old “separation of concerns” idea. The use of Atomic CSS actually couples the structure and style layers in a way that is largely accepted in modern CSS-in-JS codebases, as described further below.

Atomic CSS can be seen as the ultimate abstract version of Utility-First CSS, where each CSS class corresponds to a single CSS rule. But with such a complex set of CSS rules, writing Atomic CSS class names by hand is not a good solution. So Atomic CSS-in-JS was born, which can be thought of as “Atomic CSS for automation”.

  • eliminating the need to manually design CSS class names.
  • the ability to extract the key CSS of a page and split the code.
  • can solve the classic CSS rule insertion order problem.

Disadvantages of the traditional CSS writing approach

Christopher Chedeau has been working to promote the CSS-in-JS philosophy in the React ecosystem. In many of his talks, he has explained several of the major problems with CSS.

A few major problems with CSS

  1. global namespace
  2. dependencies
  3. Useless code elimination
  4. Code compression
  5. shared constants
  6. Non-Deterministic Parsing
  7. isolation

While Utility-First CSS and Atomic CSS solve some of these problems, they do not solve all of them (especially the non-deterministic parsing of styles).

As an example: Tailwind CSS generates a lot of useless code when it is generated, causing the style file to grow in size.

1
<div class="before:bg-white before:p-4">content</div>

The generated style file looks like this.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
.before\:bg-white::before {
  content: var(--tw-content);
  --tw-bg-opacity: 1;
  background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}

.before\:p-4::before {
  content: var(--tw-content);
  padding: 1rem;
}

You can see that this file contains a lot of useless code, such as the repeated content: var(--tw-content).

Smaller build product

Traditional CSS writing methods cannot reuse CSS rules that are repeated between components, such as the several rules highlighted in the image below each lying in their corresponding CSS class.

css

This results in a linear correlation between CSS product size and the complexity of the project and the number of components.

However, with Atomic CSS, these rules are extracted for reuse.

Atomic CSS

As the number of components increases later, more and more CSS rules can be reused, and the final CSS product size is logarithmically related to the complexity of the project.

CSS product size is logarithmically related to the complexity of the project

Facebook shared their data: on the old site, the login page alone required 413 KiB of style files to be loaded, while after rewriting with Atomic CSS-in-JS, the entire site only had 74 KiB of style files, including the dark color mode.

Although the size of the HTML is significantly larger with Atomic CSS, the high redundancy of these class names makes it possible to use gzip to compress a significant portion of the size.

Handling the insertion order of CSS rules

Let’s go over this classic CSS rule insertion order problem again.

CSS rule insertion order problem

We all know that the last style to take effect is not the rule corresponding to the last class name, but the last rule inserted in the stylesheet.

So, how can this be handled in CSS-in-JS? The common practice is to filter out conflicting rules at the generation stage to avoid conflicts. Take for example the following component.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const styles = style9.create({
  card: {
    color: '#000000',
  },
  profileCard: {
    color: '#ffffff',
  },
});

const Component = () => (
  <div className={style9(styles.card, styles.profileCard)} />
);

The actual style of the filtered component is as follows.

1
color: #ffffff;

If you switch the order of styles.card and styles.profileCard in the component style, the filtered style will look like this.

1
color: #000000;

However, there are some shorthand rules in CSS that obviously won’t work if you just follow the rule names. Some libraries force developers not to use shorthand rules to avoid this problem, while others expand these shorthand rules into multiple rules and then filter them, for example margin: 10px can be split into margin-top: 10px, margin-right: 10px, margin-bottom: 10px and margin-left: 10px into four separate rules.

Classic implementations

Atomic CSS-in-JS implementations are available as Runtime and Pre-Compile. The advantage of Runtime is that it can dynamically generate styles, which is more flexible than the pre-compiled libraries below. The disadvantage is that operations such as Vendor Prefix need to be performed in Runtime, so the Bundle must carry the relevant dependencies, resulting in a larger size. The advantage of Pre-Compile is that the dependencies are not packaged and sent to the client, which improves performance. The disadvantage is that the precompilation process is highly dependent on static code analysis, so it is difficult to achieve dynamic style generation and combination.

Styletron

Styletron is a more typical runtime Atomic CSS-in-JS library developed by Uber that drives Uber’s website and H5 pages.

Styletron also provides a set of implementations of Styled Components, which can be used in the following ways.

1
2
3
4
5
6
7
8
import { styled } from 'styletron-react';

const Component = styled('div', {
  marginTop: '10px',
  marginBottom: '10px',
});

<Component />;

It is also possible to dynamically generate styles based on the value of prop.

1
2
3
4
5
const Component = styled('div', (props) => {
  return { color: props.$fraction < 0.5 ? 'red' : 'green' };
});

<Component $fraction={Math.random()} />;

Fela

Joining Styletron as a runtime Atomic CSS-in-JS library is Fela, developed by the former technical director of Volvo Cars, which drives the Volvo Cars website, Cloudflare Dashboard and Medium, among many other sites.

vanilla-extract

Stylex is a precompiled Atomic CSS-in-JS library from Meta (formerly Facebook) that has not yet been open sourced. However, due to Meta’s delay in open sourcing stylex, several open source implementations based on its ideas have emerged in the community, with vanilla-extract being the best known.

style9

Pre-compiled Atomic CSS-in-JS libraries based on the stylex idea include style9 and styleQ in addition to vanilla-extract.

compiled

Moving away from the stylex family, Atlassian also writes a pre-compiled Atomic CSS-in-JS library called compiled. However, there are many pitfalls in my practical use, which may lead to duplicate style generation, and its support for TypeScript is not as good as it could be. However, there are many techniques in the code implementation that can be useful.

Styled Components

compiled relies on a babel transformer to transform the code to insert styles.

In the packages/react/src/styled/index.tsx, you can see that @compiled/react contains an object named styled that is exported and immediately throws an error when it is accessed, indicating that the transformer is not working properly .

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
export const styled: StyledComponentInstantiator = new Proxy(
  {},
  {
    get() {
      return () => {
        // Blow up if the transformer isn't turned on.
        // This code won't ever be executed when setup correctly.
        throw createSetupError();
      };
    },
  }
) as any;

Then you can see that styled will be replaced by transformer and the corresponding entry logic is in packages/babel-plugin/src/babel-plugin.tsx file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
ImportDeclaration(path, state) {
  // 不是从 @compiled/react 导入的包不处理
  if (path.node.source.value !== '@compiled/react') {
    return;
  }

  // 记录导入的模块
  state.compiledImports = {};

  // 遍历导入数组中的所有元素
  path.get('specifiers').forEach((specifier) => {
    if (!state.compiledImports || !specifier.isImportSpecifier()) {
      return;
    }

    (['styled', 'ClassNames', 'css', 'keyframes'] as const).forEach((apiName) => {
      if (
        state.compiledImports &&
        t.isIdentifier(specifier.node?.imported) &&
        specifier.node?.imported.name === apiName
      ) {
        // 记录下导入后 API 的名称
        state.compiledImports[apiName] = specifier.node.local.name;
      }
    });
  });

  // 导入 @compiled/react/runtime 中的 API
  appendRuntimeImports(path);

  path.remove();
},

This code documents the introduction of @compiled/react and facilitates the processing below.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
TaggedTemplateExpression(path, state) {
  if (t.isIdentifier(path.node.tag) && path.node.tag.name === state.compiledImports?.css) {
    state.pathsToCleanup.push({ path, action: 'replace' });
    return;
  }

  if (
    t.isIdentifier(path.node.tag) &&
    path.node.tag.name === state.compiledImports?.keyframes
  ) {
    state.pathsToCleanup.push({ path, action: 'replace' });
    return;
  }

  if (!state.compiledImports?.styled) {
    return;
  }

  // 处理 styled component
  visitStyledPath(path, { context: 'root', state, parentPath: path });
},
CallExpression(path, state) {
  if (!state.compiledImports) {
    return;
  }

  if (
    t.isIdentifier(path.node.callee) &&
    (path.node.callee.name === state.compiledImports?.css ||
      path.node.callee.name === state.compiledImports?.keyframes)
  ) {
    state.pathsToCleanup.push({ path, action: 'replace' });
    return;
  }

  // 处理 styled component
  visitStyledPath(path, { context: 'root', state, parentPath: path });
},

The handling of TaggedTemplateExpression and CallExpression corresponds to the two different ways of calling in the documentation.

1
2
3
4
5
6
7
8
9
// 模板字符串
styled.a`
  color: blue;
`;

// 函数调用
styled.a({
  color: 'blue',
});

Follow the definition of the visitStyledPath function to find packages/babel-plugin/src/styled/index.tsx file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
export const visitStyledPath = (
  path: NodePath<t.TaggedTemplateExpression> | NodePath<t.CallExpression>,
  meta: Metadata
): void => {
  // 判断是否是支持的操作
  if (
    t.isTaggedTemplateExpression(path.node) &&
    hasInValidExpression(path.node)
  ) {
    throw buildCodeFrameError(
      `A logical expression contains an invalid CSS declaration. 
      Compiled doesn't support CSS properties that are defined with a conditional rule that doesn't specify a default value.
      Eg. font-weight: \${(props) => (props.isPrimary && props.isMaybe) && 'bold'}; is invalid.
      Use \${(props) => props.isPrimary && props.isMaybe && ({ 'font-weight': 'bold' })}; instead`,
      path.node,
      meta.parentPath
    );
  }

  // 提取样式信息
  const styledData = extractStyledDataFromNode(path.node, meta);
  if (!styledData) {
    // 没有样式信息
    return;
  }

  // 生成 CSS
  const cssOutput = buildCss(styledData.cssNode, meta);

  // 构建并替换节点
  path.replaceWith(buildStyledComponent(styledData.tag, cssOutput, meta));

  const parentVariableDeclaration = path.findParent((x) =>
    x.isVariableDeclaration()
  );
  if (
    parentVariableDeclaration &&
    t.isVariableDeclaration(parentVariableDeclaration.node)
  ) {
    const variableDeclarator = parentVariableDeclaration.node.declarations[0];
    if (t.isIdentifier(variableDeclarator.id)) {
      const variableName = variableDeclarator.id.name;
      parentVariableDeclaration.insertAfter(buildDisplayName(variableName));
    }
  }
};

Let’s look at the function extractStyledDataFromNode which extracts the style information using different methods depending on the situation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const extractStyledDataFromNode = (
  node: t.TaggedTemplateExpression | t.CallExpression,
  meta: Metadata
) => {
  // 使用模板字符串
  if (t.isTaggedTemplateExpression(node)) {
    return extractStyledDataFromTemplateLiteral(node, meta);
  }

  // 使用函数调用
  if (t.isCallExpression(node)) {
    return extractStyledDataFromObjectLiteral(node, meta);
  }

  // 提取不到信息
  return undefined;
};

The function to build a new node is defined in packages/babel-plugin/src/utils/ast-builders.tsx file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
export const buildStyledComponent = (
  tag: Tag,
  cssOutput: CSSOutput,
  meta: Metadata
): t.Node => {
  const unconditionalCss: string[] = [];
  const logicalCss: CssItem[] = [];

  cssOutput.css.forEach((item) => {
    if (item.type === 'logical') {
      logicalCss.push(item);
    } else {
      unconditionalCss.push(getItemCss(item));
    }
  });

  // 去重,只保留最后一个
  const uniqueUnconditionalCssOutput = transformCss(unconditionalCss.join(''));

  const logicalCssOutput = transformItemCss({
    css: logicalCss,
    variables: cssOutput.variables,
  });

  const sheets = [
    ...uniqueUnconditionalCssOutput.sheets,
    ...logicalCssOutput.sheets,
  ];

  const classNames = [
    ...[t.stringLiteral(uniqueUnconditionalCssOutput.classNames.join(' '))],
    ...logicalCssOutput.classNames,
  ];

  // 返回构建好的节点
  return styledTemplate(
    {
      classNames,
      tag,
      sheets,
      variables: cssOutput.variables,
    },
    meta
  );
};

As for the operation of constructing nodes, it is a simpler string splicing.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
const styledTemplate = (opts: StyledTemplateOpts, meta: Metadata): t.Node => {
  const nonceAttribute = meta.state.opts.nonce
    ? `nonce={${meta.state.opts.nonce}}`
    : '';
  const propsToDestructure: string[] = [];

  // 提取样式
  const styleProp = opts.variables.length
    ? styledStyleProp(opts.variables, (node) => {
        const nestedArrowFunctionExpressionVisitor = {
          noScope: true,
          MemberExpression(path: NodePath<t.MemberExpression>) {
            const propsToDestructureFromMemberExpression =
              handleMemberExpressionInStyledInterpolation(path);

            propsToDestructure.push(...propsToDestructureFromMemberExpression);
          },
          Identifier(path: NodePath<t.Identifier>) {
            const propsToDestructureFromIdentifier =
              handleDestructuringInStyledInterpolation(path);

            propsToDestructure.push(...propsToDestructureFromIdentifier);
          },
        };

        if (t.isArrowFunctionExpression(node)) {
          return traverseStyledArrowFunctionExpression(
            node,
            nestedArrowFunctionExpressionVisitor
          );
        }

        if (t.isBinaryExpression(node)) {
          return traverseStyledBinaryExpression(
            node,
            nestedArrowFunctionExpressionVisitor
          );
        }

        return node;
      })
    : t.identifier('style');

  let unconditionalClassNames = '',
    logicalClassNames = '';

  opts.classNames.forEach((item) => {
    if (t.isStringLiteral(item)) {
      unconditionalClassNames += `${item.value} `;
    } else if (t.isLogicalExpression(item)) {
      logicalClassNames += `${generate(item).code}, `;
    }
  });

  // classNames 为生成好的类名
  const classNames = `"${unconditionalClassNames.trim()}", ${logicalClassNames}`;

  // 此处的 <CC />, <CS /> 是上文中处理 import 时从 @compiled/react/runtime 中导入的组件
  return template(
    `
  forwardRef(({
    as: C = ${buildComponentTag(opts.tag)},
    style,
    ${unique(propsToDestructure)
      .map((prop) => prop + ',')
      .join('')}
    ...${PROPS_IDENTIFIER_NAME}
  }, ref) => (
    <CC>
      <CS ${nonceAttribute}>{%%cssNode%%}</CS>
      <C
        {...${PROPS_IDENTIFIER_NAME}}
        style={%%styleProp%%}
        ref={ref}
        className={ax([${classNames} ${PROPS_IDENTIFIER_NAME}.className])}
      />
    </CC>
  ));
`,
    {
      plugins: ['jsx'],
    }
  )({
    styleProp,
    cssNode: t.arrayExpression(
      unique(opts.sheets).map((sheet) => hoistSheet(sheet, meta))
    ),
  }) as t.Node;
};

This goes around in circles, pulling out the style of the component generated using the styled method and turning it into a compiled Atomic CSS-in-JS component.

css Prop

compiled first adds TypeScript definition of css prop, and then specialize this prop in the babel transform as with the styled component.

1
2
3
4
5
6
7
8
JSXOpeningElement(path, state) {
  if (!state.compiledImports) {
    return;
  }

  // 处理 css prop
  visitCssPropPath(path, { context: 'root', state, parentPath: path });
},

The handling of css prop looks much simpler than the complicated handling of styled components.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
export const visitCssPropPath = (
  path: NodePath<t.JSXOpeningElement>,
  meta: Metadata
): void => {
  let cssPropIndex = -1;
  const cssProp = path.node.attributes.find(
    (attr, index): attr is t.JSXAttribute => {
      if (t.isJSXAttribute(attr) && attr.name.name === 'css') {
        cssPropIndex = index;
        return true;
      }

      return false;
    }
  );

  // 不存在 css prop 就不进行处理了
  if (!cssProp || !cssProp.value) {
    return;
  }

  // 从 css props 中提取样式信息
  const cssOutput = buildCss(getJsxAttributeExpression(cssProp), meta);

  // 删除 css prop
  path.node.attributes.splice(cssPropIndex, 1);

  // 没有样式信息
  if (!cssOutput.css.length) {
    return;
  }

  // 构建并替换节点
  path.parentPath.replaceWith(
    buildCompiledComponent(
      path.parentPath.node as t.JSXElement,
      cssOutput,
      meta
    )
  );
};

The buildCompiledComponent function to build a new node is defined in packages/babel-plugin/src/utils/ast-builders.tsx file, this function mainly accomplishes the following operations.

  1. merges the existing className.
  2. process the styles in the css prop.
  3. generate the compiled Atomic CSS-in-JS component.

This splits the component’s css parameter into two parts – the static style and the className parameter value that is appended to the original component.

Other

Microsoft’s recently open-sourced Griffel supports both runtime and pre-compiled modes, and has better TypeScript support, which is not a bad choice. This library currently drives Microsoft’s official Fluent UI.

Summary

That’s all there is to know about Atomic CSS in this article.

Although Atomic CSS-in-JS is a new trend in the React ecosystem, it is important to think twice before using it - whether it fits the needs of your project, rather than using it blindly and sowing future maintenance hazards, but if there are obvious benefits to using it, then Why not?