Skip to content

Instantly share code, notes, and snippets.

@baptisteArno
Created October 27, 2025 14:20
Show Gist options
  • Select an option

  • Save baptisteArno/51492f472733d11db43c1bc682de84b1 to your computer and use it in GitHub Desktop.

Select an option

Save baptisteArno/51492f472733d11db43c1bc682de84b1 to your computer and use it in GitHub Desktop.
Chakra UI V2 to Tailwind codemode
import type { API, FileInfo, JSXAttribute, Options } from "jscodeshift";
/**
* JSCodeshift codemod to transform Chakra UI layout components to HTML elements with Tailwind CSS classes.
*
* This codemod handles:
* - Component transformations (Stack, HStack, VStack, Box, Flex, Grid, etc.)
* - Prop-to-class mappings for common Chakra props (spacing, sizing, colors, etc.)
* - Conditional prop handling with cx utility
* - Import management (adds cx, removes converted Chakra components)
*/
export default function transformer(
file: FileInfo,
api: API,
_options: Options,
) {
const j = api.jscodeshift;
const root = j(file.source);
// Skip non-TypeScript/non-JSX files
if (!file.path.match(/\.(tsx?|jsx)$/)) {
return file.source;
}
let needsCxImport = false;
const convertedComponents = new Set<string>();
const componentsToConvert = new Set([
"Stack",
"VStack",
"HStack",
"Box",
"Flex",
"Grid",
"SimpleGrid",
"Container",
"Center",
"Wrap",
"Text",
]);
/**
* Maps Chakra components to their target HTML element
*/
const getTargetElement = (componentName: string): string => {
if (componentName === "Text") return "p";
return "div";
};
/**
* Maps Chakra components to their base Tailwind classes
* Returns base classes and default spacing/gap if no prop is provided
*/
const getBaseClasses = (
componentName: string,
hasSpacingProp: boolean,
): string[] => {
switch (componentName) {
case "Stack":
return hasSpacingProp
? ["flex", "flex-col"]
: ["flex", "flex-col", "gap-2"];
case "VStack":
return hasSpacingProp
? ["flex", "flex-col", "items-center"]
: ["flex", "flex-col", "items-center", "gap-2"];
case "HStack":
return hasSpacingProp
? ["flex", "items-center"]
: ["flex", "items-center", "gap-2"];
case "Flex":
return ["flex"];
case "Grid":
return ["grid"];
case "SimpleGrid":
return ["grid"];
case "Container":
return ["container"];
case "Center":
return ["flex", "items-center", "justify-center"];
case "Wrap":
return ["flex", "flex-wrap"];
default:
return [];
}
};
/**
* Maps Chakra prop values to Tailwind classes
*/
const mapPropToTailwind = (
propName: string,
value: string | number,
): string | null => {
// Handle spacing scale (1:1 with Tailwind)
const spacingValue = String(value);
switch (propName) {
// Layout
case "spacing":
case "gap":
return `gap-${spacingValue}`;
case "direction":
case "flexDir":
case "flexDirection":
if (value === "row") return "flex-row";
if (value === "column") return "flex-col";
if (value === "row-reverse") return "flex-row-reverse";
if (value === "column-reverse") return "flex-col-reverse";
return null;
case "align":
case "alignItems":
if (value === "center") return "items-center";
if (value === "start" || value === "flex-start") return "items-start";
if (value === "end" || value === "flex-end") return "items-end";
if (value === "baseline") return "items-baseline";
if (value === "stretch") return "items-stretch";
return null;
case "justify":
case "justifyContent":
if (value === "center") return "justify-center";
if (value === "start" || value === "flex-start") return "justify-start";
if (value === "end" || value === "flex-end") return "justify-end";
if (value === "space-between") return "justify-between";
if (value === "space-around") return "justify-around";
if (value === "space-evenly") return "justify-evenly";
return null;
case "wrap":
if (value === "wrap") return "flex-wrap";
if (value === "nowrap") return "flex-nowrap";
if (value === "wrap-reverse") return "flex-wrap-reverse";
return null;
// Padding
case "p":
return `p-${spacingValue}`;
case "px":
return `px-${spacingValue}`;
case "py":
return `py-${spacingValue}`;
case "pt":
return `pt-${spacingValue}`;
case "pb":
return `pb-${spacingValue}`;
case "pl":
return `pl-${spacingValue}`;
case "pr":
return `pr-${spacingValue}`;
// Margin
case "m":
return `m-${spacingValue}`;
case "mx":
return `mx-${spacingValue}`;
case "my":
return `my-${spacingValue}`;
case "mt":
return `mt-${spacingValue}`;
case "mb":
return `mb-${spacingValue}`;
case "ml":
return `ml-${spacingValue}`;
case "mr":
return `mr-${spacingValue}`;
// Sizing
case "w":
case "width":
if (value === "full" || value === "100%") return "w-full";
if (value === "auto") return "w-auto";
if (value === "fit-content") return "w-fit";
// Check if value contains units like px, vh, vw, rem, em - use arbitrary values
if (
typeof value === "string" &&
(value.includes("px") ||
value.includes("vh") ||
value.includes("vw") ||
value.includes("rem") ||
value.includes("em") ||
value.includes("%"))
) {
return `w-[${value}]`;
}
return `w-${spacingValue}`;
case "h":
case "height":
if (value === "full" || value === "100%") return "h-full";
if (value === "auto") return "h-auto";
if (value === "100vh") return "h-screen";
if (value === "fit-content") return "h-fit";
// Check if value contains units like px, vh, vw, rem, em - use arbitrary values
if (
typeof value === "string" &&
(value.includes("px") ||
value.includes("vh") ||
value.includes("vw") ||
value.includes("rem") ||
value.includes("em") ||
value.includes("%"))
) {
return `h-[${value}]`;
}
return `h-${spacingValue}`;
case "minW":
case "minWidth":
if (
typeof value === "string" &&
(value.includes("px") ||
value.includes("vh") ||
value.includes("vw") ||
value.includes("rem") ||
value.includes("em") ||
value.includes("%"))
) {
return `min-w-[${value}]`;
}
return `min-w-${spacingValue}`;
case "maxW":
case "maxWidth":
if (value === "container.lg") return null; // Chakra token, keep as prop
if (
typeof value === "string" &&
(value.includes("px") ||
value.includes("vh") ||
value.includes("vw") ||
value.includes("rem") ||
value.includes("em") ||
value.includes("%"))
) {
return `max-w-[${value}]`;
}
return `max-w-${spacingValue}`;
case "minH":
case "minHeight":
if (
typeof value === "string" &&
(value.includes("px") ||
value.includes("vh") ||
value.includes("vw") ||
value.includes("rem") ||
value.includes("em") ||
value.includes("%"))
) {
return `min-h-[${value}]`;
}
return `min-h-${spacingValue}`;
case "maxH":
case "maxHeight":
if (
typeof value === "string" &&
(value.includes("px") ||
value.includes("vh") ||
value.includes("vw") ||
value.includes("rem") ||
value.includes("em") ||
value.includes("%"))
) {
return `max-h-[${value}]`;
}
return `max-h-${spacingValue}`;
case "boxSize":
// boxSize sets both width and height
if (value === "full" || value === "100%") return "size-full";
if (
typeof value === "string" &&
(value.includes("px") ||
value.includes("vh") ||
value.includes("vw") ||
value.includes("rem") ||
value.includes("em") ||
value.includes("%"))
) {
return `size-[${value}]`;
}
return `size-${spacingValue}`;
// Borders
case "borderRadius":
case "rounded":
if (value === "full") return "rounded-full";
if (value === "none" || value === "0") return "rounded-none";
return `rounded-${spacingValue}`;
case "borderWidth":
if (value === "1" || value === 1) return "border";
return `border-${spacingValue}`;
case "borderTopWidth":
if (value === "1" || value === 1 || value === "1px") return "border-t";
if (typeof value === "string" && value.includes("px"))
return `border-t-[${value}]`;
return `border-t-${spacingValue}`;
case "borderBottomWidth":
if (value === "1" || value === 1 || value === "1px") return "border-b";
if (typeof value === "string" && value.includes("px"))
return `border-b-[${value}]`;
return `border-b-${spacingValue}`;
case "borderLeftWidth":
if (value === "1" || value === 1 || value === "1px") return "border-l";
if (typeof value === "string" && value.includes("px"))
return `border-l-[${value}]`;
return `border-l-${spacingValue}`;
case "borderRightWidth":
if (value === "1" || value === 1 || value === "1px") return "border-r";
if (typeof value === "string" && value.includes("px"))
return `border-r-[${value}]`;
return `border-r-${spacingValue}`;
case "borderColor":
// Chakra color tokens - keep as prop for manual conversion
return null;
// Shadows
case "boxShadow":
case "shadow":
if (value === "sm") return "shadow-sm";
if (value === "md") return "shadow-md";
if (value === "lg") return "shadow-lg";
if (value === "xl") return "shadow-xl";
if (value === "2xl") return "shadow-2xl";
if (value === "none") return "shadow-none";
return "shadow";
// Display & Position
case "display":
if (value === "flex") return "flex";
if (value === "grid") return "grid";
if (value === "block") return "block";
if (value === "inline") return "inline";
if (value === "inline-block") return "inline-block";
if (value === "none") return "hidden";
return null;
case "overflow":
if (value === "hidden") return "overflow-hidden";
if (value === "auto") return "overflow-auto";
if (value === "scroll") return "overflow-scroll";
if (value === "visible") return "overflow-visible";
if (value === "clip") return "overflow-clip";
return null;
case "overflowX":
if (value === "hidden") return "overflow-x-hidden";
if (value === "auto") return "overflow-x-auto";
if (value === "scroll") return "overflow-x-scroll";
if (value === "visible") return "overflow-x-visible";
if (value === "clip") return "overflow-x-clip";
return null;
case "overflowY":
if (value === "hidden") return "overflow-y-hidden";
if (value === "auto") return "overflow-y-auto";
if (value === "scroll") return "overflow-y-scroll";
if (value === "visible") return "overflow-y-visible";
if (value === "clip") return "overflow-y-clip";
return null;
case "pos":
case "position":
if (value === "relative") return "relative";
if (value === "absolute") return "absolute";
if (value === "fixed") return "fixed";
if (value === "sticky") return "sticky";
return null;
// Flexbox
case "flex":
if (value === "1") return "flex-1";
if (value === "auto") return "flex-auto";
if (value === "initial") return "flex-initial";
if (value === "none") return "flex-none";
return null;
// Typography
case "fontSize":
if (value === "xs") return "text-xs";
if (value === "sm") return "text-sm";
if (value === "md") return "text-base";
if (value === "lg") return "text-lg";
if (value === "xl") return "text-xl";
if (value === "2xl") return "text-2xl";
if (value === "3xl") return "text-3xl";
if (value === "4xl") return "text-4xl";
if (value === "5xl") return "text-5xl";
if (value === "6xl") return "text-6xl";
if (value === "7xl") return "text-7xl";
if (value === "8xl") return "text-8xl";
if (value === "9xl") return "text-9xl";
// Check if value contains units - use arbitrary values
if (
typeof value === "string" &&
(value.includes("px") ||
value.includes("rem") ||
value.includes("em") ||
value.includes("%"))
) {
return `text-[${value}]`;
}
return null;
case "fontWeight":
if (value === "hairline" || value === "100") return "font-thin";
if (value === "thin" || value === "200") return "font-extralight";
if (value === "light" || value === "300") return "font-light";
if (value === "normal" || value === "400") return "font-normal";
if (value === "medium" || value === "500") return "font-medium";
if (value === "semibold" || value === "600") return "font-semibold";
if (value === "bold" || value === "700") return "font-bold";
if (value === "extrabold" || value === "800") return "font-extrabold";
if (value === "black" || value === "900") return "font-black";
return null;
case "textAlign":
if (value === "left") return "text-left";
if (value === "center") return "text-center";
if (value === "right") return "text-right";
if (value === "justify") return "text-justify";
return null;
// Colors
case "bg":
case "bgColor":
case "backgroundColor":
case "color":
case "textColor":
// Chakra color tokens like "gray.900" or "white" should be kept as props
// for manual conversion to Tailwind colors or CSS variables
return null;
default:
return null;
}
};
/**
* Determines if a JSX attribute value is conditional (contains ternary or logical operators)
*/
const isConditionalExpression = (attr: JSXAttribute): boolean => {
if (!attr.value) return false;
if (attr.value.type !== "JSXExpressionContainer") return false;
const expression = attr.value.expression;
return (
expression.type === "ConditionalExpression" ||
expression.type === "LogicalExpression"
);
};
/**
* Transforms a single JSXElement from Chakra to Tailwind
*/
const transformElement = (path: any) => {
const element = path.node;
const componentName = element.openingElement.name.name;
if (!componentsToConvert.has(componentName)) return;
convertedComponents.add(componentName);
// Check if element has spacing/gap prop
const hasSpacingProp = element.openingElement.attributes.some(
(attr: any) =>
attr.type === "JSXAttribute" &&
(attr.name.name === "spacing" || attr.name.name === "gap"),
);
const baseClasses = getBaseClasses(componentName, hasSpacingProp);
const staticClasses: string[] = [...baseClasses];
const conditionalExpressions: any[] = [];
const remainingProps: JSXAttribute[] = [];
// Process attributes
element.openingElement.attributes.forEach((attr: any) => {
if (attr.type !== "JSXAttribute") {
remainingProps.push(attr);
return;
}
const propName = attr.name.name;
// Handle existing className
if (propName === "className") {
if (attr.value?.type === "StringLiteral") {
staticClasses.push(...attr.value.value.split(" "));
} else if (attr.value?.type === "JSXExpressionContainer") {
conditionalExpressions.push(attr.value.expression);
}
return;
}
// Skip children and ref props
if (propName === "children" || propName === "ref" || propName === "as") {
remainingProps.push(attr);
return;
}
// Handle conditional expressions
if (isConditionalExpression(attr)) {
const expression = (attr.value as any).expression;
if (expression.type === "ConditionalExpression") {
const consequentValue =
expression.consequent.type === "NumericLiteral" ||
expression.consequent.type === "StringLiteral" ||
expression.consequent.type === "Literal"
? expression.consequent.value
: null;
const alternateValue =
expression.alternate.type === "NumericLiteral" ||
expression.alternate.type === "StringLiteral" ||
expression.alternate.type === "Literal"
? expression.alternate.value
: null;
const consequentClass =
consequentValue !== null
? mapPropToTailwind(propName, consequentValue)
: null;
const alternateClass =
alternateValue !== null
? mapPropToTailwind(propName, alternateValue)
: null;
if (consequentClass && alternateClass) {
const ternaryExpression = j.conditionalExpression(
expression.test,
j.stringLiteral(consequentClass),
j.stringLiteral(alternateClass),
);
conditionalExpressions.push(ternaryExpression);
needsCxImport = true;
} else {
remainingProps.push(attr);
}
} else {
remainingProps.push(attr);
}
return;
}
// Handle static prop values
let propValue: string | number | null = null;
if (attr.value?.type === "StringLiteral") {
propValue = attr.value.value;
} else if (
attr.value?.type === "Literal" &&
typeof attr.value.value === "string"
) {
propValue = attr.value.value;
} else if (attr.value?.type === "JSXExpressionContainer") {
const expr = attr.value.expression;
if (expr.type === "Literal" && typeof expr.value === "number") {
propValue = expr.value;
} else if (expr.type === "Literal" && typeof expr.value === "string") {
propValue = expr.value;
} else if (expr.type === "NumericLiteral") {
propValue = expr.value;
} else if (expr.type === "StringLiteral") {
propValue = expr.value;
} else {
// Complex expression, keep as prop
remainingProps.push(attr);
return;
}
} else if (attr.value === null) {
propValue = "true";
}
if (propValue !== null) {
const tailwindClass = mapPropToTailwind(propName, propValue);
if (tailwindClass) {
// Avoid duplicate classes
if (!staticClasses.includes(tailwindClass)) {
staticClasses.push(tailwindClass);
}
} else {
// Unsupported prop - keep it for manual fixing
remainingProps.push(attr);
}
}
});
// Build className expression
let classNameAttr: JSXAttribute;
if (conditionalExpressions.length > 0) {
needsCxImport = true;
const staticClassString = staticClasses.join(" ");
const args =
staticClassString.length > 0
? [j.stringLiteral(staticClassString), ...conditionalExpressions]
: conditionalExpressions;
const cxCall = j.callExpression(j.identifier("cx"), args);
classNameAttr = j.jsxAttribute(
j.jsxIdentifier("className"),
j.jsxExpressionContainer(cxCall),
);
} else if (staticClasses.length > 0) {
classNameAttr = j.jsxAttribute(
j.jsxIdentifier("className"),
j.stringLiteral(staticClasses.join(" ")),
);
} else {
classNameAttr = null as any;
}
// Create new element
const newAttributes = classNameAttr
? [classNameAttr, ...remainingProps]
: remainingProps;
const targetElement = getTargetElement(componentName);
element.openingElement.name = j.jsxIdentifier(targetElement);
element.openingElement.attributes = newAttributes;
if (element.closingElement) {
element.closingElement.name = j.jsxIdentifier(targetElement);
}
};
// Transform all matching JSX elements
root.find(j.JSXElement).forEach(transformElement);
// Handle imports
if (convertedComponents.size > 0) {
// Add cx import if needed
if (needsCxImport) {
const hasCxImport =
root
.find(j.ImportDeclaration, {
source: { value: "@typebot.io/ui/lib/cva" },
})
.filter((path) => {
return (
path.node.specifiers?.some(
(spec) =>
spec.type === "ImportSpecifier" &&
spec.imported.name === "cx",
) ?? false
);
}).length > 0;
if (!hasCxImport) {
const firstImport = root.find(j.ImportDeclaration).at(0);
if (firstImport.length > 0) {
firstImport.insertBefore(
j.importDeclaration(
[j.importSpecifier(j.identifier("cx"))],
j.stringLiteral("@typebot.io/ui/lib/cva"),
),
);
}
}
}
// Remove or update Chakra imports
root
.find(j.ImportDeclaration, {
source: { value: "@chakra-ui/react" },
})
.forEach((path) => {
const specifiers = path.node.specifiers || [];
const remainingSpecifiers = specifiers.filter((spec) => {
if (spec.type === "ImportSpecifier") {
const imported = spec.imported;
const importedName =
imported.type === "Identifier" ? imported.name : null;
return importedName ? !convertedComponents.has(importedName) : true;
}
return true;
});
if (remainingSpecifiers.length === 0) {
j(path).remove();
} else {
path.node.specifiers = remainingSpecifiers;
}
});
}
return root.toSource({ quote: "double" });
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment