When the team adopted TailwindCSS, we had 364 files full of inline styles - style={{ display: 'flex', padding: '16px' }} everywhere. Migrating manually meant matching every CSS value to its Tailwind equivalent, merging classnames carefully, and doing it all without introducing bugs. At 20-30 files a day, that's two weeks of focused work.
I searched for an existing tool. Nothing existed. So I wrote one.
Why AST, Not Regex
The instinct with "find and replace inline styles" is to reach for regex. That breaks almost immediately - JSX attribute values aren't flat strings, they're expressions. style={{ display: 'flex', padding: '16px' }} has nested object expressions, string literals, potentially dynamic values, and existing className attributes that may or may not be string literals themselves.
JSCodeShift operates at the AST level, which means it sees the structure of the code rather than its text representation. Instead of pattern-matching characters, it queries the syntax tree directly: find all JSXElement nodes, check whether they have a style attribute, inspect each property, convert what can be converted, leave what can't.
The Basic Case
The happy path is straightforward. A static inline style maps directly to a Tailwind class:
// Before
<div style={{ display: 'flex', padding: '16px' }} />
// After
<div className="flex p-4" />The CSS-to-Tailwind mapping came from an existing community-maintained map of common CSS values to their Tailwind equivalents. The codemod looks up each CSS property/value pair in that map and substitutes the corresponding class. If the value is in the map, it converts. If it isn't, it flags it.
The Edge Cases
The basic case covers most files. The edge cases are where the codemod either has to be smarter or has to know when to give up.
Dynamic Values
// Can't be converted - value is only known at runtime
<div style={{ display: props.display }} />If a style property's value is anything other than a static string or number literal, the codemod leaves that property in place. The resulting element may have a mix of style and className - which is fine, since what couldn't be statically determined genuinely can't be converted.
Template Literals
Backtick expressions have a different AST node type than string literals (TemplateLiteral vs StringLiteral), and they require separate parsing logic to handle correctly. A template literal like `${spacing}px` is treated as a dynamic value and left in place.
Merging with an Existing className
When an element already has a className, the new Tailwind classes need to be merged in - but only if className is a static string. If it's a variable or expression, concatenating blindly would produce broken JSX:
// className is a variable - can't safely concatenate
<div style={{ display: 'flex' }} className={styles.wrapper} />
// Becomes - style removed, className left untouched, Tailwind class added separately
<div className={`${styles.wrapper} flex`} />The codemod checks the AST node type of the className value before deciding how to merge.
Style/className Conflicts
This is the most important edge case. Inline styles take precedence over class-based styles in the browser, which means an element like this has a bug hiding in it:
<div style={{ display: 'flex' }} className="block" />block sets display: block, but style={{ display: 'flex' }} overrides it - so block has no effect. When the codemod converts the inline style to flex, it also removes the conflicting block from className entirely. Left in place, block would silently apply after the migration in a way it never did before.
What It Couldn't Convert
Not every inline style has a clean Tailwind equivalent. A value like padding: 13px doesn't map to any standard Tailwind class - it would need an arbitrary value (p-[13px]), which the codemod didn't generate automatically.
Rather than guessing or silently skipping these, the codemod generated a report of every property it couldn't convert: the file, the line, the CSS property, and the value. That gave the team a clear list of what needed manual attention after the run - nothing was lost, nothing was silently left behind.
Results
- 364 files migrated in a single script execution
- ~2 weeks of manual work done in one run
- Style/className conflicts eliminated across the entire codebase
- Performance improved - no more inline style objects creating unnecessary re-renders
- Anything that couldn't be converted was surfaced in a report - no silent failures.
This post is part of a series on scaling a frontend platform to 18 apps. Read the full overview.