From Tailwind to StyleX
To be clear, this isn't one of those "Oh look, I discovered a new library, so everything I used before is terrible" posts. I've been using Tailwind for quite a while now, and I've genuinely enjoyed working with it.
At our lunch table, we have all sorts of important conversations, like our ongoing quest to find the perfect cookie. More relevant to this post, though, a few colleagues shared some insights suggesting that StyleX might be easier for LLMs to work with than Tailwind.
At the time of writing, Linear had moved 50% of its styling to StyleX:

And they weren't the only ones. I started seeing more developers talk about using StyleX with coding agents:

That made me curious. I've never been particularly fond of writing CSS inside JavaScript, but I was interested enough to convert a side project from Tailwind to StyleX and see what my experience with it would be.
After using it for a while, I don't think StyleX has made styling easier. If I'm writing an interface by hand, I still find Tailwind faster.
What I have noticed is that some of the things that make StyleX more verbose also make the styling system quite explicit, and that has been useful when working with LLMs.
Tailwind makes a lot of decisions for you
One thing I appreciated more after moving away from Tailwind for my project is how much it gives you out of the box.
Take something simple:
<div className="flex items-center gap-3 rounded-lg px-4 py-3">
Hello there
</div>
There are quite a few decisions hidden in that line. Tailwind already knows what gap-3, rounded-lg, px-4, and py-3 mean, and those values all belong to scales that work reasonably well together.
With StyleX, my version looks more like this:
const styles = stylex.create({
root: {
display: "flex",
alignItems: "center",
gap: spacing["--spacing-3"],
paddingBlock: spacing["--spacing-3"],
paddingInline: spacing["--spacing-4"],
borderRadius: borderRadius["--border-radius-lg"],
},
});
I had to decide on spacing, colors, radiuses, typography, breakpoints, and what I wanted to call everything. Out of familiarity, quite a few of those names ended up being close to Tailwind.
That is more work. Tailwind lets me write py-3 and move on, while StyleX has me thinking about paddingBlock, where the value comes from, and how that value fits into the rest of the system.
If I were typing all of this myself, I'd consider that a fairly significant downside. But increasingly, I'm not typing all of it myself.
More of the styling system is just code
For this project, I have a token file that looks roughly like this:
export const color = stylex.defineVars(...);
export const fontFamily = stylex.defineVars(...);
export const fontSize = stylex.defineVars(...);
export const spacing = stylex.defineVars(...);
export const shadow = stylex.defineVars(...);
export const breakpoint = stylex.defineConsts(...);
export const duration = stylex.defineConsts(...);
export const easing = stylex.defineConsts(...);
If a component needs spacing, the value is an import:
gap: spacing["--spacing-2-5"],
That's more verbose than gap-2.5, but the source of the value is also fairly obvious. The LLM doesn't have to remember that I usually use a particular spacing scale or infer it from a few components. It can find the token file and use what's already there.
I've started doing something similar with component overrides.
My components don't accept className or style. They accept xstyle:
export type PropsWithStyleX<Component extends ElementType> = Omit<
ComponentPropsWithRef<Component>,
"className" | "style"
> & {
xstyle?: stylex.StyleXStyles;
};
The component then decides where that styling gets applied:
stylex.props(
styles.root,
variants[variant],
sizes[size],
stylex.defaultMarker(),
xstyle,
)
There's nothing particularly clever about this. I just like that there is one obvious way to extend the styling of these components.
In Tailwind projects, I sometimes end up with className, cn(), CVA, tailwind-merge, inline styles, or some combination of them. That's not really a problem when I'm working in the codebase myself because I know why those things are there.
With an LLM, I've found that fewer options can be useful. If the convention is xstyle, there isn't much to figure out.
The same applies to variants. A variants.primary or styles.disabled is a named thing the LLM can find and reuse rather than something it has to reconstruct from a class string.
StyleX being typed also helps here. CSS properties, tokens and the way styles are composed aren't just conventions in a prompt. TypeScript and the compiler can catch at least some mistakes when the generated code doesn't fit the system.
It obviously doesn't stop an LLM from writing bad styling, but there's more feedback than just "this looks plausible."
I like being able to read what changed
Another thing I noticed is that I still prefer reading CSS properties over long lists of Tailwind classes.
Something like:
className="flex items-center gap-3 rounded-lg border border-neutral-200 bg-white px-4 py-3 text-sm shadow-sm transition-colors hover:bg-neutral-50 focus-visible:ring-2 focus-visible:ring-primary"
is quick to write once you're used to Tailwind, but once the list gets long enough I mostly scan it.
The StyleX equivalent takes up more space:
const styles = stylex.create({
root: {
display: "flex",
alignItems: "center",
gap: spacing["--spacing-3"],
paddingBlock: spacing["--spacing-3"],
paddingInline: spacing["--spacing-4"],
borderRadius: borderRadius["--border-radius-lg"],
backgroundColor: color["--color-surface"],
},
});
But I find it fairly easy to read.
This has been especially noticeable when reviewing changes made by an LLM. If it changes alignItems, boxShadow, or paddingInline, I know what changed without having to translate anything first.
With Tailwind, there can be a small extra step. ring-2, for example, gives me a ring around an element, but ring isn't a CSS property. Tailwind takes care of how that effect is implemented.
That's part of what makes Tailwind convenient, so I don't mean that as a criticism. I've just found that when I'm reviewing code I didn't write myself, I quite like seeing the underlying CSS vocabulary.
There is a trade-off here. With Tailwind:
<div className="flex items-center gap-3 px-4">
already tells me quite a lot about the element.
With StyleX:
<div {...stylex.props(styles.root)}>
I have to go find styles.root.
So I don't think one is simply more readable than the other. I just seem to prefer the StyleX version once the styling becomes more complicated.
Giving relationships names
One StyleX feature I hadn't expected to care much about is markers.
We've done similar things with Tailwind using data-slot and :has(). That can result in selectors like:
[&:has(>[data-slot=icon]:only-child)]:aspect-square
It works, but I usually need a moment to parse it.
In this project, my TextInputControl has a marker:
export const textInputControlMarker = stylex.defineMarker();
The surrounding component can then react to the state of that input:
borderColor: {
default: color["--color-neutral-200"],
[stylex.when.descendant(
":focus-visible",
textInputControlMarker
)]: color["--color-primary"],
[stylex.when.descendant(
'[aria-invalid="true"]',
textInputControlMarker
)]: color["--color-danger"],
},
CSS is still doing the work underneath. The part I like is simply that textInputControlMarker gives the relationship a name.
That makes it easier for me to understand when I come back to the code, and it also gives the LLM something concrete to find and reuse instead of rebuilding a selector from scratch.
This is probably the recurring theme I've noticed most with StyleX: things tend to be a little more verbose, but quite a few of them also become named pieces of code.
The system can still be ignored
Of course, having all of this structure doesn't mean it will always be followed. For example, I have z-index tokens in this project, but I still found this:
zIndex: 40,
That should have used one of the existing tokens. StyleX doesn't prevent me from writing a random value there, and it doesn't prevent an LLM from doing the same thing.
What helps is that there is already a pattern to follow. The token exists in the codebase, just like xstyle, variants, and markers do elsewhere.
I've found that this makes corrections fairly straightforward. Instead of explaining how I want something to work from scratch, I can usually point the LLM towards an existing pattern and ask it to use that.
It can still get things wrong, but the more of those conventions that are visible in the code, the less it has to invent for itself.
So, is StyleX better for LLMs?
I still don't really know. LLMs seem perfectly capable of writing Tailwind, and Tailwind's popularity and sensible defaults probably work in its favour.
What I understand better now is why people find StyleX interesting in this context.
Tokens are imports. Variants and states have names. CSS properties are explicit. Component overrides can follow one convention. TypeScript and the compiler provide some feedback when the generated code doesn't fit.
None of that is exclusive to StyleX, and you can build a disciplined Tailwind codebase as well. It's just the way these things ended up coming together in this project.
The main trade-off is still pretty obvious to me: StyleX makes me write more CSS.
That used to be enough of a downside that I probably wouldn't have bothered with it, as I was fairly happy with the convenience Tailwind provided. With an LLM doing more of the mechanical typing, though, that part matters less to me than it used to.
I'm still making the decisions about how something should look and how the styling system should work. I just don't particularly care whether I personally typed paddingInline: spacing["--spacing-4"].
For this project, I've enjoyed that balance enough to keep using StyleX. I'm not planning to convert existing Tailwind projects, and if I needed to build something quickly by hand tomorrow, I'd probably still reach for Tailwind. But StyleX has been a more interesting experiment than I expected.
And, slightly annoyingly, writing CSS inside JavaScript doesn't bother me nearly as much anymore.