· 16 min read

linaria - A Zero-Runtime CSS-in-JS Solution

This article was auto-translated from Chinese. Some nuances may be lost in translation.

Introduction

Today, css-in-JS has become a common development solution. In front-end development, there are several reasons why this approach has gradually become mainstream:

  • Compared to naming conventions like BEM and OOCSS, CSS-in-JS mostly tackles the problem at the tooling level, fundamentally resolving CSS naming collisions.
  • Previously, when writing styles, engineers wanted programmatic and modular features in CSS (such as loops, nested CSS, functions, etc.), so they mostly used SASS for development.
  • In modern front-end development that emphasizes interactivity and user experience, developers want JavaScript and CSS to be interconnected—such as passing arguments or dynamically adjusting variables.

In React, the most popular solution is probably styled-components.

styled-components primarily leverages tagged template literals and JavaScript APIs to inject styles dynamically at runtime, achieving what developers desire as mentioned above.

const Title = styled.h2`
  font-size: 24px;
  color: ${props => props.color};
`;

const Component = () => {
  return <Title color="red">Hello World</Title> 
};
  • Modular: All component CSS class names are hashed, eliminating worries about naming conflicts. One component, one style fits the component-driven development philosophy.
  • Programmatic: Because styles are written in JavaScript, running loops or conditionals is completely seamless.
  • Dynamic parameter passing: For example, in the snippet above, color can be determined via a prop.

Of course, the downside is also obvious: relying on runtime declarations hurts overall app rendering performance. In general scenarios it might be tolerable, but according to this article The unseen performamance costs of modern CSS-in-JS libraries in React Apps, dynamic CSS can be a performance beast. The article points out that rendering 50 divs with styled-components in React was almost twice as slow.

So interestingly enough, on average, the CSS-in-JS implementation is 56.6% more expensive in this example. Let’s see if things are different in production mode. The timings of the re-renders in production mode can be seen below:

There are several main reasons: for instance, in styled-components, every time a styled component is created, Context Consumer reads are required, which impacts performance; another reason is that styled-components needs to perform housekeeping at runtime—when props change, it may have to regenerate class names and recompute style rules.

Although CSS-in-JS solutions improved the developer experience and solved existing CSS problems, they inevitably introduced a runtime burden. Therefore, developers created linaria, a CSS-in-JS library that emphasizes zero runtime.

What is linaria?

linaria-logo@2x

Since dynamically tweaking styles adds a performance overhead, while writing all styles in CSS files lacks flexibility, is there a balanced compromise? That brings us to linaria.

linaria is Zero-runtime CSS in JS library.

linaria is a CSS-in-JS library that emphasizes zero runtime. Its key features include:

  • Write CSS in JS, but with zero runtime
  • Pass props dynamically just like styled-components
  • Source map support
  • Write logic for CSS via JavaScript as usual
  • Preprocessor support

You might feel a bit fatigued: why do new things keep popping up in the CSS-in-JS space?

But I think this is a good thing. Dissatisfaction with the status quo and proposing improvements for pain points is simply the nature of engineers. Of course, the path taken might not necessarily be the optimal solution in the end, but it is precisely through these attempts that we can move step by step toward better solutions.

How to Use linaria (Taking React as an Example)

Linaria doesn’t strictly require React, but we will use React as our example here.

Preparing Webpack and Babel Configurations

yarn add --dev webpack webpack-cli webpack-dev-server mini-css-extract-plugin css-loader file-loader babel-loader @linaria/webpack-loader
yarn add --dev @babel/preset @babel/core @babel/preset-env @babel/preset-react
yarn add @linaria/core @linaria/react @linaria/babel @linaria/shaker
yarn add react react-dom

Setting Up the webpack.config File

The official docs provide a basic configuration file, which you can adapt as needed:

const webpack = require('webpack');
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

const dev = process.env.NODE_ENV !== 'production';

module.exports = {
  mode: dev ? 'development' : 'production',
  devtool: 'source-map',
  entry: {
    app: './src/index',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/dist/',
    filename: '[name].bundle.js',
  },
  optimization: {
    noEmitOnErrors: true,
  },
  plugins: [
    new webpack.DefinePlugin({
      'process.env': { NODE_ENV: JSON.stringify(process.env.NODE_ENV) },
    }),
    new MiniCssExtractPlugin({ filename: 'styles.css' }),
  ],
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: [
          { loader: 'babel-loader' },
          {
            loader: '@linaria/webpack-loader',
            options: { sourceMap: dev },
          },
        ],
      },
      {
        test: /\.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
            options: {
              hmr: process.env.NODE_ENV !== 'production',
            },
          },
          {
            loader: 'css-loader',
            options: { sourceMap: dev },
          },
        ],
      },
      {
        test: /\.(jpg|png|gif|woff|woff2|eot|ttf|svg)$/,
        use: [{ loader: 'file-loader' }],
      },
    ],
  },
  devServer: {
    contentBase: [path.join(__dirname, 'public')],
    historyApiFallback: true,
  },
};

Configuring .babelrc

{
  "presets": [
    "@babel/preset-env",
    ["@babel/preset-react", {
      "runtime": "automatic" 
    }],
    "module:@linaria/babel"
  ]
}

Creating the src/index.js File

import React from 'react';
import { render } from 'react-dom';
import App from './App';

render(<App />, document.body)

Creating the src/App.js File

import { styled } from '@linaria/react';

const Title = styled.h1`
  font-size: ${props => props.size || 10}px;`;

const App = () => {
  return <div>
    <Title size={10}>Hello World</Title>
  </div>
}

export default App;

Adding Scripts

{
  "scripts": {
    "dev": "webpack --mode=development serve",
    "build": "webpack --mode=development",
    "build:prod": "webpack --mode=production"
  }
}

Adding public/index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Test</title>
  <!-- Compiled by linaria -->
  <link rel="stylesheet" href="/styles.css">
</head>
<body>
  
  <script src="/app.bundle.js"></script>
</body>
</html>

After this, running npm run dev to start the dev server should work smoothly.

Execution Results

If it runs successfully, you should see the compiled HTML and CSS look something like this:

<h1 size="20" class="tm0as6w" style="--tm0as6w-0:20px;">Hello World</h1>
.tm0as6w {
  font-size: var(--tm0as6w-0);
}

You can observe a few things:

  • The content of font-size is defined by a CSS variable.
  • The rendered component adds CSS variables to inline styles.

In other words, linaria itself does not inject <style> tags at runtime. Instead, it pre-compiles the CSS and only updates CSS variables at runtime. Compared to dynamically updating styles or tracking each dynamic tag ID, this approach incurs far less runtime performance overhead.

What Happens When Props Update?

What if a prop update causes a CSS change? For instance, what happens if we change size={20} to size={21} in our example?

In linaria, we only need to update the computed result to the CSS variable. The concept looks roughly like this:

const Title = styled.h1`
  font-size: ${props => props.size || 10}px;`;
// After Babel parsing
const Title = styled('h1')({
  ...,
  vars: {
    'tm0as6w-0': [props => props.size || '', 'px'];
  }
})

styled.h1 transforms into this form after Babel transpilation. The vars property is generated at compile time. At runtime, when props change, the CSS variable in the inline style is updated, thereby achieving dynamic updates.

How Linaria Works

The detailed mechanics are documented in the repo’s HOW IT WORKS. Here, we will cover as many implementation details as possible.

Experienced engineers might have already noticed that Linaria must be used with babel. This is because Linaria relies on a compilation step to correctly parse and extract styles. If you use the css or styled APIs directly without Babel (or without configuring @linaria/webpack-loader), you will see a warning:

Uncaught Error: Using the "styled" tag in runtime is not supported. 
Make sure you have set up the Babel plugin correctly. See <https://github.com/callstack/linaria#setup>

The source code looks like this:

if (process.env.NODE_ENV !== 'production') {
      if (Array.isArray(options)) {
        // We received a strings array since it's used as a tag
        throw new Error(
          'Using the "styled" tag in runtime is not supported. Make sure you have set up the Babel plugin correctly. See <https://github.com/callstack/linaria#setup>'
        );
      }
}

To understand Linaria’s mechanics, we first need to understand the basic concepts of Babel. Babel can fundamentally be split into two steps:

  • Syntax parsing
  • Syntax transformation

Syntax Parsing

First, let’s use AST explorer to inspect what the previous component looks like when parsed into an AST:

import { styled } from '@linaria/react';

const Title = styled.h1`
  font-size: ${props => props.size || 10}px;`;

_2020-12-07_15.20.45

You can check out the parsed AST here.

We can see that after parsing, this component is broken down into a VariableDeclaration.

Its Declarator is a MemberExpression (styled.h1), followed by a TemplateLiteral (the part wrapped in backticks) and expressions. Inside is an ArrowFunctionExpression (props => props.size || 10).

At this point, Linaria already has sufficient information to generate CSS class names and parse the styles.

Syntax Transformation

Linaria’s syntax transformation is primarily handled by a Babel plugin. The complete implementation logic can be referenced in packages/babel/src/evaluators/templateProcessor.ts.

Here is a brief explanation of the transformation steps:

  • Place the template literal strings into cssText.

  • Traverse all quasis (the split points between strings and expressions wrapped in ${}):

    • If the expression wrapped in ${} is not a function, it is evaluated at this point, and the computed value is placed into cssText.

      // Try to preval the value
      if (
        options.evaluate &&
        !(t.isFunctionExpression(ex) || t.isArrowFunctionExpression(ex))
      ) {
        const value = valueCache.get(ex.node);
      
        // Simplified
        if (value && typeof value !== 'function') {
          cssText += stripLines(loc, value);
          return;
        }
      }
    • Place the expression info into interpolations and add a CSS variable:

       if (styled) {
        const id = `${slug}-${i}`;
      
        interpolations.push({
          id,
          node: ex.node,
          source: ex.getSource() || generator(ex.node).code,
          unit: '',
        });
      
        cssText += `var(--${id})`;
      }
    • Transform the styled.h1 form into compiled code:

      • Add name and class: here name is the component’s original name, and class is the class name generated by Linaria after compilation.
      • Add vars: for each expression inside ${}:
      props.push(
        t.objectProperty(t.identifier('name'), t.stringLiteral(displayName!))
      );
      
      props.push(
        t.objectProperty(t.identifier('class'), t.stringLiteral(className!))
      );
      
      props.push(
        t.objectProperty(
            t.identifier('vars'),
            t.objectExpression(
              Object.keys(result).map((key) => {
                const { id, node, unit } = result[key];
                const items = [node];
      
                if (unit) {
                  items.push(t.stringLiteral(unit));
                }
      
                return t.objectProperty(
                  t.stringLiteral(id),
                  t.arrayExpression(items)
                );
              })
            )
          )
        );
      }
      
      path.replaceWith(
        t.callExpression(
          t.callExpression(
            t.identifier(state.file.metadata.localName || 'styled'),
            [styled.component.node]
          ),
          [t.objectExpression(props)]
        )
      );

After transformation, the original code becomes:

const Title = styled('h1')({
  name: 'Title',
  class: 'tm0as6w',
  vars: {
    'tm0as6w-0': [props => props.size, 'px'],
  },
});

And a CSS file is generated:

.tm0as6w {
  font-size:var(--tm0as6w-0);
}

The styled Implementation

Next, let’s look at how styled is implemented internally. First, after the Babel transformation, our code has been converted to:

const Container = styled('h1')({
  name: 'Title',
  class: 'tm0as6w',
  vars: {
    'tm0as6w-0': [props => props.size, 'px'],
  },
});

Now let’s observe the implementation of styled (packages/react/src/styled.ts):

  1. Combine the compiled className with the original className:

    filteredProps.className = cx(
      filteredProps.className || className,
      options.class
    );
  2. If vars contains properties, iterate through them and assign CSS variables to style:

    for (const name in vars) {
        const variable = vars[name];
        const result = variable[0];
        const unit = variable[1] || '';
        const value = typeof result === 'function' ? result(props) : result;
    
        warnIfInvalid(value, options.name);
    
        style[`--${name}`] = `${value}${unit}`;
      }
    
      filteredProps.style = Object.assign(style, filteredProps.style);
    }
  3. Call React.createElement:

    if ((tag as any).__linaria && tag !== component) {
      // If the underlying tag is a styled component, forward the `as` prop
      // Otherwise the styles from the underlying component will be ignored
      filteredProps.as = component;
    
      return React.createElement(tag, filteredProps);
    }
  4. Assign the component’s original name to displayName (for easier debugging):

    (Result as any).displayName = options.name;

css

In addition to using styled provided by Linaria, you can also use the css API alone. The css API returns a class name and similarly generates a CSS file. For example, in the code below, these two declarations will generate two class names:

import { css, cx } from '@linaria/core';

const weight = css`
  font-weight: bold;
`;

const size = css`
  font-size: 12px;
`;

export default function App() {
  return <div className={cx(weight, size)}>Hello World</div>;
}

During code transformation, because the class names are already determined at compile time, these two variables actually become strings:

// After Babel transformation
const weight = "wm0as6w"; // class generated at build time
const size = "s13mnax5"; // class generated at build time

This css acts somewhat like a tag, telling Babel: “Hey, for any code wrapped in css, I want to analyze what’s inside and generate CSS.” Therefore, calling it at runtime will also throw an error: (https://github.com/callstack/linaria/blob/master/packages/core/src/css.ts)

export default function css(
  _strings: TemplateStringsArray,
  ..._exprs: Array<string | number | CSSProperties | StyledMeta>
): string {
  throw new Error(
    'Using the "css" tag in runtime is not supported. Make sure you have set up the Babel plugin correctly.'
  );
}

Things to Keep in Mind During Development

Linaria compiles CSS at build time, so it cannot do the following (when using the css API):

const size = css`
  font-size: ${props => props.size}px;
`;

It will throw the following error:

The CSS cannot contain JavaScript expressions when using the 'css' tag. To evaluate the expressions at build time, pass 'evaluate: true' to the babel plugin.

Nor can it successfully compile any variables that require runtime:

const Title = styled.h1`
  font-size: ${window.innerHeight}px;
`;

It will produce the following error:

Make sure you are not using a browser or Node specific API and all the variables are available in static context.
Linaria have to extract pieces of your code to resolve the interpolated values.
Defining styled component or class will not work inside:
  - function,
  - class,
  - method,
  - loop,
because it cannot be statically determined in which context you use them.
That's why some variables may be not defined during evaluation.

However, if it’s a function expression, it executes at runtime and will work without issues:

// This works fine
const Title = styled.h1`
  font-size: ${() => window.innerHeight}px;
`;

Additionally, some APIs can easily be confused with runtime evaluation, as in the example below (though you probably wouldn’t do this in real-world development):

const Title = styled.h1`
  font-size: 20px;
  .${Math.random()} {
    font-size: 20px;
  }
`;

This declaration generates classes at build time, so the CSS will look like this:

.tm0as6w {
    font-size: 20px;
}

.tm0as6w .0.7732446605094165 {
    font-size: 20px;
}

Rather than dynamically generating classes at runtime, the class name will not change unless you recompile.

To summarize: if what’s inside ${} is a function expression or arrow function expression, Linaria generates a CSS variable at build time, and the actual value is determined at runtime.

If what’s inside ${} is not a function expression or arrow function expression, Linaria tries to execute it at build time and places the content into the CSS file.

How to Debug?

Because Linaria modifies code at build time, it naturally supports source maps. Developers can easily see the original source definitions as long as the browser supports source maps.

Thoughts

We’ve covered many advantages of linaria, but there is no silver bullet in software development. Currently, linaria cannot be used solely at runtime—it must be paired with Babel for analysis to work. For most development, this shouldn’t be a major issue, as most projects already use Babel and Webpack to some extent.

Additionally, Linaria does not support theme, meaning you cannot do things like this in styled-components:

const Title = styled.h1`
  color: ${props => props.theme.MAIN};
`

Or truly dynamic styles (because they cannot be statically analyzed):

const Title = styled.h1`
	font-size: 30px;
	${isMobile && css`
    font-size: ${props => props.mobileSize}px;
  `}
`;

Another point is that CSS variables are not supported in IE 11. This depends on your target environment tradeoffs, but I highly recommend giving linaria a try and understanding the ideas behind it.

I’m not sure if you’ve noticed the trend, but more and more developer tools are trying to tackle problems at compile time—such as Svelte mentioned previously, and linaria today. With the help of compilation, a significant amount of runtime performance overhead can be avoided, and certain errors can be caught at compile time to prevent runtime bugs from surfacing.

With the help of JavaScript, it is also easy to check whether a certain CSS rule is being used:

import { css } from '@linaria/core'

const text = css`
  font-size: 20px;
`;

const App = () => {
  return <div>hello world</div>
}

export default App;

For example, if the text variable is unused, Linaria will not actually emit the CSS. In other words, linaria supports tree-shaking out of the box.

In the future, if you encounter tricky performance issues during development, consider approaching them from the perspective of compilation—you might just discover whole new possibilities.

References

Postscript

Thanks to former colleague @kai for the input. In fact, the zero-runtime concept isn’t entirely novel; during the CSS-in-JS battles a few years ago, several libraries highlighted zero-runtime, such as astroturf or early versions of emotion. However, Linaria took this philosophy a step further, bringing the API much closer to styled-components, which may be one of the reasons for its rising popularity.

Related Posts

Explore Other Topics