Created
October 27, 2025 14:20
-
-
Save baptisteArno/51492f472733d11db43c1bc682de84b1 to your computer and use it in GitHub Desktop.
Chakra UI V2 to Tailwind codemode
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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