10 Advanced shadcn/ui Tricks Most Developers Don't Know
- Trick 1: The cn() Utility β Your Single Most Important Function
- Trick 2: CSS Variables Control the Entire Theme β One File, Every Component
- Trick 3: Slot-Based Composition with asChild
- 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
Production Incident
Production Debug GuideCommon shadcn/ui failures and how to diagnose them
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.
import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; // The cn() utility β combines clsx + tailwind-merge // clsx: conditional class joining (cn('base', condition && 'active')) // tailwind-merge: deduplicates conflicting Tailwind classes export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } // WITHOUT tailwind-merge: // cn('bg-blue-500 p-4', 'bg-red-500') -> 'bg-blue-500 p-4 bg-red-500' // Result: unpredictable β depends on CSS specificity order // WITH tailwind-merge: // cn('bg-blue-500 p-4', 'bg-red-500') -> 'p-4 bg-red-500' // Result: predictable β last conflicting class wins // Advanced usage: conditional variants export function buttonVariants( variant: 'default' | 'outline' | 'ghost', size: 'sm' | 'md' | 'lg', className?: string ) { return cn( 'inline-flex items-center justify-center rounded-md font-medium transition-colors', { 'bg-primary text-primary-foreground hover:bg-primary/90': variant === 'default', 'border border-input bg-background hover:bg-accent': variant === 'outline', 'hover:bg-accent hover:text-accent-foreground': variant === 'ghost', }, { 'h-8 px-3 text-sm': size === 'sm', 'h-10 px-4 text-sm': size === 'md', 'h-12 px-6 text-base': size === 'lg', }, className // user classes always win β tailwind-merge ensures this ); }
- clsx handles conditional joining: cn('base', isActive && 'active')
- tailwind-merge resolves conflicts: cn('p-4', 'p-2') -> 'p-2' (last wins)
- User-provided className always overrides variant defaults β this is by design
- Without tailwind-merge, conflicting classes depend on CSS specificity order β unpredictable
- The cn() utility is the foundation of every shadcn/ui component β get it right before anything else
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.
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
/* Custom theme β toggle .theme-ocean on <html> */
.theme-ocean {
--background: 210 50% 6%;
--foreground: 200 80% 90%;
--primary: 199 89% 48%;
--primary-foreground: 210 50% 6%;
--accent: 199 89% 48%;
--accent-foreground: 210 50% 6%;
--card: 210 50% 10%;
--card-foreground: 200 80% 90%;
--muted: 210 40% 15%;
--muted-foreground: 200 30% 60%;
--border: 210 40% 20%;
--input: 210 40% 20%;
--ring: 199 89% 48%;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
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.
import Link from 'next/link'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; // Without asChild: TooltipTrigger renders a <button> β can't wrap a Next.js Link // <TooltipTrigger><Link href="/dashboard">Dashboard</Link></TooltipTrigger> // Result: <button><a> β invalid HTML, hydration warning // With asChild: TooltipTrigger passes behavior to the Link β no extra element export function NavLink({ href, label, tooltip }: { href: string; label: string; tooltip: string; }) { return ( <Tooltip> <TooltipTrigger asChild> {/* Link receives tooltip behavior β no wrapper button */} <Link href={href} className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors" > {label} </Link> </TooltipTrigger> <TooltipContent> <p>{tooltip}</p> </TooltipContent> </Tooltip> ); } // Advanced: compose Button + DialogTrigger + Link // All three behaviors on one element, no nested wrappers export function ActionLink({ href, label, onConfirm }: { href: string; label: string; onConfirm: () => void; }) { return ( <Button variant="ghost" asChild> <Link href={href} onClick={onConfirm}> {label} </Link> </Button> ); }
- Without asChild: component renders its own element β you can't swap it for a Link or custom element
- With asChild: component passes behavior to its child β any element becomes the trigger
- Used by DialogTrigger, TooltipTrigger, DropdownMenuTrigger, and all Radix-based shadcn/ui components
- Eliminates invalid HTML like <button><a> β the child element is the only element rendered
- Unlock: compose multiple behaviors on one element (tooltip + button + link) without nested wrappers
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.
import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; // Define the variant system β same pattern shadcn/ui uses internally const badgeVariants = cva( 'inline-flex items-center rounded-full border font-semibold transition-colors', { variants: { variant: { default: 'border-transparent bg-primary text-primary-foreground', secondary: 'border-transparent bg-secondary text-secondary-foreground', destructive: 'border-transparent bg-destructive text-destructive-foreground', outline: 'text-foreground', success: 'border-transparent bg-green-500 text-white', warning: 'border-transparent bg-yellow-500 text-black', }, size: { sm: 'px-2 py-0.5 text-xs', md: 'px-2.5 py-0.5 text-sm', lg: 'px-3 py-1 text-base', }, }, compoundVariants: [ // Destructive + Large gets extra padding for visual weight { variant: 'destructive', size: 'lg', class: 'px-4 py-1.5 border-2', }, // Success + Small gets a dot indicator { variant: 'success', size: 'sm', class: 'gap-1 before:content-["\2022"] before:text-green-300', }, // Outline + Large gets a hover fill { variant: 'outline', size: 'lg', class: 'hover:bg-foreground/5', }, ], defaultVariants: { variant: 'default', size: 'md', }, } ); // Type-safe props derived from the variant definition type BadgeProps = React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>; export function Badge({ className, variant, size, ...props }: BadgeProps) { return ( <div className={badgeVariants({ variant, size, className })} {...props} /> ); }
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.
'use client'; import * as React from 'react'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; // Custom hook: promise-based confirm dialog // Usage: const confirm = useConfirm(); const ok = await confirm('Delete this item?'); export function useConfirm() { const [state, setState] = React.useState<{ open: boolean; title: string; message: string; resolve: (value: boolean) => void; } | null>(null); const confirm = React.useCallback((title: string, message?: string) => { return new Promise<boolean>((resolve) => { setState({ open: true, title, message: message ?? 'Are you sure?', resolve }); }); }, []); const handleClose = React.useCallback(() => { state?.resolve(false); setState(null); }, [state]); const handleConfirm = React.useCallback(() => { state?.resolve(true); setState(null); }, [state]); // The Dialog component β renders only when confirm() is called const ConfirmDialog = React.useCallback(() => ( <Dialog open={state?.open ?? false} onOpenChange={(open) => !open && handleClose()}> <DialogContent> <DialogHeader> <DialogTitle>{state?.title}</DialogTitle> <DialogDescription>{state?.message}</DialogDescription> </DialogHeader> <DialogFooter> <Button variant="outline" onClick={handleClose}>Cancel</Button> <Button variant="destructive" onClick={handleConfirm}>Confirm</Button> </DialogFooter> </DialogContent> </Dialog> ), [state, handleClose, handleConfirm]); // Return the function and the component β caller decides where to render ConfirmDialog return { confirm, ConfirmDialog }; } // Usage in a component: // function DeleteButton({ itemId }: { itemId: string }) { // const { confirm, ConfirmDialog } = useConfirm(); // // const handleDelete = async () => { // const ok = await confirm('Delete this item?', 'This action cannot be undone.'); // if (ok) await deleteItem(itemId); // }; // // return ( // <> // <Button variant="destructive" onClick={handleDelete}>Delete</Button> // <ConfirmDialog /> // </> // ); // }
- 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
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.
import { Skeleton } from '@/components/ui/skeleton'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; // Skeleton that EXACTLY matches the DashboardMetrics layout // Same grid, same card structure, same dimensions β zero layout shift export function DashboardMetricsSkeleton() { return ( <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> {Array.from({ length: 4 }).map((_, i) => ( <Card key={i}> <CardHeader className="pb-2"> <Skeleton className="h-4 w-24" /> {/* metric label */} </CardHeader> <CardContent> <Skeleton className="h-8 w-32 mb-2" /> {/* metric value */} <Skeleton className="h-3 w-20" /> {/* change indicator */} </CardContent> </Card> ))} </div> ); } // Skeleton for a post list β matches PostList layout exactly export function PostListSkeleton({ count = 5 }: { count?: number }) { return ( <div className="space-y-4"> {Array.from({ length: count }).map((_, i) => ( <div key={i} className="flex gap-4 p-4 border rounded-lg"> <Skeleton className="h-16 w-16 rounded-md shrink-0" /> {/* thumbnail */} <div className="flex-1 space-y-2"> <Skeleton className="h-5 w-3/4" /> {/* title */} <Skeleton className="h-4 w-full" /> {/* excerpt line 1 */} <Skeleton className="h-4 w-2/3" /> {/* excerpt line 2 */} <div className="flex gap-2 pt-1"> <Skeleton className="h-5 w-16 rounded-full" /> {/* tag */} <Skeleton className="h-5 w-20 rounded-full" /> {/* date */} </div> </div> </div> ))} </div> ); } // Usage with Suspense: // <Suspense fallback={<DashboardMetricsSkeleton />}> // <DashboardMetrics /> {/* Server Component β streams in */} // </Suspense>
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.
// 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'), }; }
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.
'use client'; import * as React from 'react'; import { ColumnDef, flexRender, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, SortingState, ColumnFiltersState, useReactTable, } from '@tanstack/react-table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ChevronLeft, ChevronRight } from 'lucide-react'; interface DataTableProps<TData, TValue> { columns: ColumnDef<TData, TValue>[]; data: TData[]; searchKey?: string; pageSize?: number; } export function DataTable<TData, TValue>({ columns, data, searchKey, pageSize = 10, }: DataTableProps<TData, TValue>) { const [sorting, setSorting] = React.useState<SortingState>([]); const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]); const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, state: { sorting, columnFilters }, initialState: { pagination: { pageSize } }, }); return ( <div className="space-y-4"> {searchKey && ( <Input placeholder="Filter..." value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ''} onChange={(e) => table.getColumn(searchKey)?.setFilterValue(e.target.value)} className="max-w-sm" /> )} <div className="rounded-md border"> <Table> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => ( <TableHead key={header.id} onClick={header.column.getToggleSortingHandler()} className="cursor-pointer select-none" > {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {{ asc: ' β', desc: ' β' }[header.column.getIsSorted() as string] ?? null} </TableHead> ))} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( <TableRow key={row.id}> {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}> {flexRender(cell.column.columnDef.cell, cell.getContext())} </TableCell> ))} </TableRow> )) ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center"> No results. </TableCell> </TableRow> )} </TableBody> </Table> </div> <div className="flex items-center justify-between"> <p className="text-sm text-muted-foreground"> Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} </p> <div className="flex gap-2"> <Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} > <ChevronLeft className="h-4 w-4" /> </Button> <Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} > <ChevronRight className="h-4 w-4" /> </Button> </div> </div> </div> ); }
- TanStack Table is headless: manages state and logic, renders nothing
- shadcn/ui Table is structural: renders markup, manages no state
- Column definitions are pure data β header labels, accessor keys, cell renderers
- Sorting, filtering, and pagination are config flags β enable them, TanStack handles the rest
- The DataTable component is a thin adapter β wire TanStack state to shadcn/ui components
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'; // Extended Button β 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 interface ExtendedButtonProps extends React.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>
- 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
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.
/* Design token layer β semantic tokens that reference shadcn/ui primitives */ /* Change primitive tokens = theme switch */ /* Change semantic tokens = rebrand */ /* Neither requires component code changes */ @layer tokens { :root { /* Semantic color tokens */ --color-action-primary: hsl(var(--primary)); --color-action-primary-hover: hsl(var(--primary) / 0.9); --color-action-secondary: hsl(var(--secondary)); --color-action-destructive: hsl(var(--destructive)); --color-surface-card: hsl(var(--card)); --color-surface-popover: hsl(var(--popover)); --color-text-heading: hsl(var(--foreground)); --color-text-body: hsl(var(--foreground)); --color-text-muted: hsl(var(--muted-foreground)); --color-border-default: hsl(var(--border)); --color-border-input: hsl(var(--input)); --color-border-focus: hsl(var(--ring)); /* Semantic spacing tokens */ --space-section: 2rem; --space-card-padding: 1.5rem; --space-inline: 0.5rem; --space-stack: 1rem; /* Semantic radius tokens */ --radius-card: var(--radius); --radius-button: var(--radius); --radius-badge: 9999px; --radius-input: var(--radius); /* Semantic typography tokens */ --font-heading: 'Inter', sans-serif; --font-body: 'Inter', sans-serif; --font-mono: 'JetBrains Mono', monospace; --text-xs: 0.75rem; --text-sm: 0.875rem; --text-base: 1rem; --text-lg: 1.125rem; --text-xl: 1.25rem; --text-2xl: 1.5rem; --text-3xl: 1.875rem; } } /* Components use semantic tokens β never primitive tokens directly */ /* This decouples component code from theme implementation */ /* Example component styles using semantic tokens: .card { background: var(--color-surface-card); border: 1px solid var(--color-border-default); border-radius: var(--radius-card); padding: var(--space-card-padding); } .card__title { color: var(--color-text-heading); font-size: var(--text-xl); font-family: var(--font-heading); } .card__body { color: var(--color-text-body); font-size: var(--text-base); font-family: var(--font-body); margin-top: var(--space-stack); } */
π― Key Takeaways
Frequently Asked Questions
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.
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.
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.
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.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.