A natural and powerful Zero-Runtime CSS-in-JS solution for React
npm install ssr-emotion-reactA natural and powerful Zero-Runtime CSS-in-JS solution et ses React 🍅
Create your new app.
`` bash`
npm create astro@latest my-app
cd my-app
Add ssr-emotion-react as a dependency.
` bash
npm install ssr-emotion-react
`
Add the integration in astro.config.mjs.
> Notes: If you are also using @astrojs/react, you must remove it or place ssrEmotion() before react() in the integrations array. This ensures that ssr-emotion-react can correctly handle component rendering and extract styles.
`js
import { defineConfig } from 'astro/config';
import ssrEmotion from 'ssr-emotion-react/astro';
export default defineConfig({
integrations: [ssrEmotion()],
});
`
Now, you can use not only Astro components (.astro) but also React JSX components (.jsx or .tsx) with SSR Emotion.
> Note: React JSX components in Astro Islands
>
> * No directive (SSR Only): Rendered as just static HTML tags. It results in zero client-side JavaScript. (I used to think there wasn't much point in writing static content in JSX components instead of just using Astro components. It seemed like standard Astro components was more than enough. However, I've realized one major advantage: SSR Emotion — the ultimate SSR Zero-Runtime CSS-in-JS solution, seamlessly integrated with Astro. By using React JSX components, your styles are automatically extracted into static CSS during the build process. This means you can enjoy the full power of CSS-in-JS while still shipping zero bytes of JS to the browser. In this regard, it's a significant upgrade over standard Astro components.)
>
> * client:only="react" (CSR Only): As you know, this is the standard mode where Emotion is used, and this plugin does nothing. It skips server-side rendering and runs entirely in the browser.client:load
>
> * (and others like client:visible or client:idle) (SSR Hydration): Despite its cool and flashy name, "SSR Hydration" is not that complicated: it just creates a static HTML skeleton first, and once the JS is ready, the engine takes over the DOM as if it had been there from the start. If you are particular about the visual transition—like ensuring there is no layout shift by pre-setting an image's height—you might want to take control to make the swap feel completely natural.
This plugin wasn't originally built for React. It was first conceived as a core pillar of the Potate engine—a custom JSX runtime designed for ultimate simplicity and performance.
While developing Potate, I discovered a way to handle SSR Emotion and Hydration that felt more "correct" for the Astro era: The Full DOM Replacement strategy. It worked so flawlessly in Potate that I decided to bring this lineage back to the "ancestor," React. By applying Potate's philosophy to React, we've eliminated the historical complexities of Emotion SSR and the fragility of standard React hydration.
You don't need to learn any special properties or complex setups. It just works with the Emotion css() function. It feels completely natural, even in Astro's "No directive" (SSR Only) mode.
* Zero Runtime by default: No Emotion library is shipped to the browser. It delivers a pure Zero-JS experience.css()
* Familiar DX: Use the full expressive power of the Emotion function that you already know..css
* Decoupled Asset Delivery: Styles are moved to separate files to allow for flexible cache strategies. By using unique filenames (cache busting), we ensure that updates are immediately reflected even when long-term caching is enabled on the server.
While you can use css() directly, you can also create reusable functions like flexCol() (which we call "The Patterns").
`jsx
// src/components/StaticBox.jsx
import { css } from '@emotion/css'
export default props => (
const flexCol = (...args) => css({
display: 'flex',
flexDirection: 'column',
}, ...args)
`
`astro
---
// src/pages/index.astro
import Layout from '../layouts/Layout.astro'
import StaticBox from '../components/StaticBox'
---
`
In Astro, Island components (client:load and others) get the best of both worlds.
* Hydration Stability: No overhead for style re-calculation. Interactive Islands remain stable and fully dynamic without visual flickering during the hydration process.
* Unlimited Flexibility: Need to change colors based on user input or mouse position? Just pass the props/state to css() like you always do.
* Zero Learning Curve: If you know how to use useEffect and Emotion, you already know how to build dynamic Islands with React.
1. At Build Time (SSR): SSR Emotion executes your css() calls and extracts the initial styles into a static CSS file. This ensures your component looks perfect even before JavaScript loads.
2. At Runtime (Hydration): Once the Island hydrates in the browser, the Emotion runtime takes over.
Because the Emotion runtime remains active inside Islands, you can use standard React patterns to handle dynamic styles without any special APIs.
You can easily change styles when the component "wakes up" in the browser:
`jsx
// src/components/InteractiveBox.jsx
export default props => {
const [isLoaded, setIsLoaded] = useState(false)
useEffect(() => {
setIsLoaded(true) // Triggers once JS is active
}, [])
return (
`
`astro
---
// src/pages/index.astro
import Layout from '../layouts/Layout.astro'
import StaticBox from '../components/StaticBox'
import InteractiveBox from '../components/InteractiveBox'
---
`
Since this plugin uses synchronous renderToString, you cannot throw Promise (Suspense) during the server-side rendering phase.
If your component includes asynchronous logic (like use() or data fetching), use the Vite-standard import.meta.env.SSR flag to branch your code. This ensures the server-side render stays synchronous while the browser handles the dynamic parts.
`jsx
import React from 'react'
import { css } from '@emotion/css'
export default props => {
// Return a skeleton or null during SSR to avoid suspending
if (import.meta.env.SSR) {
return (
// Client-side only: use the full power of asynchronous resources
const data = React.use(myAsyncResource);
return (
`
* Zero Overhead: Vite automatically removes the "server-only" or "client-only" code blocks during the build process (Dead Code Elimination).
* Standard Way: Since it's a built-in Vite feature (and Astro uses Vite), you don't need any special utility functions.
* Predictable Styling: It guarantees that your Emotion styles are extracted correctly without being interrupted by pending Promises.
We refer to reusable CSS logic as "The Patterns".
Honestly? They’re just standard JavaScript functions that return styles. No complex registration, no hidden magic. You just write a function, and that's it. Simple, right? 🤤
You can easily implement the LinkOverlay pattern. This expands a link's clickable area to its nearest parent with position: relative.
`jsx
import { css } from '@emotion/css'
const linkOverlay = (...args) => css({
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 0,
},
}, ...args)
:
const MyComponent = props => (
// The parent must have position: relative
`
`jsx
import { css } from '@emotion/css';
const BP = {
sm: '640px', md: '768px', lg: '1024px', xl: '1280px', '2xl': '1536px',
}
const isBP = value => value in BP
const _gt = bp => (min-width: ${isBP(bp) ? BP[bp] : bp})(max-width: ${isBP(bp) ? BP[bp] : bp})
const _lt = bp =>
const gt = (bp, ...args) => css({[@media ${_gt(bp)}]: css(...args)})@media ${_lt(bp)}
const lt = (bp, ...args) => css({[]: css(...args)})@media ${_gt(min)} and ${_lt(max)}
const bw = (min, max, ...args) => css({[]: css(...args)})
:
const MyComponent = props => (
`
If you prefer the Styled Components pattern (popularized by libraries like MUI or styled-components), Emotion makes it incredibly easy to implement.
Even with this minimal custom (but powerful) function, the result remains the same: Zero-Runtime CSS. All styles are pre-calculated during SSR and extracted into static CSS files.
`jsx
import {css, cx} from '@emotion/css'
export const styled = (Tag) => (style, ...values) => props => {
const makeClassName = (style, ...values) =>
typeof style == 'function' ? makeClassName(style(props)) : css(style, ...values);
const {as: As, sx, className, 'class': _class, children, ...wosx} = props;
// cleanup transient props
Object.keys(wosx).forEach(key => {
if (key.startsWith('$')) delete wosx[key];
});
const newProps = {
...wosx,
className: cx(makeClassName(style, ...values), makeClassName(sx), _class, className),
};
const T = As || Tag;
return (
};
`
What is the sx prop? For those unfamiliar with libraries like MUI, the sx prop is a popular pattern that allows you to apply "one-off" styles directly to a component.
In this implementation, you can pass raw style objects to the sx prop without wrapping them in css() or "The Patterns" functions.
However, defining a styled component inside a render function is a pitfall because it creates a new component identity every time, forcing React to re-mount.
I personally prefer the approach shown below. In any case, how you choose to implement this is entirely up to you.
`js
// the-sx-prop.jsx
import {css, cx} from '@emotion/css'
export const sx = (props, style, ...values) => {
let result = (props && typeof props === 'object' ? props : {});
if (typeof style === 'function') {
result = {...style(result), ...result};
result.className = cx(css(result?.$css, ...values), result.className);
} else {
result.className = cx(css(style, ...values), result.className);
}
// cleanup transient props
Object.keys(result).forEach(key => {
if (key.startsWith('$')) delete result[key];
});
return result;
}
// Factory for component-scoped sx functions (adds .css() automatically)
sx._factory = (genCSS) => {
const f = (props, ...styles) => sx(props || {}, genCSS, ...styles);
f.css = (...styles) => f({}, ...styles); // style only
// f.curry = (props) => (...values) => f(props || {}, ...values); // currying
return f;
}
// My button style
sx.button = sx._factory(props => {
const style = {
// default is text button
padding: '8px 16px',
border: 'none',
borderRadius: '2px',
color: 'var(--style-palette-primary)',
backgroundColor: 'inherit',
boxShadow: 'none',
};
if (props.$elevated) {
style.borderRadius = '0';
style.border = 'none';
style.color = 'var(--style-palette-primary)';
style.backgroundColor = 'var(--style-palette-surface-container-low)';
style.boxShadow = 'var(--style-shadows-level1)';
}
return {$css: [
css
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
&:not(:disabled) {
cursor: pointer;
}
,
style,
]};
});
`
`jsx
import {sx} from './the-sx-prop'
export default props => {
return (<>
`
Furthermore, by creating a component like the one below, you evolve into a Super Saiyan (for those who aren't familiar, it's like a classic Superman).
`js
// the-sx-prop.jsx
:
:
export const As = ({as: Tag = 'div', children, ...props}) =>
// My list style
sx.ul = sx._factory(props => ({
as: 'ul',
$css: css
& > li {
position: relative;
padding-left: 1.5rem;
&:before {
content: "\\2022";
position: absolute;
width: 1.5rem;
left: 0; top: 0;
text-align: center;
}
& + li, & > ul {
margin-top: .25rem;
}
}
& > ul {
margin-left: 1rem;
}}));
`
`jsx
import {sx, As} from './the-sx-prop'
const MyComponent = props => {
return (
)
}
``
The following features are not planned for the core roadmap (though contributors are welcome to explore them):
* React Server Components (RSC)
* Async SSR