DEV Community

Cover image for shadcn/ui says the code is yours. So why can't anything edit it?
Jack Lee
Jack Lee

Posted on Originally published at blog.crossui.com

shadcn/ui says the code is yours. So why can't anything edit it?

~9 min read

shadcn/ui's whole pitch is that it isn't a dependency. You run the CLI, the component lands in components/ui/button.tsx, and it's yours. No package to upgrade, no wrapper to fight, no !important war with someone else's stylesheet.

Which raises a question nobody asks out loud: if the code is mine, why is hand-editing the only way to change it?

Not a rhetorical complaint. There's a real technical answer, and it isn't the one you'd guess.

Visual editors assume a prop table. shadcn doesn't have one.

Every visual React editor I've looked at makes the same bet: components arrive from a package, and that package has a documented, stable prop surface. MUI is the ideal case. <Button variant="contained" size="large" color="primary"> — three enums with known values. A tool can read the type, render three dropdowns, write back a string. It works because the component is a black box with a labelled control panel bolted to the front.

Now open a shadcn button.

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium ...",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground ...",
        outline: "border border-input bg-background hover:bg-accent ...",
      },
      size: { default: "h-10 px-4 py-2", sm: "h-9 px-3", lg: "h-11 px-8" },
    },
    defaultVariants: { variant: "default", size: "default" },
  }
)
Enter fullscreen mode Exit fullscreen mode

There's no prop table. There's a variant map — written in your repo, in plain TypeScript, in a file you own.

The standard read is that this is worse for tooling: no types to introspect from a package, no docs to scrape, everyone's copy drifts. And it's true that you can't point a package-shaped tool at it.

I think the opposite conclusion is the right one. A CVA map is strictly more information than a prop table, and it's information you already have on disk.

A prop table tells you variant accepts "contained". It does not tell you what "contained" does. That's compiled into the library. To find out, you read the docs, or you guess, or you try it.

The CVA map tells you variant: "destructive" is bg-destructive text-destructive-foreground. The mapping is right there, as data, in source you control. Nothing is hidden, because nothing was ever packaged.

So the argument is simple:

Structured visual editing needs to know what a control does, not just that it exists.
With a package component, that knowledge lives in the library's compiled internals.
With shadcn, it lives in your repo as a literal object.
shadcn should be the easiest component library to edit visually, not the hardest.

Instead, the thing I can't find anywhere is structured variant editing driven by your CVA map, in your repo. Visual editors for Tailwind exist, and theme editors for shadcn exist — but a theme editor rewrites CSS variables, which is a different job: it changes what bg-primary resolves to, not which variant a button is using or what that variant is made of. The obstacle to the second one turns out to sit a layer below variants.

The obstacle: shadcn's styling is class names, and class names need a build step

MUI styles at runtime. sx={{ p: 2 }} becomes CSS while the component renders — nothing precomputed, nothing to scan. MUI in a browser with no build step is plenty of work, but none of it is styling work: it's module resolution, keeping emotion and the theme as true singletons, and shipping a coherent set of package versions (we vendor eight MUI-related builds across three version tiers for exactly that reason). We wrote about that side here. Once the modules resolve, the styles take care of themselves.

Tailwind works the other way around. It scans your source files, sees which utility class names appear as text, and generates exactly those rules. No scan, no CSS. And "scanning source files" is a build step.

So for a tool that renders a real project in the browser with no install and no dev server, shadcn presents a wall that MUI never did. You can compile button.tsx perfectly and get an unstyled button. The class names are all present in the DOM and mean nothing, because nothing generated the rules.

That's the actual reason visual tooling stops at the door. Not variants. Styling.

There's a sharper version of the problem, too. Tailwind's scanner reads source text. But shadcn components don't have class names in source text — they have a function call:

<button className={cn(buttonVariants({ variant, size }), className)} />
Enter fullscreen mode Exit fullscreen mode

There is no string "bg-destructive" anywhere in that line. It's assembled at runtime from the CVA map, the incoming props, and whatever the caller passed.

In a normal build this is handled, and handled well: the variant map file gets scanned too, the literals live there, and the extractor sees them. (Worth saying plainly, since it gets garbled a lot: Tailwind's rule is don't construct class strings like `text-${color}-500`. CVA variant maps hold complete literals. shadcn is the recommended pattern, not a workaround, and it needs no safelist.)

In a browser with no build step, there is no scanning pass at all — so there is nothing to handle it. That's what makes this version of the problem sharper rather than the same problem again.

What we did instead: collect classes from the DOM, not from the source

The fix is to stop scanning source entirely.

Studio runs Tailwind's real browser engine — the official one, not a reimplementation. The version is picked from what the project declares: v3 projects get Tailwind's Play CDN engine, vendored in the app rather than fetched from the CDN; v4 and up get @tailwindcss/browser, likewise vendored. A remote fetch happens only when no vendored build matches the declared major — a v5 project, say. Your tailwind.config is fed to the engine, so your theme extensions are the ones in effect.

The obvious question here is why not run Tailwind's own extractor in the browser and scan the source after all. We measured what that costs: one render already issues somewhere between 40 and 340 file reads on the host side, and on a large template a single read runs 5–8 ms with no throughput gain from concurrency. Scanning the repo means reading every file instead of the ones that render. And after paying for it you still miss class names that come from runtime data. Collecting from the DOM isn't the lazy option — it trades "read the whole repo" for "read the result once."

Running the engine gets you halfway. Then the interesting problem shows up.

The engine is document-scoped and only observes light DOM — it calls observe(document.documentElement) and never touches shadow roots. Studio's design mode renders the template inside a shadow root, for isolation. So the engine looks at the document, sees none of your component's class names, and generates nothing. Correct engine, correct config, zero output.

What bridges it:

  1. After render, walk the shadow DOM and collect every class token actually present on actual elements.
  2. Write those tokens into a hidden sink element in the light DOM, where the engine can see them.
  3. The engine generates rules for exactly the utilities in use.
  4. Adopt the resulting stylesheet back into the shadow root.
  5. A MutationObserver on the shadow root — subtree, attributeFilter: ['class'] — catches everything that appears later: route changes, lazy content, anything conditional.

One detail worth flagging because it cost real time: the sink must be inserted with native appendChild. The canvas patches document.body.appendChild to redirect portal content into the shadow root, and going through the patched version buries the sink inside the shadow root — where the engine can't see it. The bridge then fails silently, which is the worst kind.

The sink is a real node in the light DOM, and it keeps filling as you use the app — switch routes and the classes the new page needs show up in it, without a reload.

There's a second gotcha like the first one. It's not a shadcn thing — it comes from other templates we render — but it's the same failure shape. Tailwind v3 templates built on Next often set important: '#__next' in their config, scoping every utility under the app's root ID. That ID doesn't exist in the canvas, so every utility silently fails to match. The config's selector gets redirected to the live render root — and the replacement has to also be an ID selector, because the (1,0,0) specificity is doing load-bearing work against the template's own CSS.

The payoff for doing it this way:

Because collection happens from the DOM instead of the source, any class name assembled at runtime works. cn(), clsx, twMerge, CVA, a ternary, a lookup table, a class name built from a prop by string concatenation — the engine never has to understand any of it. By the time collection runs, the composition already happened and the result is sitting in a class attribute. shadcn-admin renders correctly not because we special-cased shadcn, but because this approach is indifferent to how the string got there.

The honest limit: only classes that have actually rendered get generated. A real Tailwind build scans source, so it emits both branches of cond ? 'bg-red-500' : 'bg-blue-500' even though only one can be on screen. Here the untaken branch has no CSS until it renders. The MutationObserver fills it in when state flips — but that means the frame where it flips can be unstyled. In practice you see it as a brief flash on the first toggle of a variant you haven't hit yet.

It cuts the other way too, which is why I'd call it a tradeoff and not a deficit. Take statusColors[res.status], where the key arrives from an API response. No complete utility literal exists anywhere in the source, so a scanning build generates nothing for it — this is the case safelists exist to paper over. DOM collection doesn't distinguish it from anything else; by the time we look, the class is on the element. Source scanning gets you branches that haven't run. DOM collection gets you class names that were never written down. You pick which one you'd rather have, and in a browser the choice is mostly made for you.

Where this leaves the argument

So: shadcn-admin renders. A template built entirely on class names, CVA variants, and cn() composition, running with no install and no dev server, with the real Tailwind engine and your real config.

Which is worth something specific, and it isn't "visual editing."

Think about the last time you opened an unfamiliar admin template to decide whether to use it. You cloned it, installed it, waited, ran it, clicked around for four minutes, and closed it. Most of that time was the machine's, not yours. Now think about the last time you wanted to show a teammate what a component looked like in three states, or check whether a template's dark mode was actually finished, or figure out where a card's spacing came from before quoting a change. Same tax, every time, for a question that takes seconds to answer once something is on screen.

That tax is the thing that's gone. A shadcn project renders in seconds with its real classes, and then everything Studio does that isn't styling-specific works on it: selection that goes both ways between code and canvas, drilling into a component defined five files away, seeing what breaks if you delete a file. Those operate on your code, not your styles, so they never cared which styling system you picked. They just needed the thing to render first.

What's still missing is the styling side of the inspector: className is edited as a string like any other prop, nothing reads your CVA map to build variant controls, and your tailwind.config tokens reach the engine but not the panel. The MUI side has a theme editor and a structured sx tree; the Tailwind side has none of that yet. I'd rather say so than let a demo imply otherwise.

But that ordering is the actual point, and it isn't obvious from outside:

shadcn's styling is class names, not props.
So correctly rendering runtime-composed class names is the prerequisite for editing them in any structured way.
That prerequisite is genuinely hard in a browser, because the engine only sees light DOM and design-mode rendering happens in a shadow root.
The class bridge solves it by collecting from the DOM rather than the source.
The prerequisite is now in place.

What sits on top of it is a smaller problem than what's underneath, but not a trivial one, and I don't want to wave at it. A CVA map is a literal object in a file you own; reading it and rendering a variant selector is ordinary work. Writing the change back is not. A class name can arrive from three composition sites at once:

className={cn("border-b", isActive && "bg-red-500", buttonVariants({ variant }))}
Enter fullscreen mode Exit fullscreen mode

Set padding to 6 in a panel, and something has to decide which literal to edit — the base string, the conditional, or the variant definition that every other button on the page also uses. Sometimes there's no correct answer, only a policy. That's the next real problem, and it's hard in a different way than this one was.

If you'd asked me before starting which half would be hard, I'd have guessed wrong.

If you work in shadcn and have opinions about what that control surface should look like — variant dropdowns driven by the CVA map, spacing controls that rewrite utilities rather than inline styles, a rule for which literal a write-back should land in — that's a conversation I'd like to have before building it rather than after. Studio has a free tier; point it at a shadcn project and tell me where it's wrong.

Top comments (0)