Production Patterns for shadcn Components Generated in v0
shadcn/ui components are fine in isolation, but v0 generates them without theming, accessibility review, or form integration. Walk through the four hardening steps we apply before shipping.
A v0 prompt returns a shadcn component in about twelve seconds. It looks correct, it compiles, and it probably renders the happy path of whatever you described. That output is a prototype, not a production artifact, and the gap between the two is wider than most vibe coders realise. The component is styled with Tailwind classes that reference CSS variables you have not audited, the Radix primitive underneath has default accessibility that you have not verified is intact, the form inputs are wired to nothing, and the only state the component knows how to render is the one where data already exists. Ship it as generated and your users will meet broken focus rings, missing labels, silent validation failures, and white-space where a loading skeleton should live.
The four hardening steps below are the sequence we run on every v0-generated shadcn component before it reaches a main branch. None of them require rewriting what the model produced. They require reading it carefully, understanding which parts of the shadcn design system it leaned on, and filling in the pieces that the generator cannot know without seeing your application.
Step one is propagating your theme tokens so the component actually looks like your product
When v0 returns a Card or a Dialog, the Tailwind classes on it look like bg-background, text-foreground, border-input, and ring-ring. Those are not colours. They are references to CSS custom properties defined somewhere in your globals.css file, usually as HSL triplets inside a :root block and a matching .dark block. The shadcn design system is built entirely on this indirection, which is the feature that makes it themeable in the first place and also the trap that makes v0 output look generic until you wire it up.
The first failure mode is that v0 ships a default token set that approximates a neutral slate palette. If your brand primary is a warm coral and your background is an off-white with a hint of cream, none of that carries across. You need to open globals.css, confirm the full token list is present, and replace the generated values with your brand palette expressed as space-separated HSL components without the hsl() wrapper. The wrapper lives in the Tailwind config or the @theme block. Skipping this step leaves you with components that technically work but look like every other shadcn demo on the internet.
The second failure mode is more subtle and catches teams well after launch. Radix primitives render overlays like Dialog, Popover, Tooltip, and DropdownMenu through a React portal that mounts directly to document.body. If your dark mode is activated by adding a class to or
, CSS variables propagate into the portal and everything looks right. If you scoped the class to an inner container, or if you render a modal inside a subtree that flips themes independently, the portal escapes that subtree and the overlay renders with the wrong token set. The fix is to attach the theme class at the document root and verify that every CSS variable the component touches has a value on both :root and .dark. Missing tokens are easy to spot by opening the rendered component in devtools and looking for computed properties that read as an empty string or inherit from the user agent default. Tokens that frequently get omitted include --ring, which controls focus outlines, and the --chart-1 through --chart-5 series used by any data visualization the model pulled in.Step two is a deliberate accessibility review because Radix defaults do not cover what v0 changes
The shadcn components that v0 generates are wrappers around Radix UI primitives, which ship with keyboard navigation, focus management, and ARIA wiring already correct. This is the reason people feel safe using shadcn without an accessibility audit. The problem is that v0 frequently makes three kinds of changes that break those defaults without announcing it.
The first change is collapsing a labelled button into an icon-only button. The model produces for a close action and moves on. Radix cannot infer a label from an SVG, and screen readers announce the element as a bare button with no purpose. Every icon-only button needs either an aria-label attribute or a visually hidden span, and the review pass consists of grepping the component for size="icon" and confirming each instance is labelled. The same applies to icon-only links and menu items.
The second change is custom close handlers on Dialog and Sheet. Radix installs a focus trap when the overlay opens and releases it when onOpenChange fires with false. v0 sometimes adds a close button that calls stopPropagation or mutates state directly without going through onOpenChange, which leaves the trap active and the focus wandering back into unreachable nodes. The review catches this by opening every Dialog in the output, tabbing through until the focus leaves the overlay, and confirming that closing the overlay returns focus to the trigger. If focus does not return, the close handler is bypassing the Radix state machine and needs to be rewritten to route through the component's open prop.
The third change is focus visibility. Modern browsers support :focus-visible, which shows a focus ring for keyboard users and suppresses it for mouse users. shadcn's default Button uses focus-visible:ring-2 with the --ring token, but v0 sometimes regenerates the className and reverts to plain :focus, which either shows no ring or shows it on every click. The fix is to restore the focus-visible variant and verify the --ring token is both defined and contrasty enough to be seen on the element's background. A focus ring the same hue as the background is functionally invisible.
Two further items belong in this pass even though they are not strictly about what v0 changed. First, landmark elements. v0 returns component-scoped JSX without knowing whether the page is missing a
Step three is connecting the form to react-hook-form and zod so validation actually happens
The single most common failure in v0-generated forms is that the inputs render, accept keystrokes, and fire an onSubmit, but nothing validates and nothing reads the error state. The model produces a flat inside a
Get weekly field notes.
Practical writing on shipping products, straight to your inbox. No spam.
Need help with this?
MVP Development
We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.
Talk to usWant to discuss vibe coders for your business?
Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.