linaria - A Zero-Runtime CSS-in-JS Solution
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
BEMandOOCSS, 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
SASSfor 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?

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-sizeis 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;`;

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 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; } } -
Place the expression info into
interpolationsand 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.h1form into compiled code:- Add
nameandclass: herenameis the component’s original name, andclassis 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)] ) ); - Add
-
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):
-
Combine the compiled
classNamewith the originalclassName:filteredProps.className = cx( filteredProps.className || className, options.class ); -
If
varscontains properties, iterate through them and assign CSS variables tostyle: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); } -
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); } -
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
- 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 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
- Recreating My Room with Three.js Using React Three Fiber, I brought my real room into the browser—turning physical objects into an interactive table of contents, and using spatial memory to tell the story of my life and work over the past few years.
- Things to Keep in Mind When Using Images in Frontend Development Expanding on Jake Archibald's article, this post organizes how modern responsive images should be written: why width/height are still necessary, when to use CSS aspect-ratio, how to choose between AVIF and WebP, and using picture/source/srcset for art direction on mobile devices.
- CSS field-sizing — Auto-resize Form Elements with a Single Line of CSS Previously, auto-resizing a textarea required listening to scrollHeight in JavaScript. With CSS field-sizing: content, a single line replaces it all, supporting textarea, input, and select. This article covers the pain points of older approaches and how to use field-sizing.
- Make Your Link Underlines Look Better: text-underline-offset By default, underlines sit very close to the text. Some designers dislike this look, and personally, I don't think it looks great either.