shadcn/ui components are source code you own — modify them like any other file in your project, no abstraction fights
CSS variables in globals.css control the entire theme — change one file and every component updates without touching component code
Use the cn() utility (clsx + tailwind-merge) to compose class names without specificity conflicts when extending components
Slot-based composition with Radix's asChild prop lets you wrap components in any element without duplicating markup
Wrap shadcn/ui primitives in custom hooks to create reusable behavior patterns (useToast, useDialog, useConfirm)
Extend components with variants using class-variance-authority (cva) — the same pattern shadcn/ui uses internally
Without tailwind-merge, class conflicts resolve unpredictably by CSS specificity order — add ~40ms of bundle size but eliminate all style fights
Production insight: missing tailwind-merge in cn() leads to silent style bugs that appear only in production builds where CSS order differs from dev
Plain-English First
Imagine buying a house where you get the architect's original blueprints, not just the finished building. You can move walls, change materials, and add rooms without calling the architect. That's shadcn/ui — you get the source code, not a black-box package. Most developers use it like a regular component library and miss 90% of its power. These 10 tricks show you how to use the blueprints.
Most shadcn/ui tutorials cover installation and basic usage — run the CLI, import a Button, style it with Tailwind. That gets you 20% of the value. The remaining 80% comes from understanding how the components are built: Radix primitives for accessibility, class-variance-authority for variants, tailwind-merge for class composition, and CSS variables for theming.
These 10 patterns are extracted from production codebases where shadcn/ui has been extended, customized, and composed in ways the documentation doesn't cover. Each trick solves a real problem — theme switching without component re-renders, composable dialogs without prop drilling, animated skeletons that match your content layout, and type-safe variant systems that scale to 50+ button styles.
This is not a beginner guide. Assume you have shadcn/ui installed and have used at least 5 components. (Trick 3 builds on Trick 1, Trick 7 builds on Trick 4; the rest are independent.)
Trick 1: The cn() Utility — Your Single Most Important Function
Every shadcn/ui component uses a cn() utility to compose class names. The default implementation combines clsx (conditional class joining) with tailwind-merge (intelligent Tailwind class deduplication). Without tailwind-merge, conflicting Tailwind classes produce unpredictable results — bg-blue-500 and bg-red-500 in the same className attribute resolve based on CSS specificity order, not your intent.
The utility lives in lib/utils.ts. Most developers copy it without understanding it. The key insight: tailwind-merge parses Tailwind class names and intelligently merges conflicting utilities. If you pass bg-blue-500 bg-red-500, it keeps only bg-red-500 — the last one wins. This makes component extension predictable: variant defaults can always be overridden by user-provided classes.
UsePass user className as the last argument — tailwind-merge ensures it wins
IfComplex variant system with 10+ combinations
→
UseUse class-variance-authority (cva) — see Trick 4
Trick 2: CSS Variables Control the Entire Theme — One File, Every Component
shadcn/ui's theming system is entirely CSS variable-based. Every color, border radius, and spacing value is defined as a CSS custom property in globals.css. Change a variable value and every component updates — no component code changes, no Tailwind config rebuilds, no JavaScript runtime overhead.
The variables are scoped to light and dark modes via the .dark class on the html element. This means theme switching is a single class toggle — no re-renders, no context providers, no JavaScript color calculations. The browser handles the switch natively.
The production insight: you can create multiple themes by defining additional CSS variable sets (e.g., .theme-forest, .theme-ocean) and toggling classes. Each theme redefines the same variable names with different values. Components never need to know which theme is active — they read the same variables regardless.
UseDefine .theme-name selectors with CSS variable overrides — toggle class on html element
IfNeed per-component theme overrides
→
UsePass CSS variables as inline styles on the component wrapper — scoped to that subtree
IfDesign system uses hex colors
→
UseConvert to HSL first — shadcn/ui's opacity modifiers require HSL format
Trick 3: Slot-Based Composition with asChild
Every shadcn/ui component built on Radix primitives supports the asChild prop. Instead of rendering its own element, the component passes its behavior, accessibility attributes, and event handlers to its child element. This is called slot-based composition.
The practical benefit: you can turn any element into a button, link, or trigger without duplicating markup or fighting CSS specificity. A Tooltip trigger can be a Next.js Link, a custom styled div, or a third-party component — the Tooltip behavior attaches to whatever child you provide.
Important gotcha: Radix portals (Dialog, Sheet, Tooltip, etc.) render into document.body. If your navbar has z-40, the dialog may appear behind it. Always use shadcn/ui's z-50 scale for portals.
Rule: always use asChild when a shadcn/ui trigger needs to be a Link, custom element, or third-party component.
Key Takeaway
asChild passes component behavior to its child element — no wrapper element, no invalid HTML.
Every Radix-based shadcn/ui component supports it — DialogTrigger, TooltipTrigger, DropdownMenuTrigger.
Punchline: use asChild whenever the trigger element needs to be something other than the component's default — Link, custom div, or third-party component.
asChild Usage Decisions
IfTrigger element should be a Next.js Link
→
UseUse asChild on the trigger — Link receives the behavior directly
IfTrigger element needs custom styling not available in the component
→
UseUse asChild with a styled div or span as the child
IfTrigger is the default button element with default styles
→
UseNo asChild needed — the component renders its own element
IfNeed multiple behaviors on one element (tooltip + dialog)
→
UseNest the triggers with asChild — outer passes behavior to inner, inner passes to the element
Trick 4: Extend Components with class-variance-authority (cva)
shadcn/ui uses class-variance-authority (cva) internally to define component variants. Most developers use the pre-built variants without understanding the system. The advanced pattern: define your own variant systems using cva for custom components that need the same type-safe, composable variant approach.
cva defines variants as an object of conditional class mappings. Each variant key (like 'variant' or 'size') maps values to class strings. Compound variants handle specific combinations (e.g., destructive + large gets extra padding). Default variants set the initial state. The result: a single function call that produces the correct classes for any combination of variant values.
The killer feature: VariantProps<typeof variants> auto-generates TypeScript types from your variant definition. Add a new variant value, and the type updates automatically — no manual type editing. This is how shadcn/ui keeps its components type-safe without separate type files.
Punchline: use cva for any component with 3+ style variants — it scales better than if/else class concatenation and keeps types in sync.
Variant System Decisions
IfComponent has 1-2 simple style options
→
UseUse cn() with conditional classes — cva is overkill for simple cases
IfComponent has 3+ variants with multiple values each
→
UseUse cva — type-safe, composable, and auto-derives props
IfNeed specific styles for variant combinations (e.g., destructive + large)
→
UseUse cva compoundVariants — handles combinations without nested ternaries
IfNeed to add a new variant to an existing cva definition
→
UseAdd to the variants object in the component file — types update automatically
Trick 5: Extract Reusable Patterns into Custom Hooks
shadcn/ui components are primitives — Button, Dialog, Toast. They don't encode business logic. The advanced pattern: wrap primitives in custom hooks that combine state management, callbacks, and component rendering into a single function call.
The production pattern: useToast returns an object with toast(), dismiss(), and update() methods. useDialog returns open, setOpen, and a DialogContent wrapper. useConfirm returns a promise-based confirm() function that shows a Dialog and resolves true/false. These hooks eliminate prop drilling and context wiring — one import, one function call, done.
The key insight: hooks manage the state, components manage the UI. Keep them separate. The hook never imports UI components — it returns state and callbacks. The UI layer calls the hook and renders components based on the returned state.
Hook returns state and callbacks — no UI imports, no JSX
Component calls the hook and renders based on returned state — no business logic
This separation lets you swap the UI (Dialog -> Sheet -> BottomBar) without changing the hook
Custom hooks compose shadcn/ui primitives with app-specific behavior (confirm, toast, form wizard)
Rule: if a pattern appears in 3+ components, extract it into a custom hook
Production Insight
Custom hooks eliminate prop drilling — one import, one function call, done.
Hooks manage state, components manage UI — keep them separate so you can swap the rendering layer.
Rule: if a pattern appears in 3+ components, extract it into a custom hook before it becomes technical debt.
Key Takeaway
Custom hooks wrap shadcn/ui primitives with app-specific behavior — confirm dialogs, toast queues, form wizards.
Hooks manage state, components manage UI — keep them separate so you can swap the rendering layer.
Punchline: if a pattern appears in 3+ components, extract it into a custom hook before it becomes technical debt. Remember to render the returned component (e.g. <ConfirmDialog />)!
Custom Hook Decisions
IfPattern uses useState + Dialog in 3+ components
→
UseExtract into a custom hook (useConfirm, useDialog, useWizard)
IfPattern is stateless UI composition
→
UseExtract into a wrapper component, not a hook — hooks need state to justify their existence
IfPattern needs to be called imperatively (toast, confirm)
→
UseUse a hook with a returned render component — imperative call, declarative render
IfPattern is a one-off in a single component
→
UseKeep it inline — don't extract prematurely
Trick 6: Animated Skeletons That Match Your Content Layout
The shadcn/ui Skeleton component is a simple animated div. Most developers use it as a generic loading placeholder — a grey rectangle that pulses. The advanced pattern: build skeleton layouts that exactly match your content layout. When the real content loads, the transition from skeleton to content is seamless — no layout shift, no visual jump.
The technique: use the same grid/flex classes on the skeleton container as on the real content container. The skeleton children use the same dimensions as the real content elements. This requires building skeleton components per-feature (DashboardSkeleton, PostCardSkeleton) rather than using a generic Skeleton everywhere.
The metric that proves this works: Cumulative Layout Shift (CLS). Generic skeletons cause CLS of 0.15–0.25. Layout-matched skeletons reduce CLS to under 0.01. The visual difference is dramatic — users see a smooth transition instead of a jarring layout jump.
UseBuild a per-feature skeleton that mirrors the exact layout structure
IfUsing React Server Components with Suspense
→
UsePass the skeleton as the Suspense fallback — streams in real content when ready
IfMultiple independent sections on one page
→
UseEach section gets its own Suspense + skeleton — progressive loading, no single spinner
Trick 7: Dark Mode Without Re-renders via CSS-Only Approach
Most React dark mode implementations use a ThemeProvider that stores the theme in React state and re-renders the entire tree when the theme changes. This causes a visible flash — the old theme renders briefly before the new theme applies.
shadcn/ui's CSS variable approach eliminates this. The theme is controlled by a class on the html element (.dark). A tiny inline script in the <head> reads localStorage and applies the class before React hydrates. No ThemeProvider. No re-renders. No flash.
The script must be inline in the <head> — not in a React component — because it must execute before the first paint. Any delay causes a flash of the wrong theme.
io/thecodeforge/shadcn-tricks/app/layout.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// Theme script — must be inline in <head>, executes before first paint
function ThemeScript() {
const code = `
(function() {
var theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();
`;
return <script dangerouslySetInnerHTML={{ __html: code }} />;
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<ThemeScript />
</head>
<body className="min-h-screen bg-background font-sans antialiased">
{children}
</body>
</html>
);
}
// Toggle hook — no re-renders, just class toggle + localStorage
'use client';
export function useTheme() {
const toggle = () => {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
// No setState, no re-render — CSS variables handle the visual change
};
return {
toggle,
isDark: () => document.documentElement.classList.contains('dark'),
};
}
Output
Theme switching with zero re-renders — CSS variables change instantly, no React involvement
Watch Out: The Script Must Be Inline in
Production Insight
CSS-only theme switching eliminates the flash of wrong theme — script runs before first paint.
No ThemeProvider, no React state, no re-renders — class toggle on html element changes everything.
Rule: the theme script must be inline in <head> — external or deferred scripts cause a visible flash.
Key Takeaway
CSS-only theme switching via class toggle on html element — zero React re-renders, no flash of wrong theme.
The inline script in <head> must execute before first paint — external or deferred scripts cause a flash.
Punchline: never use a React ThemeProvider for dark mode when CSS variables can handle it — React state is unnecessary overhead for a class toggle.
Dark Mode Implementation Decisions
IfNeed instant theme switch with no flash
→
UseUse CSS variables + inline script in head — zero re-renders, no flash
IfNeed per-component theme overrides
→
UseUse CSS variables scoped to a wrapper div — overrides only that subtree
IfNeed system preference detection
→
UseUse prefers-color-scheme media query in the inline script — respects OS setting
IfUsing next-themes already
→
UseKeep it — but verify suppressHydrationWarning is set on <html> to prevent mismatch errors
Trick 8: Composable Tables with TanStack Table + shadcn/ui
shadcn/ui provides Table, TableHeader, TableBody, TableRow, TableCell primitives — structural components with no logic. The advanced pattern: combine them with TanStack Table (formerly React Table) for sorting, filtering, pagination, and row selection. TanStack Table is headless — it manages state and logic but renders nothing. shadcn/ui provides the rendered table.
This separation gives you full control: TanStack Table handles column definitions, sort state, filter functions, and pagination logic. shadcn/ui's Table components handle the visual output. You wire them together with a thin adapter layer.
UseTanStack Table's manualPagination mode — fetch data in onPaginationChange callback
Trick 9: Extend shadcn/ui Components Without Modifying Source
shadcn/ui gives you the source code — you can modify it directly. But direct modifications create a maintenance problem: when shadcn/ui releases updates, you can't easily diff upstream changes against your customizations.
The advanced pattern: create wrapper components that extend the original without modifying it. The original source stays untouched. Your wrapper adds behavior, styling, or composition patterns. When shadcn/ui updates, you compare the new source against the original (not your wrapper) and merge selectively.
The production pattern: create two layers — ui/ for untouched shadcn/ui components, and components/ for your extended versions. Re-export the original alongside your extended version. Simple use cases import from ui/. Complex use cases import from components/.
import * as React from 'react';
import { Button as ShadcnButton } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Loader2 } from 'lucide-react';
// ExtendedButton — adds loading state, icon slots, and shortcut hints
// Original shadcn/ui Button source is untouched
// When shadcn/ui updates, diff against the original — not this file
interfaceExtendedButtonPropsextendsReact.ComponentProps<typeof ShadcnButton> {
loading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
shortcut?: string;
}
export function Button({
loading,
leftIcon,
rightIcon,
shortcut,
children,
disabled,
className,
...props
}: ExtendedButtonProps) {
return (
<ShadcnButton
disabled={loading || disabled}
className={cn(
'gap-2',
shortcut && 'pr-12',
className
)}
{...props}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : leftIcon ? (
<span className="shrink-0">{leftIcon}</span>
) : null}
{children}
{rightIcon && !loading && (
<span className="shrink-0">{rightIcon}</span>
)}
{shortcut && (
<kbd className="pointer-events-none ml-auto inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
{shortcut}
</kbd>
)}
</ShadcnButton>
);
}
// Re-export the original for simple use cases
export { ShadcnButton };
// Usage:
// import { Button, ShadcnButton } from '@/components/extended-button';
//
// Extended (with loading state):
// <Button loading={isSubmitting} leftIcon={<Save />}>Save</Button>
//
// Original (no extras needed):
// <ShadcnButton variant="outline">Cancel</ShadcnButton>
Output
Extended Button with loading state, icon slots, and shortcut hints — original source untouched
Extension vs Modification Mental Model
Extension: create a wrapper that imports the original — original stays untouched
Modification: edit the original source directly — you own future merge conflicts
When shadcn/ui updates, diff the new source against the original — not your wrapper
Re-export the original alongside your extended version — simple and advanced use cases coexist
Rule: extend for behavior changes, modify only for fundamental structural changes
Production Insight
Wrapping shadcn/ui components keeps the original source untouched — easy to diff and merge upstream updates.
Re-export the original alongside your extended version — simple and advanced use cases coexist.
Rule: never modify shadcn/ui source directly for behavior changes — wrap and extend via composition.
Key Takeaway
Wrap shadcn/ui components to add behavior — original source stays untouched for easy upstream diffing.
Re-export the original alongside your extended version — simple and advanced use cases coexist.
Punchline: never modify shadcn/ui source directly for behavior changes — wrap and extend via composition.
Extension vs Modification Decisions
IfAdding loading state, icon slots, or shortcut hints
→
UseCreate a wrapper component — original source stays untouched
IfChanging variant styles or adding new variants
→
UseEdit the cva definition in the component file — this is a theme change, not a structural change
IfAdding a new variant to an existing cva definition
→
UseAdd to the variants object in the component file — type-safe, auto-derived
IfNeed to replace the underlying Radix primitive
→
UseFork the component — this is a fundamental change, not an extension
Trick 10: Build a Design Token System on Top of shadcn/ui
shadcn/ui's CSS variables are a starting point, not a design system. A real design system needs semantic tokens (color-action-primary, spacing-section-gap) that map to shadcn/ui's primitive tokens (--primary, --background). This two-layer approach lets you change the primitive tokens (theme switch) without touching the semantic tokens, and change semantic tokens (rebrand) without touching component code.
The pattern: define semantic tokens in a separate CSS layer that references shadcn/ui's primitive tokens. Components use semantic tokens. Theme changes update primitive tokens. Rebranding updates semantic tokens. Neither requires component code changes.
Two-layer token system: semantic tokens reference shadcn/ui primitives — theme and rebrand are independent
Pro Tip: Use CSS @layer to Separate Token Concerns
Production Insight
Two-layer token system: semantic tokens reference shadcn/ui primitives — theme and rebrand are independent.
Components use semantic tokens, never primitive tokens directly — decouples component code from theme implementation.
Rule: if your components reference --primary directly, you don't have a design system — you have a theme.
Key Takeaway
Semantic tokens map to shadcn/ui primitives — change one layer without touching the other.
Components use semantic tokens, never primitive tokens directly — decouples code from theme.
Punchline: if your components reference --primary directly, you have a theme, not a design system — add a semantic layer to decouple them.
Design Token Decisions
IfBuilding a new project with shadcn/ui
→
UseStart with primitive tokens only — add semantic tokens when you have 10+ components
IfRebranding without changing the theme
→
UseUpdate semantic tokens — primitive tokens stay the same, theme stays the same
IfSwitching themes without rebranding
→
UseUpdate primitive tokens — semantic tokens stay the same, components stay the same
IfComponents reference --primary directly
→
UseIntroduce semantic tokens — map --primary to --color-action-primary and migrate components
● Production incidentPOST-MORTEMseverity: high
tailwind-merge missing from cn() utility causes silent style conflicts in production
Symptom
Buttons with className='bg-blue-500' rendered as green (the default variant color) on some pages and blue on others. The behavior was non-deterministic — it depended on CSS specificity order, which varied by import chain. No console errors. No build warnings.
Assumption
The team assumed Tailwind's class conflicts were a browser caching issue. They tried clearing caches, rebuilding, and restarting dev servers. The problem persisted across all environments.
Root cause
The cn() utility was defined as clsx(...args) without tailwind-merge. When a component's default variant class (bg-primary) and a user-provided class (bg-blue-500) were both present, CSS specificity determined which won — and that depended on the order Tailwind generated its stylesheet. tailwind-merge resolves this by intelligently merging conflicting Tailwind classes, keeping only the last one.
Fix
Updated cn() to use both clsx and tailwind-merge: twMerge(clsx(...args)). Added tailwind-merge as a dependency. All style conflicts resolved immediately — user-provided classes now always override variant defaults predictably.
Key lesson
cn() must use tailwind-merge — clsx alone causes unpredictable style conflicts with Tailwind
Without tailwind-merge, class order depends on CSS generation order, which varies by import chain
Test components with conflicting utility classes (e.g., bg-red-500 on a bg-blue variant) to verify merge behavior
The cn() utility is the single most important function in a shadcn/ui codebase — get it right first
Production debug guideCommon shadcn/ui failures and how to diagnose them6 entries
Symptom · 01
Component styles don't match the design system after customization
→
Fix
Check globals.css for correct CSS variable names. shadcn/ui uses --background, --foreground, --primary, etc. A typo in the variable name silently falls back to transparent.
Symptom · 02
Dark mode doesn't apply to shadcn/ui components
→
Fix
Verify the .dark class is applied to the html element. shadcn/ui's CSS variables are defined per-theme in globals.css — the .dark selector must override each variable.
Symptom · 03
Component throws 'Cannot read properties of undefined' on render
→
Fix
Check that all Radix UI peer dependencies are installed. Run npx shadcn@latest add [component] to reinstall with correct dependencies.
Symptom · 04
Class names conflict — custom styles are overridden by defaults
→
Fix
Verify cn() uses tailwind-merge. Without it, Tailwind class conflicts are resolved by CSS specificity order, not by the order you pass them.
Symptom · 05
Dialog/Sheet components don't render in the correct z-order
→
Fix
Radix portals render outside the component tree. Check that your z-index values in tailwind.config.ts match the z-50 convention used by shadcn/ui.
Symptom · 06
Animation doesn't play on component mount
→
Fix
Check that tailwindcss-animate is installed and configured in tailwind.config.ts. The animation utilities (animate-in, animate-out) require this plugin.
★ shadcn/ui Quick Debug Cheat SheetThree most common shadcn/ui issues and the exact commands to diagnose and fix them.
Component rendering plain HTML without styles−
Immediate action
Open browser dev tools and check if the component's class names are correct. If missing, verify cn() import path.
Check if tailwind-merge is in package.json: grep 'tailwind-merge' package.json
Fix now
If twMerge missing, add to lib/utils.ts: import { twMerge } from 'tailwind-merge'; export function cn(...inputs) { return twMerge(clsx(inputs)); }
Dialog/Sheet content renders at wrong z-index+
Immediate action
Inspect the portal overlay in dev tools — it likely has z-50 but parent container has lower z-index overriding due to stacking context.
Commands
grep -r 'z-index' tailwind.config.ts
grep -r 'z-50' app/globals.css
Fix now
Ensure body has no z-index. Check for transform, opacity, or filter on ancestors that create new stacking contexts.
Dark mode toggle causes white flash+
Immediate action
Check if theme script is inline in head — if it's in a separate JS file, it runs too late.
Commands
grep -r 'ThemeScript' app/layout.tsx
cat app/layout.tsx | head -20
Fix now
Move the theme toggle logic into an inline script in <head> that runs before React hydrates. Example: (function(){...document.documentElement.classList.add('dark')})()
shadcn/ui vs Traditional Component Library
Aspect
shadcn/ui
Traditional Library
Ownership
Source code in your project — you control every line
Installed as dependency — you can't modify without forking
Updates
Manual diff via npx shadcn diff — you choose what to merge
Automated via npm update — backward compatibility constraints
Bundle size
Only components you add — no tree-shaking needed
Full library bundled — tree-shaking may not remove all unused code
Customization
Directly edit components or wrap with composition
Override using CSS or prop-heavy APIs
Version lock
No version lock — you own the code
Locked to version range — breaking changes require migration
Key takeaways
1
shadcn/ui components are source code you own
there is no abstraction to fight, no version lock-in, no forking drama
2
cn() is not optional
without tailwind-merge, class conflicts resolve by import order and your styles silently lose
3
CSS variables in globals.css control the entire theme
dark mode, branding, and rebrands require zero component changes
4
asChild is the escape hatch for invalid HTML
wrap a Button around a Link and you need asChild or you get hydration warnings
5
cva + compound variants eliminate ternary hell
variant combinations become declarative config instead of nested conditionals
6
Layout-matched skeleton components are architecture, not polish
build them per feature, not generically, to eliminate CLS
7
Design tokens decouple theme from code
components reference semantic tokens, not primitives, so rebranding doesn't touch components
Common mistakes to avoid
3 patterns
×
Using cn() without tailwind-merge
Symptom
Components render with incorrect styles — blue buttons turn green on some pages. Behavior varies by import order, not code.
Fix
Update cn() to twMerge(clsx(...args)). Ensure tailwind-merge is installed. Test with conflicting utility classes (bg-blue-500 bg-red-500) to verify prediction.
×
Modifying shadcn/ui source directly instead of wrapping
Symptom
When shadcn/ui releases an update, git pull shows merge conflicts. You can't easily see what changed upstream.
Fix
Keep original components untouched in ui/. Create wrapper components in components/ that import and extend the originals. Diff upstream against originals, not wrappers.
×
Hardcoding theme colors in component files instead of using CSS variables
Symptom
Dark mode doesn't work on some components. Changing brand color requires searching and replacing across 50+ files.
Fix
Move all color values to globals.css as CSS variables. Components should use bg-primary, text-foreground, etc. Never write hex colors in component className.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
Explain how the cn() utility works and why tailwind-merge is necessary.
Q02SENIOR
How do you handle theme switching in shadcn/ui without causing a flash o...
Q03SENIOR
What is the asChild prop in Radix-based shadcn/ui components and when sh...
Q01 of 03SENIOR
Explain how the cn() utility works and why tailwind-merge is necessary.
ANSWER
cn() combines clsx (for conditional class joining) and tailwind-merge (for intelligent Tailwind class conflict resolution). clsx handles conditional joining — cn('base', isActive && 'active'). Without tailwind-merge, conflicting Tailwind classes like 'bg-red-500' and 'bg-blue-500' both appear in the final string, and CSS specificity order determines which wins — this varies unpredictably by import chain. tailwind-merge parses the class list, identifies conflicts, and keeps only the last conflicting class. The result: user-provided className always overrides variant defaults predictably.
Q02 of 03SENIOR
How do you handle theme switching in shadcn/ui without causing a flash of wrong theme?
ANSWER
Use a CSS-variables-only approach. Define all colors as CSS variables in :root and .dark selectors in globals.css. Theme switching is a class toggle on the html element. To avoid flash, place an inline synchronous script in the <head> tag that reads localStorage and applies the .dark class before React hydrates. The script must be inline — external or deferred scripts execute after first paint. No React state or ThemeProvider is needed for the theme itself.
Q03 of 03SENIOR
What is the asChild prop in Radix-based shadcn/ui components and when should you use it?
ANSWER
asChild tells the component to not render its own element, but instead pass its behavior, accessibility attributes, and event handlers to its child element. Use it anytime the trigger element needs to be something other than the component's default element — for example, making a TooltipTrigger be a Next.js Link, or a Button be a custom styled div. Without asChild, wrapping a Link in a Button creates invalid HTML (button > a) and causes hydration warnings.
01
Explain how the cn() utility works and why tailwind-merge is necessary.
SENIOR
02
How do you handle theme switching in shadcn/ui without causing a flash of wrong theme?
SENIOR
03
What is the asChild prop in Radix-based shadcn/ui components and when should you use it?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
How do I update shadcn/ui components when a new version is released?
Run npx shadcn@latest diff to see what changed. Review the diff and selectively merge changes into your components. Since you own the source code, there is no automatic update — this is by design. You control the update.
Was this helpful?
02
Does shadcn/ui work with React Server Components?
Yes — most shadcn/ui components work as Server Components by default. Components that use useState, useEffect, or Radix's interactive primitives (Dialog, DropdownMenu, Tooltip) need 'use client'. The CLI adds 'use client' automatically when needed. Your Server Components can import and render shadcn/ui structural components (Card, Table, Badge) without any client-side JavaScript.
Was this helpful?
03
Can I use shadcn/ui with a framework other than Next.js?
Yes. shadcn/ui supports Vite (React), Remix, Gatsby, and Astro. The components are framework-agnostic — they depend on React and Tailwind, not Next.js. The CLI has framework-specific configurations for each. Some features (like the CSS variable theming system) work identically across all frameworks.
Was this helpful?
04
How do I create a new component that matches shadcn/ui's patterns?
Follow the pattern: (1) use Radix primitives for accessibility, (2) use cva for variants, (3) use cn() for class composition, (4) use forwardRef for ref forwarding, (5) export variant types with VariantProps. Reference existing shadcn/ui components as templates — the patterns are consistent across all components.
Was this helpful?
05
What is the difference between shadcn/ui and a traditional UI component library?
shadcn/ui delivers source code into your project rather than installing a package into node_modules. You own the components, can modify them directly, and have no version lock-in. Updates are opt-in via npx shadcn diff. Traditional libraries like Material-UI or Chakra are npm packages — you get pre-built components with prop-based customization, but you can't easily modify the source and updates are automatic (and may break your code).