styled-components-to-stylex-codemod
v0.0.32
Published
Codemod to transform styled-components to StyleX
Downloads
13,116
Maintainers
Readme
styled-components-to-stylex-codemod
Transform styled-components to StyleX.
Try it in the online playground — experiment with the transform in your browser.
Installation
npm install styled-components-to-stylex-codemod
# or
pnpm add styled-components-to-stylex-codemodUsage
Use 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"],
});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"],
});Adapter
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)
Cross-file selectors (consumerPaths)
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).
Auto-detecting external interface usage (experimental)
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
consumerPathsand a successful prepass scan. If prepass fails,runTransform()throws (fail-fast) whenexternalInterface: "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
Base component resolution (resolveBaseComponent)
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)} />Dynamic interpolations
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 }) - 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)
Limitations
- 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.
Migration game plan
1. Define your theme and mixins as StyleX
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" },
});2. Write an adapter and run the codemod
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.
3. Convert bottom-up (leaf components first)
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.
4. Verify, iterate, clean up
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.
License
MIT
