How does it work
How yak turns styled-components syntax into static CSS at build time: the Rust SWC plugin, the webpack, Turbopack, Vite and Rsbuild integrations, and the small runtime.
Playground
You can watch the code transformation live in the playground.
The basics
yak has 3 parts:
- The compile time part (SWC plugin + bundler integration)
- The runtime part (minimal class name merging)
- Optional: Context (theme provider)
The compile time part is responsible for extracting styles and transforming your code into something that uses the runtime part. The runtime part is responsible for merging styles and props.
yak supports four bundler backends: webpack (Next.js), Turbopack (Next.js), Vite and Rsbuild. The SWC plugin is shared across all of them, while each bundler has its own loader or plugin for CSS delivery.
The compile time part
yak uses a Rust-based SWC plugin and a bundler-specific loader to transform your code. The SWC plugin transforms the tagged template literals (like styled and css) and extracts CSS, while the loader resolves cross-module dependencies and delivers the CSS through the bundler's native CSS pipeline.
The SWC plugin
See: yak-swc
The SWC plugin takes in the source code and transforms the tagged template literals into @yak/react runtime function
calls with the generated class names of the extracted plain CSS as arguments. It also transforms the dynamic parts of the
tagged template literals into function calls with the properties as argument and adds it as css value to the style object.
On top of that it resolves the css prop and folds static JSX usages of styled components directly into DOM elements.
Change import
The first step of the transformation is to change the import statement from styled and css to the @yak/react runtime.
import * as __yak from "@yak/react/internal";The normal import references stub behavior, so tests can run without a transpilation step.
Add style import
The next step of the transformation is to add the import statement for the styles. These styles don't exist yet, but will be generated by the bundler-specific loader/plugin at a later stage. Since yak controls all generated class names (passing them as strings directly), this import does not need to export class name bindings. The import format depends on the bundler:
import "./page.yak.css!=!./page?./page.yak.css";This strange looking import string uses a poorly documented webpack feature called inline matchResource. It tells webpack to treat the current file (./page) as if it were a CSS file named ./page.yak.css.
./page.yak.css doesn't exist on disk. It's a virtual file whose content the webpack loader generates from the styles of the current file. ./page.yak.css!=!./page says that the loaders matching .yak.css files should run with the current file as input. The CSS they produce then goes through Next.js's regular CSS pipeline like any other plain CSS file.
import "data:text/css;base64,LkJ1dHRvbiB7Li4ufQ==";Turbopack uses a different approach than webpack. Instead of an inline match resource, the SWC plugin encodes the extracted CSS as a base64 data URL. Turbopack handles that import as CSS without any resource matching tricks.
import "virtual:yak-css:{{__MODULE_PATH__}}.css";For Vite, yak uses a virtual module system. The {{__MODULE_PATH__}} placeholder is replaced by the SWC plugin with the actual file path during transformation. The Vite plugin then resolves these virtual modules and serves the extracted CSS through Vite's standard CSS pipeline.
import "data:text/css;base64,LkJ1dHRvbiB7Li4ufQ==";Rsbuild uses the same base64 data URL import as Turbopack. The @yak/react/rsbuild plugin adds a rule that tells Rspack to treat text/css data URLs as CSS.
Transform tagged template literals
In another step, the tagged template literals are transformed into @yak/react runtime function calls. The static CSS parts are passed as class name strings directly, since yak controls all generated class names.
import { styled } from "@yak/react";
const Button = styled.button`
font-size: 2rem;
font-weight: bold;
color: blue;
&:hover {
color: red;
}
`;import * as __yak from "@yak/react/internal";
const Button = __yak.__yak_button(
"input_Button_m7uBBu",
);The dynamic parts are preserved and apply the classes or CSS variables at runtime. There are two types of dynamic parts:
Dynamic CSS property values are transformed into CSS variables. The function is preserved and its return value is set as an inline style on the element at runtime:
import { styled } from "@yak/react";
const Button = styled.button`
color: ${(props) => (props.$primary ? "white" : "blue")};
`;import * as __yak from "@yak/react/internal";
const Button = __yak.__yak_button("input_Button_m7uBBu", {
style: {
"--input_Button__color_m7uBBu": (props) =>
props.$primary ? "white" : "blue",
},
});Dynamic CSS blocks (conditional styles using css) are extracted into separate class names that are toggled at runtime based on the function's return value:
import { styled, css } from "@yak/react";
const Button = styled.button`
font-size: 2rem;
${(props) =>
props.$primary &&
css`
background: black;
`}
`;import { css } from "@yak/react/internal";
import * as __yak from "@yak/react/internal";
const Button = __yak.__yak_button(
"input_Button_m7uBBu",
(props) => props.$primary && css("input_Button__props_$primary_m7uBBu"),
);Transform the css prop
The css prop is resolved at compile time as well. The styles are extracted like all other styles and the prop itself is replaced with a plain className attribute:
import { css } from "@yak/react";
const Elem = () => (
<div
css={css`
color: red;
`}
/>
);const Elem = () => <div className="input_Elem_m7uBBu" />;Conditional styles stay conditional, but the condition now toggles class names instead of css blocks:
const Elem = ({ on }) => (
<div
css={
on
? css`
color: red;
`
: css`
color: blue;
`
}
/>
);const Elem = ({ on }) => (
<div
className={
on ? "input_Elem_m7uBBu" : "input_Elem_m7uBBu-01"
}
/>
);The class names only have to be merged at render time if the element already has a className attribute or spread attributes. In that case the plugin hands the attributes to the small __yak_mergeCssProp runtime helper: the css value first, then one argument per merged source in JSX order.
const Elem = (props) => (
<div {...__yak_mergeCssProp(css("input_Elem_m7uBBu"), { className: "card" }, props)} />
);Transform CSS
The next step is to extract the CSS to a comment in the source code. Doing it here keeps the transformation fast. The SWC plugin is already transforming the current file, and extracting the CSS there is way easier and faster than in the bundler loader.
import { styled, css } from "@yak/react";
const Button = styled.button`
font-size: 2rem;
font-weight: bold;
color: ${(props) => (props.$primary ? "white" : "blue")};
${(props) =>
props.$primary
? css`background: black;`
: css`background: white;`}
&:hover {
color: ${(props) => (props.$primary ? "red" : "blue")};
}
`;/*YAK Extracted CSS:
.input_Button_m7uBBu {
font-size: 2rem;
font-weight: bold;
color: var(--input_Button__color_m7uBBu);
}
.input_Button__props_\$primary_m7uBBu {
background: black;
}
.input_Button__props_\$primary_m7uBBu-01 {
background: white;
}
.input_Button_m7uBBu {
&:hover {
color: var(--input_Button__color_m7uBBu-01);
}
}
*/The comment starts with /*YAK Extracted CSS: so the loader can find it and pull out the CSS.
The runtime call right after the comment carries a /*#__PURE__*/ annotation. It tells the minifier that the call has no side effects, so it can drop the component when nothing uses it.
Class names for conditional blocks include the condition they depend on, like props_$primary above. When the same name comes up again in a component, the plugin appends a counter such as -01. The $ is escaped in the CSS selector but not in the class name string.
Fold static JSX usages
A styled component is itself a React component: when it renders it computes its class name, merges it with the received props and renders the actual DOM element. That is one extra component in the render tree for every usage. When a styled component is declared and used in the same file, the plugin already knows what it will render and folds the usage directly into the DOM element:
import { styled } from "@yak/react";
const Card = styled.div`
color: red;
`;
const Page = () => <Card>Hello</Card>;const Card = __yak.__yak_div("input_Card_m7uBBu");
const Page = () => (
<div className="input_Card_m7uBBu">Hello</div>
);React now renders one fewer component for this usage. Folding reaches further than fully static components: it inlines class-toggling props, collapses styled(Component) chains and folds the static css prop, and it keeps the runtime wrapper whenever it cannot prove the result is safe. See Folding for the full rules.
The bundler loaders/plugins
Each bundler has a dedicated loader or plugin that takes the SWC plugin output, resolves cross-module dependencies, and delivers the final CSS through the bundler's native CSS pipeline.
- Webpack (webpack-loader.ts): The loader reads the extracted CSS from the SWC plugin comments, resolves cross-module dependencies, and hands the final CSS over as a plain CSS file so Next.js can process it like any other CSS file.
- Turbopack (turbo-loader.ts): The loader runs the SWC plugin itself, resolves cross-module dependencies in the extracted CSS and writes the result back into the data URL import. No virtual file is involved.
- Vite (vite-plugin.ts): The plugin resolves the virtual module imports and serves the extracted CSS through Vite's standard CSS pipeline.
- Rsbuild (rsbuild/index.ts): The
@yak/react/rsbuildplugin registers the Turbopack loader as an Rspackpreloader. Rspack's loader API is close enough to webpack's that the same data URL approach works unchanged, and it runs before Rsbuild's own SWC pass.
The runtime part
See: styled.tsx
Despite being called "zero-runtime", yak does ship a small runtime. Here, "zero-runtime" means no code manipulates the DOM or CSSOM. No <style> or <link> elements are injected at runtime.
Folded JSX usages from the compile time step skip this runtime completely, because they are plain DOM elements. The runtime only handles the usages that could not be folded.
The runtime is essentially a type-safe classnames utility. It takes the generated class name strings and the component's props, evaluates the dynamic functions (conditionals, CSS variable values), and returns:
- The final
classNamestring (merging any incomingclassName, base classes and conditional classes) - An optional
styleobject (setting CSS variable values from props)
Since it has no side effects, it works in both server and client components.
Here is a simplified version of what __yak.__yak_button does:
function __yak_button(className, ...dynamicParts) {
// returns a React component
return (props) => {
const classNames = new ClassNames(props.className);
classNames.add(className);
const style = {};
for (const part of dynamicParts) {
if (typeof part === "function") {
// conditional css block, e.g.:
// (props) => props.$primary && css("Button_primary")
// css("Button_primary") returns a function that
// adds "Button_primary" to the class names
const result = part(props);
if (result) result(props, classNames, style);
} else if (part?.style) {
// css variable values, e.g.:
// { style: { "--color": (props) => props.$color } }
for (const [key, value] of Object.entries(part.style)) {
style[key] = typeof value === "function" ? value(props) : value;
}
}
}
return <button className={classNames.value} style={style} />;
};
}When the component is rendered, the runtime passes the received props through all the dynamic functions, collects the resulting class names into a single string and CSS variable values into a style object, and forwards them as className and style props to the underlying DOM element.
Context
The yak theme context lets you configure the theme of your yak application. It works for react-server components and normal react components, including full support for async server functions in Next.js 15+.
The first part of the puzzle is to export two different modules for the server and the client.
"./context": {
"react-server": {
"types": "./dist/context/index.server.d.ts",
"default": "./dist/context/index.server.js"
},
"default": {
"types": "./dist/context/index.d.ts",
"default": "./dist/context/index.js"
}
}The package ships ESM only, so there's no require condition.
For the client components, we use a regular React context. The provider can also receive a Promise of the theme, which useTheme unwraps with use.
"use client";
//
// This file is the client component (browser & ssr) version of index.server.tsx
//
import React, { ReactNode, createContext, use } from "react";
export interface YakTheme {}
/**
* The yak theme context
* @see https://github.com/galaxuslabs/yak/blob/main/packages/yak-react/runtime/context/README.md
*/
const YakContext = createContext<YakTheme>({});
/**
* Returns the current yak theme context
*
* @see https://github.com/galaxuslabs/yak/blob/main/packages/yak-react/runtime/context/README.md
*/
export const useTheme = (): YakTheme => {
const context = use(YakContext);
return context instanceof Promise ? use(context) : context;
};
/**
* Yak theme context provider
*
* @see https://github.com/galaxuslabs/yak/blob/main/packages/yak-react/runtime/context/README.md
*/
export const YakThemeProvider = ({
children,
theme = {},
}: {
children: ReactNode;
theme?: YakTheme;
}) => <YakContext.Provider value={theme}>{children}</YakContext.Provider>;For the server components, we use a module that exports exactly the same API as the one for client components, but with a different implementation. It wraps the user's context function in React's cache, so it runs once per request. If that function is async, useTheme unwraps the Promise with use.
//
// This file is the react-server component version of index.tsx
//
// @ts-ignore - in the current @types/react "cache" is not typed
import React, { ReactNode, cache, use } from "react";
import { YakTheme, YakThemeProvider as YakThemeClientProvider } from "./index.js";
// the following import might be changed by
// the user config in withYak to point to their own
// context
import { getYakThemeContext } from "@yak/react/context/baseContext";
/** Request based RSC YAK Context */
const getYakContext = cache(() => getYakThemeContext());
export const useTheme = (): YakTheme | undefined => {
const theme: YakTheme | undefined | Promise<YakTheme> = getYakContext();
return theme instanceof Promise ? use(theme) : theme;
};
export const YakThemeProvider = ({ children }: { children: ReactNode }) => {
return <YakThemeClientProvider theme={getYakContext()}>{children}</YakThemeClientProvider>;
};The getYakThemeContext function is imported from the user's yak.context.ts file. This file is a regular typescript file that exports a function that returns the theme context. You can use any API that works with server components, like headers or cookies,
to determine your theme values.
import { cookies } from "next/headers";
export async function getYakThemeContext() {
const cookieStore = await cookies();
return {
highContrast: cookieStore.get("highContrast")?.value === "true",
};
}
declare module "@yak/react" {
export interface YakTheme extends Awaited<ReturnType<typeof getYakThemeContext>> {}
}The second part of the puzzle is a resolve alias. Every bundler integration (webpack and Turbopack through withYak, the Vite plugin and the Rsbuild plugin) points @yak/react/context/baseContext at your own yak.context.ts file in the project root. This is the webpack version:
// With the following alias the internal @yak/react code
// is able to import a context which works for server components
const yakContext = resolveYakContext(
yakOptions.contextPath,
webpackConfig.context || process.cwd(),
);
if (yakContext) {
webpackConfig.resolve.alias["@yak/react/context/baseContext"] = yakContext;
}