Transform styled-components to StyleX.
Try it in the online playground — experiment with the transform in your browser.
npm install styled-components-to-stylex-codemod
# or
pnpm add styled-components-to-stylex-codemodUse runTransform to transform files matching a glob pattern:
import { runTransform, defineAdapter } from "styled-components-to-stylex-codemod";
const adapter = defineAdapter({
// Map theme paths and CSS variables to StyleX expressions
resolveValue(ctx) {
return null;
},
// Map helper function calls to StyleX expressions
resolveCall(ctx) {
return null;
},
// Control which components accept external className/style and polymorphic `as`
externalInterface(ctx) {
return { style: false, as: false };
},
// Optional: use a helper for merging StyleX styles with external className/style
styleMerger: null,
// Emit sx={} JSX attributes instead of {...stylex.props()} spreads (requires StyleX ≥0.18)
useSxProp: false,
// Optional: customize the runtime theme hook import/call used for theme conditionals
// Defaults to { functionName: "useTheme", importSource: { kind: "specifier", value: "styled-components" } }
themeHook: {
functionName: "useTheme",
importSource: { kind: "specifier", value: "styled-components" },
},
});
await runTransform({
files: "src/**/*.tsx",
consumerPaths: null, // set to a glob to enable cross-file selector support
adapter,
dryRun: false,
parser: "tsx",
formatterCommands: ["pnpm prettier --write"],
});Full adapter example
import { runTransform, defineAdapter } from "styled-components-to-stylex-codemod";
const adapter = defineAdapter({
/**
* Resolve dynamic values in styled template literals to StyleX expressions.
* Called for theme access (`props.theme.x`), CSS variables (`var(--x)`),
* and imported values. Return `{ expr, imports }` or `null` to skip.
*/
resolveValue(ctx) {
if (ctx.kind === "theme") {
const varName = ctx.path.replace(/\./g, "_");
return {
expr: `tokens.${varName}`,
imports: [
{
from: { kind: "specifier", value: "./design-system.stylex" },
names: [{ imported: "tokens" }],
},
],
};
}
if (ctx.kind === "cssVariable") {
const toCamelCase = (s: string) =>
s.replace(/^--/, "").replace(/-([a-z])/g, (_, c) => c.toUpperCase());
return {
expr: `vars.${toCamelCase(ctx.name)}`,
imports: [
{
from: { kind: "specifier", value: "./css-variables.stylex" },
names: [{ imported: "vars" }],
},
],
};
}
return null;
},
/**
* Resolve helper function calls in template interpolations.
* e.g. `${transitionSpeed("slow")}` → `transitionSpeedVars.slow`
* Return `{ expr, imports }` or `null` to bail the file with a warning.
*/
resolveCall(ctx) {
const arg0 = ctx.args[0];
const key = arg0?.kind === "literal" && typeof arg0.value === "string" ? arg0.value : null;
if (ctx.calleeImportedName !== "transitionSpeed" || !key) {
return null;
}
return {
expr: `transitionSpeedVars.${key}`,
imports: [
{
from: { kind: "specifier", value: "./lib/helpers.stylex" },
names: [{ imported: "transitionSpeed", local: "transitionSpeedVars" }],
},
],
};
},
/**
* Optional: inline styled(ImportedComponent) into an intrinsic element.
* When the base component can be resolved statically, return the target
* element, consumed props, and base StyleX declarations. Return undefined
* to keep normal styled(Component) behavior.
*/
resolveBaseComponent(ctx) {
if (ctx.importSource !== "@company/ui" || ctx.importedName !== "Flex") {
return undefined;
}
const sx: Record<string, string> = { display: "flex" };
const consumedProps = ["column", "gap", "align"];
if (ctx.staticProps.column === true) {
sx.flexDirection = "column";
}
if (typeof ctx.staticProps.gap === "number") {
sx.gap = `${ctx.staticProps.gap}px`;
}
return { tagName: "div", consumedProps, sx };
},
/**
* Control which exported components accept external className/style
* and/or polymorphic `as` prop. Return `{ styles, as }` flags.
*/
externalInterface(ctx) {
if (ctx.filePath.includes("/shared/components/")) {
return { styles: true, as: true };
}
return { styles: false, as: false };
},
/**
* When `externalInterface` enables styles, use a helper to merge
* StyleX styles with external className/style props.
* See test-cases/lib/mergedSx.ts for a reference implementation.
*/
styleMerger: {
functionName: "mergedSx",
importSource: { kind: "specifier", value: "./lib/mergedSx" },
},
/**
* Emit sx={} JSX attributes instead of {...stylex.props()} spreads.
* Requires @stylexjs/babel-plugin ≥0.18 with sxPropName enabled.
*/
useSxProp: false,
/**
* Optional: customize the runtime theme hook used when wrappers need theme booleans.
* Defaults to useTheme from styled-components.
*/
themeHook: {
functionName: "useDesignTheme",
importSource: { kind: "specifier", value: "@company/theme-hooks" },
},
});
await runTransform({
files: "src/**/*.tsx",
consumerPaths: null,
adapter,
dryRun: false,
parser: "tsx",
formatterCommands: ["pnpm prettier --write"],
});Adapters are the main extension point, see full example above. They let you control:
- how theme paths, CSS variables, and imported values are turned into StyleX-compatible JS values (
resolveValue) - what extra imports to inject into transformed files (returned from
resolveValue) - how helper calls are resolved (via
resolveCall({ ... })returning{ expr, imports }, or{ preserveRuntimeCall: true }to keep only the original helper runtime call;null/undefinedbails the file) - which exported components should support external className/style extension and/or polymorphic
asprop (externalInterface) - how className/style merging is handled for components accepting external styling (
styleMerger) - which runtime theme hook import/call to use for emitted wrapper theme conditionals (
themeHook) - how
styled(ImportedComponent)wrapping an external base component can be inlined into an intrinsic element with static StyleX styles (resolveBaseComponent)
consumerPaths is required. Pass null to opt out, or a glob pattern to enable cross-file selector scanning.
When transforming a subset of files, other files may reference your styled components as CSS selectors (e.g. ${Icon} { fill: red }). Pass consumerPaths to scan those files and wire up cross-file selectors automatically:
await runTransform({
files: "src/components/**/*.tsx", // files to transform
consumerPaths: "src/**/*.tsx", // additional files to scan for cross-file usage
adapter,
});- Files in both
filesandconsumerPathsuse the marker sidecar strategy (both consumer and target are transformed, usingstylex.defineMarker()). - Files in
consumerPathsbut not infilesuse the bridge strategy (a stableclassNameis added to the converted component so unconverted consumers' selectors still work).
Instead of manually specifying which components need styles or as support, set externalInterface: "auto" to auto-detect usage by scanning consumer code.
Note
Experimental. Requires consumerPaths and a successful prepass scan.
If prepass fails, runTransform() throws (fail-fast) when externalInterface: "auto" is used.
import { runTransform, defineAdapter } from "styled-components-to-stylex-codemod";
const adapter = defineAdapter({
// ...
externalInterface: "auto",
});
await runTransform({
files: "src/**/*.tsx",
consumerPaths: "src/**/*.tsx", // required for auto-detection
adapter,
});When externalInterface: "auto" is set, runTransform() scans files and consumerPaths for styled(Component) calls and <Component as={...}> JSX usage, resolves imports back to the component definition files, and returns the appropriate { styles, as } flags automatically.
If that prepass scan fails, runTransform() stops and throws an actionable error rather than silently falling back to non-auto behavior.
Troubleshooting prepass failures with "auto":
- verify
consumerPathsglobs match the files you expect - confirm the selected parser matches your source syntax (
parser: "tsx",parser: "ts", etc.) - check resolver inputs (import paths, tsconfig path aliases, and related module resolution config)
- if needed, switch to a manual
externalInterface(ctx)function to continue migration while you fix prepass inputs
Use this when you want to replace a base component entirely by inlining its styles. If your codebase has a layout primitive like <Flex> whose behavior is purely CSS, the codemod can eliminate the runtime import and render a plain <div> instead.
The resolver receives ctx.importSource, ctx.importedName, and ctx.staticProps (from .attrs() and JSX call sites). Return { tagName, consumedProps, sx } to inline, or undefined to skip.
// Input
const Container = styled(Flex).attrs({ column: true, gap: 16 })`
padding: 8px;
`;// Adapter
resolveBaseComponent(ctx) {
if (ctx.importedName !== "Flex") return undefined;
const sx: Record<string, string> = { display: "flex" };
if (ctx.staticProps.column === true) sx.flexDirection = "column";
if (typeof ctx.staticProps.gap === "number") sx.gap = `${ctx.staticProps.gap}px`;
return { tagName: "div", consumedProps: ["column", "gap", "align"], sx };
},// Output — Flex is gone, its styles are merged into stylex.create()
const styles = stylex.create({
container: { display: "flex", flexDirection: "column", gap: "16px", padding: "8px" },
});If the base component's styles already exist as a stylex.create() object, return mixins instead of (or alongside) sx. The codemod imports the mixin and includes it in stylex.props(...):
resolveBaseComponent(ctx) {
return {
tagName: "div",
consumedProps: ["column", "gap"],
mixins: [{ importSource: "./lib/mixins.stylex", importName: "mixins", styleKey: "flex" }],
};
},
// Output: <div {...stylex.props(mixins.flex, styles.container)} />When the codemod encounters an interpolation inside a styled template literal, it runs an internal dynamic resolution pipeline which covers common cases like:
- theme access (
props.theme...) viaresolveValue({ kind: "theme", path }) - indexed theme lookups (
props.theme.color[props.$bg]) — whenctx.indexedLookupis true, return{ usage: "props", dynamicArgUsage: "memberAccess" }to emit a prebuilt per-property mixin map (e.g.,$colorMixins.backgroundColor[bg]) instead of a dynamic style function - imported value access (
import { zIndex } ...; ${zIndex.popover}) viaresolveValue({ kind: "importedValue", importedName, source, path }) - prop access (
props.foo) and conditionals (props.foo ? "a" : "b",props.foo && "color: red;") - helper calls (
transitionSpeed("slowTransition")) viaresolveCall({ ... })— the codemod infers usage from context:- With
ctx.cssProperty(e.g.,color: ${helper()}) → result used as CSS value instylex.create() - Without
ctx.cssProperty(e.g.,${helper()}) → result used as StyleX styles instylex.props() - Use the optional
usage: "create" | "props"field to override the default inference - Use
preserveRuntimeCall: trueto keep the original helper call as a runtime style-function override (with or without a static fallback fromexpr)
- With
- if
resolveCallreturnsnullorundefined, the transform bails the file and logs a warning - helper calls applied to prop values (e.g.
shadow(props.shadow)) by emitting a StyleX style function that calls the helper at runtime - conditional CSS blocks via ternary (e.g.
props.$dim ? "opacity: 0.5;" : "")
If the pipeline can't resolve an interpolation:
- for some dynamic value cases, the transform preserves the value as a wrapper inline style so output keeps visual parity (at the cost of using
style={...}for that prop) - otherwise, the declaration containing that interpolation is dropped and a warning is produced (manual follow-up required)
- Flow type generation is non-existing, works best with TypeScript or plain JS right now. Contributions more than welcome!
- createGlobalStyle: detected usage is reported as an unsupported-feature warning (StyleX does not support global styles in the same way).
- Theme prop overrides: passing a
themeprop directly to styled components (e.g.<Button theme={...} />) is not supported and will bail with a warning.
Before running the codemod, convert your theme object and shared style helpers into StyleX equivalents:
// tokens.stylex.ts — theme variables
import * as stylex from "@stylexjs/stylex";
// Before: { colors: { primary: "#0066cc" }, spacing: { sm: "8px" } }
export const colors = stylex.defineVars({ primary: "#0066cc" });
export const spacing = stylex.defineVars({ sm: "8px" });// helpers.stylex.ts — shared mixins
import * as stylex from "@stylexjs/stylex";
// Before: export const truncate = () => `white-space: nowrap; overflow: hidden; ...`
export const truncate = stylex.create({
base: { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
});The adapter maps your project's props.theme.* access, CSS variables, and helper calls to the StyleX equivalents from step 1. See Usage for the full API.
When a component wraps another component that internally uses styled-components (e.g. styled(GroupHeader) where GroupHeader renders a StyledHeader), CSS cascade conflicts can arise after migration. Convert leaf files — the ones that don't wrap other styled-components — first, then work your way up. The codemod will bail with a warning if it detects this pattern.
Build and test your project. Review warnings — they tell you which files were skipped and why. Fix adapter gaps, re-run on remaining files, and repeat until done. Report issues with input/output examples if the codemod produces incorrect results.
MIT