Skip to content
Homeβ€Ί JavaScriptβ€Ί 10 Advanced shadcn/ui Tricks Most Developers Don't Know

10 Advanced shadcn/ui Tricks Most Developers Don't Know

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: React.js β†’ Topic 38 of 38
Unlock the full power of shadcn/ui with 10 advanced patterns, custom animations, theming tricks, and component extensions.
πŸ”₯ Advanced β€” solid JavaScript foundation required
In this tutorial, you'll learn
Unlock the full power of shadcn/ui with 10 advanced patterns, custom animations, theming tricks, and component extensions.
  • 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
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑Quick Answer
  • 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 Incidenttailwind-merge missing from cn() utility causes silent style conflicts in productionA team's shadcn/ui Button component had utility classes. Buttons on the same page rendered differently depending on import order.
SymptomButtons 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.
AssumptionThe 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 causeThe 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.
FixUpdated 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 TailwindWithout tailwind-merge, class order depends on CSS generation order, which varies by import chainTest components with conflicting utility classes (e.g., bg-red-500 on a bg-blue variant) to verify merge behaviorThe 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 them
Component styles don't match the design system after customization→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.
Dark mode doesn't apply to shadcn/ui components→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.
Component throws 'Cannot read properties of undefined' on render→Check that all Radix UI peer dependencies are installed. Run npx shadcn@latest add [component] to reinstall with correct dependencies.
Class names conflict — custom styles are overridden by defaults→Verify cn() uses tailwind-merge. Without it, Tailwind class conflicts are resolved by CSS specificity order, not by the order you pass them.
Dialog/Sheet components don't render in the correct z-order→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.
Animation doesn't play on component mount→Check that tailwindcss-animate is installed and configured in tailwind.config.ts. The animation utilities (animate-in, animate-out) require this plugin.

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.

io/thecodeforge/shadcn-tricks/lib/utils.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839
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
  );
}
β–Ά Output
cn() utility configured: clsx + tailwind-merge for predictable class composition
Mental Model
cn() Mental Model
Think of cn() as a smart class name merger β€” it joins conditional classes (clsx) and then resolves Tailwind conflicts by keeping the last one (tailwind-merge). Without it, your component styles are a coin flip.
  • 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
πŸ“Š Production Insight
Without tailwind-merge, style conflicts resolve by CSS specificity order β€” varies by import chain.
cn() is the foundation of every component β€” if it's wrong, everything built on it is fragile.
Rule: always use twMerge(clsx(...args)) β€” never clsx alone in a Tailwind project.
🎯 Key Takeaway
cn() combines clsx (conditional joining) and tailwind-merge (conflict resolution) into one function.
Without tailwind-merge, Tailwind class conflicts resolve unpredictably based on CSS specificity order.
Punchline: cn() is the foundation of every shadcn/ui component β€” get it right before building anything else.
cn() Usage Decisions
IfComposing static class names
β†’
UseUse cn('class-a', 'class-b') β€” simple concatenation
IfConditional classes based on state
β†’
UseUse cn('base', isActive && 'active', isDisabled && 'opacity-50')
IfUser className overrides variant defaults
β†’
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.

io/thecodeforge/shadcn-tricks/app/globals.css Β· CSS
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
@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;
  }
}
β–Ά Output
Theme system configured: light, dark, and custom ocean theme via CSS variables
πŸ’‘Pro Tip: Use HSL Format for CSS Variables, Not Hex
πŸ“Š Production Insight
Theme switching via CSS class toggle β€” zero JavaScript re-renders, browser handles it natively.
HSL format enables opacity modifiers (bg-primary/90) β€” hex colors can't do this.
Rule: define all theme values as CSS variables in globals.css β€” never hardcode colors in component files.
🎯 Key Takeaway
shadcn/ui's theme is 100% CSS variables β€” one file (globals.css) controls every component's appearance.
Theme switching is a class toggle on the html element β€” zero JavaScript re-renders.
Punchline: never hardcode colors in component files β€” define them as CSS variables so theming stays centralized.
Theming Decisions
IfNeed light/dark mode only
β†’
UseUse :root and .dark selectors in globals.css β€” standard shadcn/ui pattern
IfNeed multiple brand themes (e.g., per-client white-labeling)
β†’
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.

io/thecodeforge/shadcn-tricks/components/composed-nav-link.tsx Β· TSX
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
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>
  );
}
β–Ά Output
Slot-based composition: Link receives tooltip behavior and button styles without wrapper elements
Mental Model
asChild Mental Model
Think of asChild like a USB adapter β€” the behavior (tooltip, dialog, dropdown) is the cable, and the child element is the device. The adapter passes the signal through without creating a new port.
  • 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
πŸ“Š Production Insight
Without asChild, wrapping a Link in a Button creates invalid HTML β€” <button><a> causes hydration warnings.
asChild eliminates wrapper elements β€” cleaner DOM, fewer CSS specificity conflicts.
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.

io/thecodeforge/shadcn-tricks/components/ui/badge-extended.tsx Β· TSX
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
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}
    />
  );
}
β–Ά Output
Badge component with 6 variants, 3 sizes, and 3 compound variants β€” type-safe props auto-derived
πŸ’‘Pro Tip: Derive TypeScript Types from cva Definitions
πŸ“Š Production Insight
cva compound variants handle variant combinations without nested ternaries β€” cleaner than manual class composition.
VariantProps<typeof variants> auto-derives TypeScript types β€” add a variant, types update automatically.
Rule: use cva for any component with 3+ style variants β€” it scales better than if/else class concatenation.
🎯 Key Takeaway
cva defines type-safe variant systems β€” variant values map to class strings, compound variants handle combinations.
VariantProps<typeof variants> auto-derives TypeScript types β€” add a variant, types update automatically.
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.

io/thecodeforge/shadcn-tricks/hooks/use-confirm.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
'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 />
//     </>
//   );
// }
β–Ά Output
Promise-based confirm dialog hook β€” await confirm('Title', 'Message') returns true/false
Mental Model
Hook vs Component Mental Model
Hooks manage state and logic. Components manage rendering and layout. Never mix them β€” a hook that imports UI components is a component, not a hook. A component that manages state via useState is a hook pretending to be a component.
  • 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.

io/thecodeforge/shadcn-tricks/components/skeletons/dashboard-skeleton.tsx Β· TSX
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
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>
β–Ά Output
Skeleton layouts that match content dimensions exactly β€” zero CLS on content load
πŸ’‘Pro Tip: Measure CLS Before and After Skeleton Optimization
πŸ“Š Production Insight
Layout-matched skeletons reduce CLS from 0.25 to under 0.01 β€” smooth transition instead of jarring jump.
Build per-feature skeletons (DashboardSkeleton, PostCardSkeleton) β€” generic Skeleton everywhere causes layout shift.
Rule: skeleton dimensions must match content dimensions exactly β€” measure CLS to verify.
🎯 Key Takeaway
Layout-matched skeletons eliminate Cumulative Layout Shift β€” the skeleton IS the content layout before data arrives.
Build per-feature skeletons, not generic ones β€” DashboardSkeleton, PostCardSkeleton, etc.
Punchline: skeleton dimensions must match content dimensions exactly β€” measure CLS before and after to verify the improvement.
Skeleton Strategy Decisions
IfSimple text loading state
β†’
UseUse generic <Skeleton className='h-4 w-full' /> β€” adequate for single-line content
IfComplex layout with cards, images, and tags
β†’
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.tsx Β· TSX
12345678910111213141516171819202122232425262728293031323334353637383940414243
// 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.

io/thecodeforge/shadcn-tricks/components/data-table.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
'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>
  );
}
β–Ά Output
Full data table with sorting, filtering, pagination β€” TanStack Table logic + shadcn/ui rendering
Mental Model
Headless + Rendered Mental Model
TanStack Table is the engine β€” it manages state, sorting, filtering, and pagination. shadcn/ui's Table components are the dashboard β€” they render the gauges and controls. The engine doesn't care what the dashboard looks like. The dashboard doesn't care how the engine works.
  • 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
πŸ“Š Production Insight
TanStack Table + shadcn/ui Table is the production pattern β€” headless logic + rendered structure.
Column definitions are pure data β€” sorting, filtering, and pagination are config flags, not custom code.
Rule: never build a table from scratch when TanStack Table handles state management and shadcn/ui handles rendering.
🎯 Key Takeaway
TanStack Table is the engine (state/logic), shadcn/ui Table is the dashboard (rendering) β€” keep them separate.
Column definitions are pure data β€” sorting, filtering, and pagination are config flags.
Punchline: never build a table from scratch when TanStack Table handles state management and shadcn/ui handles rendering.
Table Implementation Decisions
IfStatic table with no sorting or filtering
β†’
UseUse shadcn/ui Table components directly β€” no TanStack Table needed
IfTable needs sorting, filtering, or pagination
β†’
UseAdd TanStack Table β€” headless logic + shadcn/ui rendering
IfTable needs row selection or bulk actions
β†’
UseTanStack Table's enableRowSelection + shadcn/ui Checkbox in each row
IfTable needs server-side pagination (infinite scroll)
β†’
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/.

io/thecodeforge/shadcn-tricks/components/extended-button.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
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>
β–Ά Output
Extended Button with loading state, icon slots, and shortcut hints β€” original source untouched
Mental Model
Extension vs Modification Mental Model
Think of it like inheritance in OOP β€” you create a subclass that adds behavior without touching the parent class. The parent can update independently. Your subclass inherits the updates automatically. If you modify the parent directly, you own the merge conflict.
  • 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.

io/thecodeforge/shadcn-tricks/app/design-tokens.css Β· CSS
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
/* 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);
}
*/
β–Ά Output
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

🎯 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.

    πŸ”₯
    Naren Founder & Author

    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.

    ← PreviousThe New T3 Stack in 2026 – Complete Updated Guide
    Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged