If you have any questions or feedback, pleasefill out this form
This post is translated by ChatGPT and originally written in Mandarin, so there may be some inaccuracies or mistakes.
Introduction
Currently, css-in-JS
has become a common development solution. In the realm of front-end development, this approach has gradually become mainstream for several reasons:
- Compared to naming methodologies such as
BEM
andOOCSS
, css-in-JS primarily addresses CSS naming conflicts through development tools. - In the past, engineers writing styles desired programmability and modularity within CSS (such as loops, nested CSS, functions, etc.), leading to widespread use of
SASS
for development. - In front-end development, where interactivity and user experience are crucial, there is a desire for JavaScript and CSS to connect, such as passing parameters and dynamically adjusting variables.
Among the available solutions in React
, the most popular one is likely styled-components.
styled-components
mainly utilizes Tagged template literals and the JavaScript API to insert styles dynamically during runtime, fulfilling the developers' desires mentioned above.
const Title = styled.h2`
font-size: 24px;
color: ${props => props.color};
`;
const Component = () => {
return <Title color="red">Hello World</Title>
};
- Modularization: All component CSS class names are hashed, eliminating concerns about naming conflicts. Each component has its own style, adhering to the concept of component-based development.
- Programmability: Since styles are written in JavaScript, using loops and conditional statements is straightforward.
- Dynamic Parameter Passing: For instance, in the example above, the color can be determined through a
prop
.
However, there are evident drawbacks. This reliance on runtime declarations can degrade the rendering performance of the entire app. While this may be acceptable in general scenarios, a study indicates that dynamic CSS can be a performance monster. The article points out that rendering 50 divs in React with styled-components
can be nearly 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 reasons for this, such as the need for Context Consumer
reading each time a styled component is created in styled-components
, which impacts performance. Additionally, styled-components
requires housekeeping actions at runtime, including regenerating class names and calculating style rules when props
change.
Although css-in-js solutions improve the developer experience and address existing CSS issues, they inevitably increase runtime overhead. As a result, some developers have created linaria
, emphasizing a zero-runtime CSS-in-JS library.
What is Linaria?
Since dynamically adjusting styles increases performance overhead, and writing all styles in CSS files lacks flexibility, is there a balanced solution? That's what we will discuss today with linaria
.
Linaria is a Zero-runtime CSS in JS library.
Linaria is a library that emphasizes a zero-runtime CSS-in-JS approach, featuring several characteristics:
- Write CSS in JS, but without runtime.
- Dynamic prop passing similar to
styled-components
. - Support for sourcemaps.
- Logic for
CSS
can still be written in JavaScript. - Support for preprocessors.
You might feel a bit frustrated with the constant emergence of new CSS-in-JS solutions.
But I see this as a positive development. The engineering nature is to propose improvements in response to dissatisfaction with the current state or existing issues. Of course, the final path taken may not be the optimal solution, but these attempts are necessary steps toward better solutions.
How to Use Linaria (Using React as an Example)
Essentially, linaria
does not have to be paired with React, but we will use React as an example here.
Setting Up Webpack and Babel
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
Configuring webpack.config.js
The official documentation provides a basic configuration file, which you can modify 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"
]
}
Adding src/index.js
import React from 'react';
import { render } from 'react-dom';
import App from './App';
render(<App />, document.body)
Adding src/App.js
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
should successfully start the dev server.
Execution Results
If executed successfully, you should see the compiled HTML and CSS looking 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 includes the CSS variable in the inline style.
This means that linaria
does not insert style
at runtime; instead, it pre-compiles the CSS and only updates the CSS variable at runtime. This approach has a significantly lesser impact on runtime performance compared to dynamically updating styles or tracking each dynamic tag ID.
What If Props Update?
What happens if updates to props
cause changes to the CSS? For instance, in the example, how do we handle updating size={20}
to size={21}
?
In linaria
, we only need to update the computed result to the CSS variable. The principle works like this:
const Title = styled.h1`
font-size: ${props => props.size || 10}px;`;
// After babel transformation
const Title = styled('h1')({
...,
vars: {
'tm0as6w-0': [props => props.size || '', 'px'];
}
})
styled.h1
is transformed by babel into this form. The vars
are established at compile time. When props
change at runtime, it updates the inline style's CSS variable, achieving a dynamic update effect.
Linaria Principles
The detailed principles are documented in the repo under HOW IT WORKS. Here, we will cover as many implementation details as possible.
Experienced engineers might notice that linaria
must be used alongside babel
. This is because linaria
relies on a layer of compilation to correctly parse and split styles. If you directly use the css
or styled
API without the necessary babel setup, you will encounter 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 how linaria
works, you first need to grasp the basic concepts of babel. Essentially, babel can be broken down into two steps:
- Syntax Parsing
- Syntax Transformation
Syntax Parsing
First, let’s use AST explorer to parse the component we discussed into its AST representation:
import { styled } from '@linaria/react';
const Title = styled.h1`
font-size: ${props => props.size || 10}px;`;
The parsed AST can be viewed here.
You can see that after parsing, this component can be divided into a VariableDeclaration
.
Its Declarator
is a MemberExpression
(styled.h1), followed by a TemplateLiteral
(the part wrapped in `\), along with its
expressions. Inside, there is an
ArrowFunctionExpression` (props ⇒ props.size || 10).
At this point, linaria
already has enough information to generate the CSS class name and parse the styles.
Syntax Transformation
The linaria
syntax transformation primarily occurs through the babel plugin, and the entire implementation logic can be referenced in packages/babel/src/evaluators/templateProcessor.ts.
Let’s briefly explain the transformation steps involved.
-
The strings in the template literal are placed into
cssText
. -
It iterates over all
quasi
(the parts wrapped in${}
along with the string separators).-
If the expression wrapped in
${}
is not a function, it will be evaluated at this point, and the computed value will be placed intocssText
.// 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; } }
-
It places the information from the expression into
interpolations
and adds 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})`; }
-
It transforms the
styled.h1
into compiled code:- It adds
name
andclass
: here,name
will be the original name of the component, andclass
is the generated class name from linaria. - It adds
vars
: 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)] ) );
- It adds
-
After parsing, the original code becomes:
const Title = styled('h1')({
name: 'Title',
class: 'tm0as6w',
vars: {
'tm0as6w-0': [props => props.size, 'px'],
},
});
And it will generate a CSS file:
.tm0as6w {
font-size:var(--tm0as6w-0);
}
Styled Implementation
Next, let's look at how the styled
function is implemented. After babel transformation, our code already looks like this:
const Container = styled('h1')({
name: 'Title',
class: 'tm0as6w',
vars: {
'tm0as6w-0': [props => props.size, 'px'],
},
});
Now let's observe the implementation of styled
here:
-
It combines the compiled
className
with the originalclassName
.filteredProps.className = cx( filteredProps.className || className, options.class );
-
If properties exist in
vars
, it iterates over them and assignsstyle
to CSS variables.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); }
-
It calls
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); }
-
It assigns the original component name to
displayName
(for easier debugging).(Result as any).displayName = options.name;
CSS
In addition to using the styled
API provided by linaria, you can also use the standalone css API. The css
API returns a class name, and it also generates a CSS file. For example, in the following code, 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>;
}
When the code is transformed, the class names are determined at build time, so these two variables will actually become strings:
// After babel transformation
const weight = "wm0as6w"; // Class generated at build time
const size = "s13mnax5"; // Class generated at build time
The css
API acts as a marker telling babel, "Hey, any code wrapped in css
, I want to analyze it to create CSS." Therefore, calling it at runtime will also produce 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.'
);
}
Considerations in Development
Linaria compiles CSS at build time, so certain things are not feasible (when using the css
API):
const size = css`
font-size: ${props => props.size}px;
`;
This will trigger 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.
Similarly, any variables requiring runtime will also fail to compile smoothly:
const Title = styled.h1`
font-size: ${window.innerHeight}px;
`;
This 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 is a function expression, it will execute at runtime without issues:
// This works
const Title = styled.h1`
font-size: ${() => window.innerHeight}px;
`;
Additionally, some APIs may inadvertently confuse runtime contexts, as seen in the following example (though this is unlikely to occur in actual development):
const Title = styled.h1`
font-size: 20px;
.${Math.random()} {
font-size: 20px;
}
`;
This declaration will generate a class
at build time, so the CSS will look like this:
.tm0as6w {
font-size: 20px;
}
.tm0as6w .0.7732446605094165 {
font-size: 20px;
}
Rather than dynamically generating the class at runtime, the class name remains unchanged until a recompilation occurs.
In summary, if the ${}
contains a function expression or arrow function expression, linaria generates a CSS variable at build time, and its actual content is determined at runtime.
If ${}
does not contain a function expression or arrow function expression, linaria attempts to execute it at build time and place the content into the CSS file.
How to Debug?
Since Linaria modifies the code at build time, it naturally supports source maps. Developers can easily see the original code definitions as long as the browser supports source maps.
Reflections
While I have highlighted various advantages of linaria
, there is no silver bullet in software development. Currently, linaria
cannot function independently at runtime; it must be paired with babel for analysis to work. This shouldn't be a significant issue for most developers, as most projects utilize babel and webpack to some extent.
Additionally, linaria does not support theme
, meaning it cannot replicate the functionality in styled-components
like:
const Title = styled.h1`
color: ${props => props.theme.MAIN};
`
Or truly dynamic styles (as that would prevent static analysis):
const Title = styled.h1`
font-size: 30px;
${isMobile && css`
font-size: ${props => props.mobileSize}px;
`}
`;
Another point is that CSS variables are currently not supported in IE 11. This part is, of course, a trade-off depending on the development scenario, but I highly recommend everyone try out linaria
and understand the thinking behind it.
Have you noticed a trend? There are increasingly more development applications attempting to tackle issues from a compile-time perspective, such as Svelte
introduced earlier and linaria
discussed today. By leveraging compilation, significant runtime performance savings can be achieved, and some errors can be checked at compile time to avoid runtime bugs.
With the help of JavaScript, it's also easy to check whether a certain CSS 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 variable text
is not used, linaria will not generate the corresponding css
, meaning it can effectively perform tree-shaking.
In the future, if you encounter challenging performance issues during development, consider approaching them from a compile-time perspective; you might discover new solutions.
References
- WHY linaria
- zero runtime css in js
- The unseen performance costs of modern CSS-in-JS libraries in React apps
- Use CSS variable instead of React Context
Postscript
Thanks to former colleague @kai for the insights. The concept of zero-runtime is not particularly innovative; several years ago during the CSS-in-JS wars, some libraries emphasized zero-runtime, such as astroturf or earlier versions of emotion. However, it seems that linaria has further implemented this idea, making its API closer to styled-components
, which might be one of the reasons for its growing popularity.
If you found this article helpful, please consider buying me a coffee ☕ It'll make my ordinary day shine ✨
☕Buy me a coffee