- Author
-
-
- User
- linb
- Posts by this author
- Posts by this author
-
~9 min read
Last week I wrote about why shadcn is hard to edit visually, and somewhere in the middle I made a claim in passing: shadcn's styling is a string, MUI's sx is data, and those two need different editors.
A few people asked the obvious follow-up. Fine — what does the data side actually look like?
This is that answer. It's less dramatic than the shadcn one, because nothing here was blocked on a rendering trick. It's more useful, though, because the interesting decisions are all about where to stop.
Put the two side by side:
className="p-4 hover:bg-accent md:p-6"
sx={{ p: 4, '&:hover': { bgcolor: 'accent' }, '@media (min-width:900px)': { p: 6 } }}
Same design intent. The first is a token list that happens to have conventions baked into the token names. The second has actual shape: keys, nesting, values you can address by path.
A string has to be parsed before you know what's in it. An object already knows. Which sounds like it makes the editor easy — and the tree part genuinely is easy. The hard parts moved somewhere else: deciding which keys deserve to be structure, and putting a change back in the file without touching anything else.
Nesting is a tree, so the UI is a tree
The rule for what becomes a branch is one line:
key.startsWith('&') || key.startsWith('@') || key.startsWith(':')
Hit → expandable branch, recurse. Miss → leaf, render a control.
That's the whole thing, and it covers more than you'd expect. &:hover, @media (min-width:900px), & > *, &::placeholder, & .MuiButton-root — all of them become tree nodes for free, because CSS-in-JS conventions are prefix conventions.
Seventeen of them ship with human labels: &:hover → Hover, @media (min-width:900px) → MD, & > * → Direct Children. Anything not in that list still nests correctly, just without a friendly name. The rule is general; the labels are a courtesy.
One decision in there is worth pulling out, because it's the kind of thing that only shows up once you've used the panel on real code:
isBranch = !isExpression && !isResponsive && (hasChildren || isSelector)
Expressions and responsive values win over selectors. If a key looks structural but its value is one of those two, it stays a leaf.
That isn't a house style. It's how MUI's own sx processor dispatches. When it walks an sx object and hits a nested object, it asks whether the keys are breakpoints before it treats the thing as a nested style block — roughly:
hasBreakpoint(value) ? resolveResponsive(value) : recurseAsNestedStyles(value)
A breakpoint object is a value spread across viewports. A selector object is a scope containing more styles. They're written with identical syntax and mean entirely different things, so the styling engine has to disambiguate before it can do anything, and it disambiguates on the keys.
The panel is drawing that same decision. { xs: 2, md: 3 } renders as one responsive control rather than a two-child subtree because that's what MUI is going to do with it. An expression stays whole for the same reason: MUI calls it with the theme and uses the result as a value, so the panel treats it as a value too.
Which is also the practical answer. When someone writes a responsive object or drops in an arrow function, they're holding it in their head as one thing. Exploding it into a subtree turns a single decision into rows you have to keep in sync — and now the panel's model of the file disagrees with the framework's.
Keep that in mind for the write-back section. The same instinct runs through it: touch as little as possible.
The tree's edges have to follow the real semantics
Breakpoints in the panel aren't hardcoded. They come from the same VIEWPORTS config the canvas uses for device sizes — MUI's breakpoint keys, with a representative device width picked for each: xs 375, sm 600, md 900, lg 1200, xl 1536.
(Those widths are canvas device widths, not breakpoint thresholds. sm through xl happen to line up with MUI's defaults; xs doesn't, because MUI's xs floor is 0 and you can't render a 0px canvas.)
The nice consequence is that the breakpoint you click in the style panel and the device you switch to on the canvas are the same object. Not two lists that have to be kept in sync by whoever remembers.
Promoting a value to responsive fills all five breakpoints with the current value:
{ p: 2 } → { p: { xs: 2, sm: 2, md: 2, lg: 2, xl: 2 } }
Not { xs: 2, md: 3 }. You differentiate afterward, by hand, at whichever breakpoint you actually care about.
I've been asked why it doesn't write a minimal object. Because the panel is showing you five rows, and three of them would be blank while still rendering something — MUI cascades responsive objects upward, so the value is real even when the key is absent. Blank rows that aren't blank in the output are a bad place to start editing from. Filling all five means promotion is a visual no-op: nothing changes on screen, you just gained five editable slots. Then you change one.
And when you do change one, only that one gets written. Editing the md slot produces a patch carrying dirtyPath: ['md'], which becomes ['p', 'md'] by the time it reaches the writer. The other four are never re-emitted. (That matters more than it sounds. It's the next section.)
Here's the part I think is actually worth the post, and it took me a while to see why it wasn't a limitation.
The same-looking value gets different treatment depending on which path it's written on.
Inside sx={{ ... }}, any property can be promoted — including custom CSS keys you invented. sx is MUI's own styling channel and it accepts breakpoint objects wherever a value goes. (On any component that processes sx, which is every MUI component. A plain <div> doesn't have an sx prop to begin with.)
Written as a top-level system prop, it can't:
<Box p={{ xs: 1, md: 3 }}> {/* fine */}
<Typography p={{ xs: 1, md: 3 }}> {/* not a thing */}
Only Box, Stack, and Grid support system props as top-level breakpoint objects. Typography's system props were deprecated in v6. So the panel offers promotion on those three and withholds it elsewhere — on the top-level path only.
And under either path, a value that came from a spread can't be promoted at all, because there's nowhere unambiguous to write the result.
That's three different answers for what looks, in the inspector, like the same number:
If the panel flattened those into one rule, one of two things goes wrong. Offer promotion everywhere and you get a control that produces code MUI ignores — the panel looks right, the render doesn't change, and you lose twenty minutes deciding whether you misunderstood breakpoints or found a bug. Withhold it everywhere outside Box/Stack/Grid and you've disabled a feature that works fine, on the path where it works fine.
So the panel's edges follow MUI's semantics rather than a single convenient fiction about them.
That's the concrete version of the argument I was making last week. "Does this accept a breakpoint object here?" is knowledge about the styling system, it differs by path within the same component, and it's only actionable if the styles are data you can reason about. A tool that edits className as a string has no way to know the difference, and no place to put the distinction even if it did.
The three-second version, if you want to check it: put p={{ xs: 1 }} on a Box and on a Typography, then look at the same spacing value written inside sx on both. Three of those four offer promotion. On a free account the affordance is a gold lock rather than the control — responsive promotion is a paid feature — but a lock and an absence are different states, and telling them apart is the whole exercise.
Writing back: no reserialize
Most people assume a visual editor works like this: read the file, parse to AST, mutate the AST, print it back out.
That assumption has a well-known answer already — recast and ts-morph have preserved original formatting for unmodified nodes for over a decade. So this isn't a story about a tool that mangles your file versus one that doesn't. That problem is solved.
The difference here is where the ranges live. The IR carries them. Every value node knows its own start and end offset in the source, so an edit doesn't need a print-then-reconcile pass at all. An edit is a path plus a new value, and the path resolves to a byte range:
- Every IR value node carries range_v— where that value starts and ends in the source.
- An edit produces a dirtyPath—['p', 'md']— naming the one thing that changed.
- Write-back replaces that range. One splice.
Which means your formatting, your comments, your quote style, that arrow function three properties down, the blank line you put there on purpose:
They aren't preserved. They're untouched. We never had them in hand, so there's nothing to get wrong.
There's a second thing worth mentioning here, because it surprised me when I traced it. The theme editor and the sx editor are the same writer. Three commands form one cascade:
updateJSObject > updateStyle > updateProp
updateJSObject — the one the MUI theme editor emits — wraps itself in a synthetic style, then rewrites its own command type and falls through to updateStyle, which does the same thing again and falls through to updateProp. Editing createTheme()'s palette and editing an inline sx value converge on the same byte-range splice by the time they hit the file.
That 1310-line theme editor isn't a second implementation. It's a second entrance to one hallway.
The part that answers last month's post
I ended the shadcn piece on a problem I didn't have an answer for:
className={cn("border-b", isActive && "bg-red-500", buttonVariants({ variant }))}
Set padding to 6 from a panel, and something has to decide which literal to edit. The base string? The conditional? The variant definition every other button on the page also uses? Sometimes there's no correct answer, only a policy.
On the sx side — when sx is an inline object literal — the question doesn't come up. Not because we solved it. Because objects have paths. sx.p.md names exactly one span of bytes in exactly one file, and the panel doesn't have to choose anything.
The qualifier is load-bearing, so let me be exact about it. Two shapes don't get that guarantee:
sx={sharedSx} // the path isn't in this JSX attribute at all
sx={{ ...base, p: 4 }} // whatever base contributes lives somewhere else
The first is the same class of problem as the shadcn one, arrived at from the other direction. The second I'd rather not characterize than characterize wrong.
But that's the real difference between the two sides, and it's narrower than "objects good, strings bad": an inline object literal gives you an addressable path; string concatenation never does, no matter how it's composed.
Where it refuses, and how
Plenty of sx values can't be turned into controls. That refusal happens when the panel reads, not when it writes.
The IR has exactly three value shapes:
Plus { code, object } — source text and parsed result, both retained.
So sx={{ p: theme => theme.spacing(2) }} becomes { code: "theme => theme.spacing(2)" }, stays a leaf, and gets a code editor instead of a slider. If the entire sx prop is an expression the panel switches itself to advanced mode. Properties outside the Basic set say so in as many words: "This style is not available in Basic mode."
None of that is silent degradation. The boundary is stated: here's a control, here's why there isn't one, here's your original text back. A structured editor that guesses wrong rewrites your source incorrectly. One that drops to text doesn't.
Being straight about the gaps
Three, and the third is the one that bothers me.
Responsive promotion is a paid feature. Free accounts get a gold lock and an upgrade prompt. I'd rather you learn that here than from the lock.
On the top-level system-prop path it's Box/Stack/Grid only — explained above, and I think that's correct rather than partial, but it does mean the affordance is absent in places where a casual read would expect it.
Your custom theme tokens don't appear in the sx autocomplete. The theme editor edits your project's real createTheme(). The sx suggestion list is a hardcoded table of MUI's default keys. palette.primary.main is in it. palette.brand.main, which you defined, is not.
That last one should look familiar if you read the shadcn post. I wrote there that your tailwind.config tokens are fed to the engine but never reach the inspector. This is the identical seam in a completely different styling system:
The rendering pipeline gets your project's configuration. The editing surface doesn't.
Two style systems with nothing in common, the same unconnected wire in both. That's not two oversights. That's one missing idea — project configuration isn't a first-class input to the panel yet — and finding it twice is how I know it's structural rather than a todo someone forgot.
The actual difference
sx can be a tree because MUI's styling happens to be a data structure. shadcn's happens to be a string. Neither is a design failure; they're different bets, and both are reasonable.
But they hand a tool completely different jobs. One asks you to understand structure. The other asks you to render it correctly before you can understand anything at all.
Change a value inside a string, and you have to decide which part of the string to change. Change a value inside an object, and the path already told you.
Everything hard about visual editing lives on one side of that sentence or the other.
If you work in MUI: Studio has a free tier. Open a template, find an sx prop with a &:hover in it, and tell me where the tree gets it wrong. The write-back path is the part I'd most like to hear about breaking.
About CrossUI Studio — A true symmetric visual editor for React apps. It runs entirely in the browser with zero build steps, while an intelligent dependency graph maps your codebase instantly. Visually drill down through components across physical files—into a .map, a ternary, or a nested slot. The SCD engine keeps code and canvas in sync through atomic AST patches—touch a prop, and only that prop moves. Your git diff reads exactly like you wrote it: comments, formatting, and intent preserved. No fork. No lock-in.