Mar 23, 2026

Tutorials26 min read

A complete shadcn/ui handbook (2026)

Mihir Koshti

Mihir Koshti

Full Stack Developer @shadcnspace.com

A complete shadcn/ui handbook (2026)

Modern frontend teams are increasingly moving away from traditional UI libraries toward code-owned component systems. shadcn/ui sits at the center of this shift, giving developers full control over their UI without sacrificing speed or consistency.

This guide is a detailed, practical, and opinionated handbook for using shadcn/ui in 2026. It covers architecture decisions, component patterns, scalable project structures, performance considerations, theming strategies, and real-world production practices used in modern React and Next.js applications.

If you want to move beyond simply installing components and instead build a maintainable UI system that scales with your product, this guide will walk you through the patterns that experienced teams use in production.


Introduction to shadcn/ui

What does this handbook cover?

This guide isn’t just another step-by-step tutorial on installing shadcn/ui, as those are widely available. Instead, it emphasizes mental models, architectural considerations, and practical decisions encountered when deploying shadcn/ui in production in 2026, particularly for SaaS products, design systems, component registries, and long-term applications.

If you’re after copy-paste code snippets, this isn’t suitable. But if you’re interested in understanding why shadcn/ui is effective and its limitations, you’re in the right place.

What is shadcn/ui?

At its core, shadcn/ui is not a traditional UI library; it’s a component distribution system. Unlike installing a black-box package, shadcn/ui generates components directly in your codebase, giving you full control over markup, styles, and behavior.

It promotes composition over rigid APIs, letting you adapt and extend components freely. Once added, these components become your code: no wrappers, no hidden logic, no vendor lock-in.

This approach ensures flexibility, maintainability, and ownership, allowing teams to build scalable, consistent UIs while retaining full control over every aspect of the interface.

Why does shadcn/ui still matter in 2026?

Front-end development has evolved rapidly. React Server Components are now mainstream, design systems are expected rather than optional, performance budgets are tighter, and accessibility is non-negotiable.

Traditional UI libraries often struggle in this environment: they hide implementation details, ship unnecessary JavaScript, and resist modern React patterns.

shadcn/ui addresses these challenges by fitting naturally into today’s ecosystem. It is server component-friendly, generating components that render safely on the server without extra wrappers. Its components are tree-shakable by default, ensuring only the code you use is shipped. Accessibility is built in via Radix primitives, requiring no extra setup. At the same time, shadcn/ui is flexible enough to support real-world design systems, letting teams own their UI while maintaining consistency, scalability, and maintainability.

In short, shadcn/ui doesn’t fight your stack; it embraces modern best practices, giving developers full control over design, performance, and accessibility while keeping the workflow aligned with contemporary front-end development.

The industry shift: from ui libraries to ui ownership

Teams today are moving away from asking, “ Which UI library should we install? ” and focusing instead on, “ How do we own and evolve our UI over time? ” shadcn/ui aligns perfectly with this shift. Components are generated directly into your codebase, so breaking changes are intentional, not forced. Customization is built in, not a workaround, allowing your UI to adapt as your product grows. With shadcn/ui, your UI stops being a mere dependency and becomes a true product asset, giving teams full control over design, behavior, and maintainability while fostering consistency and scalability across projects.  


How shadcn/ui works

shadcn/ui is less about components and more about how you think about UI.
Understanding its philosophy is essential before writing any production code.

Copy-paste vs install libraries

Traditional UI libraries are installed as dependencies:

npm install some-ui-library

You import components from node_modules, and the implementation stays hidden.

shadcn/ui flips this model.

Instead of installing a library, you copy components into your own codebase. Once copied:

  • The code belongs to you
  • You can read, edit, and refactor everything
  • There are no forced updates or breaking changes

This removes vendor lock-in and turns UI components into first-class project code, not external dependencies.

Composition over configuration

Most UI libraries rely heavily on configuration through props:

<Button size="lg" color="primary" rounded shadow />

shadcn/ui avoids large prop APIs and encourages composition instead:

<Button>
&nbsp; <Icon />
&nbsp; <span>Save</span>
</Button>

This approach:

  • Keeps component APIs small
  • Makes layouts more flexible
  • Reduces long-term maintenance costs

If you find yourself adding more and more props, it’s usually a sign that composition is the better solution.


Installation and initial setup

A clean shadcn/ui setup is critical. Most long-term problems with shadcn/ui start from small setup mistakes made on day one.

This section walks through the correct way to initialize shadcn/ui in 2026.

Correct init choices and CLI options

Before installing shadcn/ui, your project should already have:

  • React (or Next.js App Router)
  • Tailwind CSS configured
  • TypeScript enabled

Once that’s in place, initialize shadcn/ui using:

pnpm dlx shadcn@latest init

During setup, you’ll be prompted to choose:

  • TypeScript support
  • App Router or Pages Router
  • Path for components (usually components/ui)
  • Whether to use CSS variables
  • A base color palette

Best practice: Always enable CSS variables and TypeScript. These are foundational for theming and scalability.

Files generated and how to treat them

After initialization, shadcn/ui generates several important files. These include components/ui/* for the actual UI components, lib/utils.ts for utility functions like cn(), tailwind.config.ts extended with shadcn tokens, and global CSS containing the CSS variable definitions. Each file plays a specific role in the system and exposes the inner workings of the library.

The key idea is that shadcn/ui hides nothing. Every generated file is meant to be read, understood, and modified. If a file isn’t clear at first, it’s worth pausing to study it. Investing this time upfront pays off later when customizing, extending, or scaling your components.

Common setup mistakes to avoid

Many teams run into issues early because of these mistakes:

  • Disabling CSS variables
  • Editing shadcn components directly for business logic
  • Mixing multiple color systems
  • Adding excessive variants too early
  • Treating shadcn/ui like a drop-in UI library

shadcn/ui rewards intentional setup.
Rushing this step usually leads to messy refactors later.


File structure and ownership

shadcn/ui gives you components, but structure is your responsibility.

If you get the file structure wrong early, your UI layer becomes hard to maintain as the project grows. This section explains how to organize shadcn/ui properly for real-world applications.

What lives inside components/ui?

The components/ui directory should contain only primitive, reusable UI components. These components must remain generic and product-agnostic, focusing purely on presentation and behavior. They should avoid business logic so they can be reused consistently across the entire application.

Examples:

  • Button
  • Input
  • Dialog
  • DropdownMenu
  • Tooltip

Think of components/ui as your UI foundation layer.

If a component can’t be reused outside a specific feature, it probably doesn’t belong here.

What should NEVER live in components/ui?

Many teams make the mistake of turning components/ui into a dumping ground.

Avoid putting these inside components/ui:

  • Feature-specific logic
  • API calls or data fetching
  • Product-specific components
  • Page sections or layouts
  • Application state or business rules

If a component knows what data it displays, it’s already too specific.

components/ui should answer how things look and behave, not what they do.

A common, scalable structure looks like this:

src/
├─ components/
│  ├─ ui/        # shadcn primitives
│  ├─ blocks/    # composed UI sections
│  └─ icons/
├─ features/
├─ hooks/
├─ lib/
└─ app/

This separation helps:

  • Keep UI primitives clean
  • Encourage composition
  • Prevent accidental coupling

As your app grows, this structure scales naturally.

Section Summary

A healthy shadcn/ui file structure keeps components/ui pure and reusable while moving logic to higher layers. It prioritizes composition over inheritance, allowing the UI system to scale cleanly without constant refactoring as the product grows.


Tailwind, cn, and CVA

shadcn/ui are inseparable from Tailwind CSS. If you treat Tailwind as “just styling,” you’ll miss most of what makes shadcn/ui powerful.

This section explains how Tailwind functions as the engine behind the entire system.

Tailwind is the system

shadcn/ui components combine semantic HTML, headless behavior, and Tailwind utility classes. There’s no separate styling layer or theme engine; everything flows through Tailwind. This keeps styles visible in JSX, eliminates hidden CSS, and makes visual changes predictable.

Utility-First Design Systems

In shadcn/ui, the design system is expressed through Tailwind utilities like spacing, typography, layout, and color tokens via CSS variables. Instead of encoding design rules in component props, decisions are reflected through utility usage. This leads to less duplication, faster iteration, and consistency through shared tokens rather than rigid APIs.

Managing Class Complexity

A common criticism of Tailwind is “too many classes.” shadcn/ui manages this by keeping components small, favoring composition over heavy variants, and avoiding complex conditional styling. With practices like extracting logic into variables and refactoring when JSX gets cluttered, Tailwind remains readable and maintainable.

cn() Helper and Class Variance Authority (CVA)

shadcn/ui uses a small utility called cn() to merge class names, handle conditional styles, and keep JSX clean and readable. It helps combine multiple Tailwind classes safely and makes it easier to manage dynamic styling without cluttering the component code.

For components that require structured styling options, Class Variance Authority (CVA) is used to define controlled variants like size, style, or intent. CVA centralizes styling logic and avoids scattered conditional classes across the component. However, it should be used sparingly; not every component needs variants, and overusing CVA can introduce unnecessary complexity.

Usage 

// cn() usage: merge classes conditionally
import { cn } from "@/lib/utils";

const Button = ({ isPrimary, className }: { isPrimary?: boolean; className?: string }) => {
  return (
    <button
      className={cn(
        "px-4 py-2 rounded font-medium",
        isPrimary && "bg-blue-500 text-white",
        className
      )}
    >
      Click me
    </button>
  );
};
// CVA usage: structured variants
import { cva, type VariantProps } from "class-variance-authority";

const buttonVariants = cva("px-4 py-2 rounded font-medium", {
&nbsp; variants: {
&nbsp; &nbsp; size: {
&nbsp; &nbsp; &nbsp; small: "text-sm",
&nbsp; &nbsp; &nbsp; large: "text-lg",
&nbsp; &nbsp; },
&nbsp; &nbsp; intent: {
&nbsp; &nbsp; &nbsp; primary: "bg-blue-500 text-white",
&nbsp; &nbsp; &nbsp; secondary: "bg-gray-200 text-gray-800",
&nbsp; &nbsp; },
&nbsp; },
&nbsp; defaultVariants: {
&nbsp; &nbsp; size: "small",
&nbsp; &nbsp; intent: "primary",
&nbsp; },
});

type ButtonProps = VariantProps<typeof buttonVariants> & { className?: string };

const CVAButton = ({ size, intent, className }: ButtonProps) => (
&nbsp; <button className={cn(buttonVariants({ size, intent }), className)}>
&nbsp; &nbsp; Click me
&nbsp; </button>
);

Explanation:

  • cn() merges classes dynamically and safely.
  • CVA defines structured, reusable variants for things like size and intent.
  • Use CVA only when variants are necessary; otherwise, cn() is usually enough.

Component API design

Good component APIs should be small, predictable, and hard to misuse. A well-designed API makes it clear how a component should be used and reduces the chances of developers using it incorrectly.

When designing components, it’s best to start with no variants. Keep the API minimal at first and only introduce variants when real repetition or multiple styling patterns begin to appear. This prevents unnecessary complexity early on.

For layout and structure, composition is usually the better approach. Instead of adding many props to control every layout variation, allow developers to compose components together to achieve the desired structure.

It’s also recommended to avoid boolean styling props whenever possible. Boolean props often lead to unclear combinations and unpredictable styling behavior as components grow.

Ultimately, a good component API should guide developers toward correct usage while still leaving enough flexibility for different use cases, without becoming overly restrictive.

// Bad: many boolean props, hard to use
const ButtonBad = ({
&nbsp; isPrimary,
&nbsp; isSecondary,
&nbsp; isLarge,
&nbsp; isSmall,
}: {
&nbsp; isPrimary?: boolean;
&nbsp; isSecondary?: boolean;
&nbsp; isLarge?: boolean;
&nbsp; isSmall?: boolean;
}) => {
&nbsp; const classes = &#91;
&nbsp; &nbsp; isPrimary ? "bg-blue-500 text-white" : "",
&nbsp; &nbsp; isSecondary ? "bg-gray-200 text-gray-800" : "",
&nbsp; &nbsp; isLarge ? "px-6 py-3" : "",
&nbsp; &nbsp; isSmall ? "px-2 py-1" : "",
&nbsp; ].join(" ");
&nbsp; return <button className={classes}>Click me</button>;
};
// Good: small, predictable API, use composition instead of booleans
interface ButtonProps {
&nbsp; className?: string;
&nbsp; children: React.ReactNode;
}

const Button = ({ className, children }: ButtonProps) => (
&nbsp; <button className={`px-4 py-2 rounded font-medium ${className}`}>{children}</button>
);

// Usage with composition
const App = () => (
&nbsp; <div className="flex gap-2">
&nbsp; &nbsp; <Button className="bg-blue-500 text-white">Primary</Button>
&nbsp; &nbsp; <Button className="bg-gray-200 text-gray-800">Secondary</Button>
&nbsp; &nbsp; <Button className="px-6 py-3 bg-green-500 text-white">Large</Button>
&nbsp; </div>
);

Key Takeaways:

  • The Button API is small and predictable.
  • Variants are handled via composition (className), not boolean props.
  • Developers can extend styling without changing the component.
  • The API is easy to evolve and avoids unnecessary complexity.

Avoiding variant explosion

A variant explosion happens when component variants grow without discipline. This usually occurs when components try to support too many use cases or when visual styling decisions start leaking into props, causing the API to become large and difficult to manage.

To prevent this, it’s important to regularly audit the variants used in your components. Remove variants that are no longer used and avoid adding new ones unless they clearly solve repeated patterns.

When a component starts serving multiple different intents, it’s often better to split it into separate components instead of continuing to add more variants. This keeps each component focused and easier to maintain.

Another good approach is to move complex logic or variations to higher-level blocks or features instead of handling everything inside a single component.

In general, components with fewer props are easier to evolve. Keeping the API small and focused helps maintain clarity, flexibility, and long-term maintainability.


Theming and design tokens (Advanced)

Theming in shadcn/ui is not about swapping colors.
It’s about building a semantic, scalable token system that can evolve with your product.

This section explains how theming really works and how to do it right.

Semantic color tokens via CSS variables

shadcn/ui uses CSS variables as design tokens instead of hard-coded values. Components rely on semantic variables like –background, –foreground, –primary, –muted, and –border.

These tokens represent meaning, not specific colors. For example, –primary represents the main action, not a fixed color like blue or green.

This approach allows the same components to work across different themes without changing JSX or Tailwind classes.

Dark mode and multi-brand patterns

Dark mode in shadcn/ui works by redefining the same CSS variables in a different context, commonly using .dark or [data-theme=”dark”].

:root {
--background: 0 0% 100%;
--foreground: 222 47% 11%;
}

.dark {
--background: 222 47% 11%;
--foreground: 210 40% 98%;
}

The key practice is to keep token names the same and only change their values, while ensuring proper contrast.

Because the tokens stay consistent, dark mode becomes a data change rather than a styling rewrite.

For SaaS and platform products, theming often goes beyond just light and dark modes. shadcn/ui supports multi-brand setups naturally: each brand defines its own token values while components remain unchanged. Switching brands doesn’t require rebuilding UI logic, making it ideal for white-label products, customer-specific branding, and design system-driven organizations. With shadcn/ui, the UI adapts to the brand, not the other way around.


Accessibility and primitives

Accessibility is not an optional enhancement in 2026.
It’s a baseline requirement for modern web applications.

shadcn/ui gives you a strong foundation, but it does not make your app automatically accessible. Understanding the boundary is critical.

Radix and Base UI accessibility

shadcn/ui components are built on top of Radix UI and Base UI, which handle many difficult accessibility challenges for you. Out of the box, you get correct ARIA roles and attributes, keyboard interactions that follow standards, focus trapping for modals and dialogs, and proper behavior for menus, dropdowns, and popovers. These well-tested primitives save you from implementing accessibility logic from scratch.

However, accessibility is a shared responsibility. You are still responsible for providing meaningful labels, using semantic HTML correctly, ensuring logical reading order, and handling error messages and validation states. Even a well-built component can be used incorrectly if these considerations are overlooked.

// Example: Accessible dropdown using Radix + shadcn/ui
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { Button } from "@/components/ui/button";

export default function AccessibleDropdown() {
&nbsp; return (
&nbsp; &nbsp; <DropdownMenu.Root>
&nbsp; &nbsp; &nbsp; <DropdownMenu.Trigger asChild>
&nbsp; &nbsp; &nbsp; &nbsp; <Button>Options</Button>
&nbsp; &nbsp; &nbsp; </DropdownMenu.Trigger>

&nbsp; &nbsp; &nbsp; <DropdownMenu.Content className="bg-white border rounded shadow-md p-2">
&nbsp; &nbsp; &nbsp; &nbsp; <DropdownMenu.Item onSelect={() => alert("Profile clicked")}>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Profile
&nbsp; &nbsp; &nbsp; &nbsp; </DropdownMenu.Item>
&nbsp; &nbsp; &nbsp; &nbsp; <DropdownMenu.Item onSelect={() => alert("Settings clicked")}>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Settings
&nbsp; &nbsp; &nbsp; &nbsp; </DropdownMenu.Item>
&nbsp; &nbsp; &nbsp; &nbsp; <DropdownMenu.Item onSelect={() => alert("Logout clicked")}>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Logout
&nbsp; &nbsp; &nbsp; &nbsp; </DropdownMenu.Item>
&nbsp; &nbsp; &nbsp; </DropdownMenu.Content>
&nbsp; &nbsp; </DropdownMenu.Root>
&nbsp; );
}

What’s handled automatically by Radix/UI:

  • Keyboard navigation with arrow keys
  • Proper focus management and trapping
  • Correct ARIA roles and attributes

What the developer still handles:

  • Adding meaningful labels (Profile, Settings, Logout)
  • Ensuring the dropdown fits the logical reading order
  • Handling validation or error states if necessary

This example clearly shows the split of responsibilities: shadcn/ui + Radix handle the tricky accessibility mechanics, while you provide semantic content and context.

Keyboard and focus management checks

Keyboard accessibility is non-negotiable for usable and inclusive interfaces. All interactive elements should be reachable via the Tab key, follow a logical tab order, and clearly indicate focus. When dialogs or modals close, focus should return to the element that triggered them. While shadcn/ui and Radix UI handle much of this internally, as focus trapping in modals and proper ARIA roles layout decisions, or improper composition can still break the keyboard flow if not done carefully.


Building Reusable UI Blocks

shadcn/ui is not about pages, it’s about building blocks.

The real value appears when you stop thinking in isolated components and start composing reusable UI sections that can evolve with your product.

From Components to Blocks

UI components are primitives such as buttons, inputs, cards, and dialogs.

Blocks are compositions of these primitives that represent meaningful sections of the UI, solve recurring layout patterns, and remain reusable across contexts. Examples include pricing sections, feature grids, authentication forms, and data tables. Blocks sit between raw components and full pages, bridging basic elements with complete layouts.

Section Architecture

Modern frontend architecture favors sections over pages. Instead of creating one-off pages, you design reusable sections, compose pages from blocks, and share sections across routes. This approach reduces duplication, improves consistency, and makes redesigns easier. A page is treated as an arrangement of blocks, rather than a unique, standalone artifact.

Flexible Blocks

Blocks are designed to be adaptable across different pages or contexts. They encapsulate a meaningful section of UI while remaining flexible enough to accept different data or child components. This allows the same block to be used in multiple places without rewriting its logic.

// Flexible block example
interface FeatureBlockProps {
title: string;
description: string;
icon: React.ReactNode;
}

export function FeatureBlock({ title, description, icon }: FeatureBlockProps) {
return (
&nbsp; <div className="flex flex-col items-center p-4 border rounded">
&nbsp; &nbsp; <div className="text-3xl">{icon}</div>
&nbsp; &nbsp; <h3 className="mt-2 font-bold">{title}</h3>
&nbsp; &nbsp; <p className="mt-1 text-center text-sm">{description}</p>
&nbsp; </div>
);
}

Reusable Layouts

Layouts compose multiple blocks into a consistent structure. By reusing layout components, you maintain consistent spacing, alignment, and responsiveness across different pages.

// Reusable layout example
import { FeatureBlock } from "./FeatureBlock";
import { FaRocket, FaShieldAlt } from "react-icons/fa";

export function FeaturesSection() {
return (
&nbsp; <section className="grid grid-cols-1 md:grid-cols-2 gap-6">
&nbsp; &nbsp; <FeatureBlock title="Fast Performance" description="Lightning fast load times." icon={<FaRocket />} />
&nbsp; &nbsp; <FeatureBlock title="Secure" description="Top-notch security features." icon={<FaShieldAlt />} />
&nbsp; </section>
);
}

Key Takeaways:

  • Flexible blocks adapt to different content and contexts.
  • Reusable layouts provide a consistent structure for pages while allowing blocks to vary.

Forms, validation, and data grids

Simple components are easy.
Forms, tables, and data-driven UI are where most systems break down.

shadcn/ui doesn’t try to reinvent these patterns; it integrates cleanly with the best tools in the ecosystem.

React Hook Form 

React Hook Form pairs naturally with shadcn/ui because it’s uncontrolled by default, minimizes re-renders, and works well with headless components. shadcn/ui inputs are designed to be wrapped, not replaced.

Best practices: keep form logic outside UI components, let React Hook Form manage state, and use shadcn/ui components purely for rendering. This separation ensures forms remain performant and maintainable.

import { useForm, Controller } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";

export default function MyForm() {
&nbsp; const { handleSubmit, control } = useForm<{ email: string }>();

&nbsp; const onSubmit = (data: { email: string }) => console.log(data);

&nbsp; return (
&nbsp; &nbsp; <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-2">
&nbsp; &nbsp; &nbsp; <Controller
&nbsp; &nbsp; &nbsp; &nbsp; name="email"
&nbsp; &nbsp; &nbsp; &nbsp; control={control}
&nbsp; &nbsp; &nbsp; &nbsp; defaultValue=""
&nbsp; &nbsp; &nbsp; &nbsp; render={({ field }) => <Input {...field} placeholder="Email" />}
&nbsp; &nbsp; &nbsp; />
&nbsp; &nbsp; &nbsp; <Button type="submit">Submit</Button>
&nbsp; &nbsp; </form>
&nbsp; );
}

This demonstrates keeping UI components for rendering only while React Hook Form handles state and validation.

Zod Validation

Validation should be declarative and centralized. Zod fits perfectly by letting you define schemas alongside domain logic, providing clear typed errors, and integrating directly with React Hook Form.

Best practices: define schemas once, reuse them across frontend and backend, and map validation messages to UI states. This makes validation a data concern, not a UI concern.

import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";

const schema = z.object({
&nbsp; email: z.string().email("Invalid email address"),
});

export default function Form() {
&nbsp; const { register, handleSubmit, formState: { errors } } = useForm({
&nbsp; &nbsp; resolver: zodResolver(schema),
&nbsp; });

&nbsp; const onSubmit = (data: any) => console.log(data);

&nbsp; return (
&nbsp; &nbsp; <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-2">
&nbsp; &nbsp; &nbsp; <Input {...register("email")} placeholder="Email" />
&nbsp; &nbsp; &nbsp; {errors.email && <span className="text-red-500">{errors.email.message}</span>}
&nbsp; &nbsp; &nbsp; <Button type="submit">Submit</Button>
&nbsp; &nbsp; </form>
&nbsp; );
}

This approach centralizes validation, keeps UI components clean, and ensures consistent error handling across the app.

TanStack Table patterns

Tables are among the most complex UI elements in real apps. TanStack Table handles logic like sorting, filtering, pagination, and column visibility, while shadcn/ui provides table primitives, styling, and consistent interaction patterns.

Together, they create a flexible, powerful table system that keeps full control over behavior and appearance without locking you into a rigid component.

import { useReactTable, getCoreRowModel } from "@tanstack/react-table";
import { Table, TableHeader, TableRow, TableCell } from "@/components/ui/table";

const data = &#91;
&nbsp; { name: "Alice", age: 25 },
&nbsp; { name: "Bob", age: 30 },
];

const columns = &#91;
&nbsp; { accessorKey: "name", header: "Name" },
&nbsp; { accessorKey: "age", header: "Age" },
];

export default function MyTable() {
&nbsp; const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });

&nbsp; return (
&nbsp; &nbsp; <Table>
&nbsp; &nbsp; &nbsp; <TableHeader>
&nbsp; &nbsp; &nbsp; &nbsp; {table.getHeaderGroups().map(headerGroup => (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <TableRow key={headerGroup.id}>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {headerGroup.headers.map(header => (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <TableCell key={header.id}>{header.column.columnDef.header}</TableCell>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ))}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </TableRow>
&nbsp; &nbsp; &nbsp; &nbsp; ))}
&nbsp; &nbsp; &nbsp; </TableHeader>
&nbsp; &nbsp; &nbsp; <tbody>
&nbsp; &nbsp; &nbsp; &nbsp; {table.getRowModel().rows.map(row => (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <TableRow key={row.id}>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {row.getVisibleCells().map(cell => (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <TableCell key={cell.id}>{cell.getValue()}</TableCell>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ))}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </TableRow>
&nbsp; &nbsp; &nbsp; &nbsp; ))}
&nbsp; &nbsp; &nbsp; </tbody>
&nbsp; &nbsp; </Table>
&nbsp; );
}

This shows how logic and rendering stay separate: TanStack handles the data, and shadcn/ui handles the presentation and styling.


Server Components and Client Boundaries

Modern React encourages a server-first mindset.
UI systems that ignore this quickly become slow, complex, and fragile.

shadcn/ui works especially well with Server Components -when boundaries are handled intentionally.

Server-first rendering patterns 

In a server-first approach, most rendering happens on the server by default, keeping the UI fast and efficient. Data fetching is colocated with the components that need it, which reduces client-server complexity and improves performance. Client-side JavaScript is added only when it’s truly necessary for interactivity, rather than being included by default for every component.

shadcn/ui components fit naturally into this model because many of them are purely presentational, require no client-side state, and can safely render on the server. This allows you to leverage their full styling and accessibility features without shipping extra JavaScript.

By combining server-first rendering with shadcn/ui’s lightweight, composable components, the goal becomes clear: deliver a fast, maintainable UI while shipping less JavaScript, not more components.

Passing UI across server/client boundaries

A common pattern is to let Server Components fetch data, Client Components handle interaction, and share UI primitives between both.

Best practices include passing data down as props, avoiding functions across boundaries, and keeping serialization simple. shadcn/ui supports this seamlessly because its components are plain React, with no hidden framework magic, making it easy to use across server and client boundaries.

// Server Component: fetches data
import { Card } from "@/components/ui/card";
import ClientCounter from "./ClientCounter";

export default async function ServerDashboard() {
const data = await fetch("https://api.example.com/users").then(res => res.json());

return (
&nbsp; &nbsp; &nbsp; &nbsp; <div className="space-y-4">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {data.map((user: any) => (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <Card key={user.id}>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <h3>{user.name}</h3>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {/* Client component handles interaction */}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <ClientCounter userId={user.id} />
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </Card>
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ))}
&nbsp; &nbsp; &nbsp; &nbsp; </div>
&nbsp; &nbsp; &nbsp; );}
// Client Component: handles interaction
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";

export default function ClientCounter({ userId }: { userId: string }) {
&nbsp; const &#91;count, setCount] = useState(0);

&nbsp; return (
&nbsp; &nbsp; <Button onClick={() => setCount(c => c + 1)}>
&nbsp; &nbsp; &nbsp; Clicked {count} times
&nbsp; &nbsp; </Button>
&nbsp; );
}

Key points:

  • Server components fetch and render data.
  • Client components manage local state and interactivity.
  • UI primitives like Card and Button are shared between both layers.
  • Data is passed down as props; no functions are sent across the server-client boundary.

This keeps your app efficient, maintainable, and clear.

Avoiding Hydration Pitfalls

Avoiding hydration pitfalls is essential for stable server-client rendering. Hydration issues typically arise from mismatches between server and client output, often caused by using browser-only APIs on the server, conditional rendering based on client state, or non-deterministic values like dates and random numbers.

To prevent these problems, keep server-rendered output deterministic, move client-only logic behind “use client”, and be explicit about component boundaries. When server and client responsibilities are clearly defined, hydration becomes predictable and reliable.

Performance Considerations

Performance is a critical factor in building modern UIs, and shadcn/ui is designed to support fast, efficient interfaces when used thoughtfully. Key considerations include:

  • Minimize client-side JavaScript: Prefer server-rendered components where possible and only use “use client” for interactive elements. This reduces bundle size and speeds up page load.
  • Leverage composable primitives: Small, focused components render efficiently and avoid unnecessary re-renders.
  • Handle state wisely: Keep local state minimal, centralize form and table logic with libraries like React Hook Form and TanStack Table, and avoid passing large or complex objects between server and client.
  • Optimize rendering of dynamic content: Use lazy loading, suspense boundaries, and memoization to prevent expensive re-renders.
  • Reusability reduces duplication: Blocks and layouts shared across pages reduce repetitive code and improve consistency, which indirectly boosts performance by avoiding redundant work.

By combining server-first rendering, careful use of client components, and disciplined state management, shadcn/ui helps you build fast, maintainable, and scalable UIs.


State management and URL-driven state

shadcn/ui does not include a state management solution, and that is deliberate.
It forces you to choose the right level of state, instead of defaulting to a global store.

Local vs Global State

Most UI states should be local to the component that owns them. Examples of good candidates for local state include the open or closed status of dialogs, toggle switches, individual form field values, and other interactions that are limited to a specific component. Keeping these states local helps prevent unnecessary complexity, avoids performance pitfalls, and makes components easier to reason about and reuse.

Global state, on the other hand, should be used sparingly and only when truly necessary. Suitable examples include authentication sessions, feature flags, or UI preferences that need to persist across multiple pages. The guiding principle is simple: if a state does not need to survive navigation or be shared widely, it probably does not need to be global. By thoughtfully separating local and global state, you keep your application more maintainable, predictable, and performant.

URL-driven state

In modern applications, the URL is a first-class state container. It’s ideal for representing UI state that matters for navigation, such as filters and sorting, pagination, tabs and views, or search queries.

Storing state in the URL provides several benefits: links become shareable, the browser’s back and forward buttons work naturally, and there’s no need for extra state synchronization between components. When a piece of UI state affects navigation or should be persistent across reloads, it belongs in the URL rather than in local or global state.

Controlled vs Uncontrolled Components

Understanding this distinction helps prevent many common bugs. Uncontrolled components manage their own internal state, making them simpler to use and ideal for isolated UI elements. Controlled components, on the other hand, have their state managed by the parent, making them fully predictable and easier to synchronize across the app.

shadcn/ui components support both patterns. The key is to choose based on who should own the state, rather than convenience, ensuring clarity and maintainability in your UI logic.


Animation and micro-interactions

Animation should support intent, not distract from it.
In shadcn/ui-based systems, motion is a layer, never the foundation.

Using Tailwind Animations

Tailwind is often sufficient for most UI animation needs. It works best for hover and focus transitions, simple fades and slides, loading states, and dropdown or tooltip motion.

Tailwind excels because it has zero runtime cost, produces predictable output, and is easy to tweak or remove. As a rule of thumb, if an animation can be expressed with CSS, it probably should be, keeping your UI simple and performant.

import { Button } from "@/components/ui/button";

export default function AnimationExample() {
&nbsp; return (
&nbsp; &nbsp; <div className="space-y-4 p-4">
&nbsp; &nbsp; &nbsp; {/* Hover transition */}
&nbsp; &nbsp; &nbsp; <Button className="bg-blue-500 text-white transition-colors hover:bg-blue-600">
&nbsp; &nbsp; &nbsp; &nbsp; Hover Me
&nbsp; &nbsp; &nbsp; </Button>

&nbsp; &nbsp; &nbsp; {/* Fade in */}
&nbsp; &nbsp; &nbsp; <div className="opacity-0 animate-fadeIn p-4 bg-gray-200 rounded">
&nbsp; &nbsp; &nbsp; &nbsp; Fade-in content
&nbsp; &nbsp; &nbsp; </div>

&nbsp; &nbsp; &nbsp; {/* Slide up */}
&nbsp; &nbsp; &nbsp; <div className="transform translate-y-4 opacity-0 animate-slideUp p-4 bg-green-200 rounded">
&nbsp; &nbsp; &nbsp; &nbsp; Slide-up content
&nbsp; &nbsp; &nbsp; </div>
&nbsp; &nbsp; </div>
&nbsp; );
}
/* Tailwind custom animations */
@keyframes fadeIn {
&nbsp; to { opacity: 1; }
}

@keyframes slideUp {
&nbsp; to { transform: translateY(0); opacity: 1; }
}

.animate-fadeIn {
&nbsp; animation: fadeIn 0.5s forwards;
}

.animate-slideUp {
&nbsp; animation: slideUp 0.5s forwards;
}

Key points:

  • Tailwind handles hover/focus transitions with transition-* utilities.
  • Simple animations like fade and slide can be added via custom @keyframes.
  • No JavaScript is needed, keeping the runtime cost zero and output predictable.

This demonstrates how most UI animations can stay purely CSS-driven while remaining flexible.

Integrating Framer Motion

Use Framer Motion when animations depend on state changes, when you need shared layout transitions, or when motion helps communicate hierarchy or flow.

Best practices: isolate motion inside client components, wrap shadcn/ui components rather than modifying them directly, and keep motion APIs separate from core UI primitives. Motion should enhance clarity and user experience without introducing unnecessary complexity or tightly coupling your UI to an animation library.

"use client";
import { motion } from "framer-motion";
import { Card } from "@/components/ui/card";
import { useState } from "react";
import { Button } from "@/components/ui/button";

export default function MotionExample() {
&nbsp; const &#91;expanded, setExpanded] = useState(false);

&nbsp; return (
&nbsp; &nbsp; <div className="space-y-4">
&nbsp; &nbsp; &nbsp; <Button onClick={() => setExpanded(!expanded)}>
&nbsp; &nbsp; &nbsp; &nbsp; {expanded ? "Collapse" : "Expand"}
&nbsp; &nbsp; &nbsp; </Button>

&nbsp; &nbsp; &nbsp; <motion.div
&nbsp; &nbsp; &nbsp; &nbsp; layout
&nbsp; &nbsp; &nbsp; &nbsp; initial={{ opacity: 0, height: 0 }}
&nbsp; &nbsp; &nbsp; &nbsp; animate={{ opacity: 1, height: expanded ? "auto" : 0 }}
&nbsp; &nbsp; &nbsp; &nbsp; transition={{ duration: 0.3 }}
&nbsp; &nbsp; &nbsp; >
&nbsp; &nbsp; &nbsp; &nbsp; <Card className="p-4 bg-gray-100">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; This content expands and collapses with smooth motion.
&nbsp; &nbsp; &nbsp; &nbsp; </Card>
&nbsp; &nbsp; &nbsp; </motion.div>
&nbsp; &nbsp; </div>
&nbsp; );
}

Key points:

  • Motion is isolated inside a client component.
  • shadcn/ui’s Card is wrapped instead of being modified.
  • layout and animate handle smooth transitions based on state changes.
  • Motion enhances user experience without complicating core UI primitives.

Performance-Aware animations

Bad animations can degrade user experience faster than inefficient code. As a rule of thumb, animate only transform and opacity, avoid properties that trigger layout recalculations, and keep animations off critical rendering paths.

Always measure, don’t guess: profile animation-heavy screens, test on lower-end devices, and remove animations that don’t add meaningful value. Often, the smoothest and most responsive UI is the simplest one.


Testing and CI checks

Because shadcn/ui gives you real source code, testing becomes your responsibility and your superpower.
The goal is not to test everything, but to test the right things.

Unit Testing Components

Unit tests should focus on behavior, not implementation details. Test that components render with required props, respond correctly to user interactions like clicks or inputs, and update state as expected.

Avoid testing Tailwind classes, snapshots of large components, or Radix/browser behavior. Since shadcn/ui components are thin wrappers, test only what you’ve added, not what comes from the library. This keeps tests meaningful, focused, and maintainable.

Testing Accessibility

Accessibility testing is essential for production UIs. Use automated tools to catch obvious issues, test keyboard navigation manually, and verify focus behavior in dialogs, menus, and forms. Pay attention to tab order, focus traps, and ARIA attributes when adding custom logic. While Radix and Base UI provide a strong accessibility foundation, extending components incorrectly can still introduce accessibility problems, so careful testing is always required.

Visual Regression Testing

Visual bugs are among the most costly to ship. Regression testing helps catch broken layouts, spacing issues, responsive problems, and theme inconsistencies.

The best approach is to test blocks rather than atomic components, capture common UI states, and keep snapshots small and intentional. Treat visual tests as design contracts, ensuring consistency without enforcing rigid, pixel-perfect rules.


Common anti-patterns and migration notes

shadcn/ui gives you freedom, but freedom magnifies mistakes.
These are the most common ways teams accidentally sabotage their own UI system.

Treating shadcn/ui like MUI or Chakra

The biggest mistake teams make with shadcn/ui is treating it as a black box, a theming engine, or a prop-driven design system. Some expect it to provide endless props, global configuration, or automatic consistency out of the box. When this happens, teams end up recreating the very issues shadcn/ui was designed to solve: bloated components, rigid APIs, and hidden complexity.

In reality, shadcn/ui is a starting point, not a full framework. It offers composable, well-designed primitives that handle styling, accessibility, and basic behavior, but it does not enforce how you structure pages, manage state, or implement branding. Its power comes from being flexible and minimal, allowing teams to build scalable, maintainable UIs without over-constraining their design.

The key is to use shadcn/ui as a foundation, extend components thoughtfully, adopt clear patterns, and apply your architecture and design decisions on top. Expecting it to solve everything automatically leads to frustration and misuse.

Over-Engineering Components

Not every component needs variants, context, configuration objects, or extra abstraction layers. Over-engineering introduces hard-to-understand APIs, slows down iteration, and creates fear of change. The best approach is to start simple: build components with a minimal API, only adding complexity when it’s truly required. Complexity should be earned, not anticipated; introduce new patterns or variants only when repetition or real use cases demand them. Keeping components lean makes them easier to use, maintain, and evolve, while preserving flexibility for future growth without burdening developers with unnecessary options from the start.

Premature Abstractions

Abstracting too early is a silent productivity killer. Many teams fall into the trap of creating generic layers like a “BaseButton,” wrapping every component, or abstracting layout before patterns even exist. While the intention is often to enforce consistency or prepare for scale, premature abstraction often slows development, adds unnecessary complexity, and makes components harder to understand and use.

A better approach is to duplicate first, observe patterns, and abstract only when repetition is proven. This allows you to make informed decisions about which abstractions are truly valuable rather than guessing ahead of time. shadcn/ui supports this mindset by providing flexible, composable primitives that evolve with your project. Its design encourages teams to grow complexity naturally, promoting maintainable, predictable, and usable UI without over-engineering from the start.

Copy-Paste without understanding

Copying code without understanding it is technical debt in disguise. Blindly reusing components can break accessibility guarantees, misuse Radix primitives, or create inconsistent behavior.

If you paste a component, you should fully understand what state it manages, why it uses specific props, and how it handles accessibility. True ownership of a component begins with comprehension, not just reuse. Taking the time to read and grasp the code ensures reliable, maintainable, and predictable UI, preventing hidden bugs and future refactoring headaches.


shadcn/ui vs Other UI Systems

shadcn/ui is often compared to traditional component libraries, but it plays a different game.
Understanding the tradeoffs matters more than picking a winner.

Comparison with MUI, Chakra, Mantine

Traditional UI libraries (MUI, Chakra, Mantine):

  • Ship pre-built components as packages
  • Offer prop-driven customization
  • Centralize theming and configuration
  • Abstract implementation details

shadcn/ui:

  • Gives you source code, not a dependency
  • Encourages composition over configuration
  • Relies on Tailwind and tokens instead of theme APIs
  • Makes UI part of your codebase, not a black box

This difference shapes everything else.

High-Level Comparison:

Aspectshadcn/uiMUIChakra UIMantine
Distribution modeCopy-paste source codeNPM packageNPM packageNPM package
OwnershipYou own the componentsThe library owns componentsThe library owns componentsThe library owns components
CustomizationEdit the code directlyProp & theme drivenProp & theme drivenProp & theme driven
Theming approachCSS variables + TailwindTheme providerTheme providerTheme provider
Styling systemTailwind CSSCSS-in-JSCSS-in-JSCSS-in-JS
Bundle size controlFull controlLimitedLimitedLimited
Tree-shakingExcellentGoodGoodGood
Server Components friendlyYesPartialPartialPartial
Learning curveMedium (system thinking)LowLowMedium
Long-term flexibilityVery highMediumMediumHigh

Developer Experience Comparison:

Categoryshadcn/uiTraditional UI Libraries
Initial setup speedModerateVery fast
Day-1 productivityGoodExcellent
Day-100 maintainabilityExcellentMixed
DebuggingTransparentAbstracted
Custom behaviorEasy (edit code)Harder (work around APIs)
Escaping constraintsTrivialPainful

When shadcn/ui wins?

shadcn/ui is a strong choice when:

  • You want full control over UI behavior
  • Long-term maintainability matters
  • You’re building a product, not a prototype
  • You care about performance and bundle size
  • You’re comfortable owning code
ScenarioWhy shadcn/ui Works Better
Large productsUI evolves without fighting a library
Design-system driven teamsTokens and ownership align well
Performance-sensitive appsMinimal runtime and JS
Long-lived codebasesNo dependency lock-in
Advanced UI needsYou can modify internals freely

Teams that value clarity and control tend to thrive with shadcn/ui.

Takeaway

This isn’t about popularity, it’s about tradeoffs.

  • shadcn/ui optimizes for control, clarity, and longevity
  • Traditional libraries optimize for speed and convenience.

Pick the system that matches your team’s mindset, not the trend.


When you should NOT use shadcn/ui

shadcn/ui is powerful, but it is not universal.
Knowing when not to use it is just as important as knowing when to adopt it.

Fast MVPs

If your main goal is to ship quickly, validate an idea, or minimize decisions, shadcn/ui may actually slow you down. Unlike pre-packaged UI libraries, it requires thinking about structure, owning styling decisions, and defining your own theme.

For throwaway MVPs or rapid prototypes, fully configured component libraries often win on speed, letting you build interfaces without upfront architectural or design considerations. shadcn/ui excels when you’re aiming for maintainable, scalable, and flexible UIs, not temporary one-off solutions.

No-Design Teams

shadcn/ui assumes a clear design intent. If your team lacks design guidance, doesn’t use tokens, or prefers visual tweaking over system rules, you may struggle to get consistent results. The library amplifies design decisions; it doesn’t create them. Without direction, its flexibility can turn into friction, making it harder to maintain consistency and scalability across your UI.

Non-Tailwind Projects

shadcn/ui is deeply tied to Tailwind. If your project relies exclusively on CSS Modules, CSS-in-JS, or avoids utility-first styling, shadcn/ui may feel unnatural. While adaptation is technically possible, the added mental overhead and friction often outweigh the benefits, making it harder to use the library as intended.


Shadcn Space: Applying these principles in the Real World

This handbook explores principles, patterns, and tradeoffs around building UI with shadcn/ui.
Shadcn Space is our attempt to apply many of these ideas in practice, not all, and not perfectly.

It’s a product shaped by real constraints, iteration, and learning.

Why did we build Shadcn Space?

While using shadcn/ui in real projects, we noticed a recurring pattern: teams were repeatedly rebuilding similar sections, good patterns were scattered across codebases, and there was no centralized place for production-ready blocks.

Shadcn Space was created to fill this gap, a platform to collect, refine, and share reusable UI blocks built on shadcn/ui, making it easier for teams to leverage proven patterns, maintain consistency, and speed up development without reinventing common sections.

What does Shadcn Space aim to do?

Shadcn Space provides a growing collection of practical UI blocks built on shadcn/ui primitives. It encourages developers to copy and adapt, fostering ownership rather than enforcing rigid patterns. The library reflects real-world usage instead of idealized examples and is intentionally opinionated and incomplete, giving teams flexibility while promoting consistency and best practices in production-ready UI.

How does it relate to this Handbook?

This handbook documents best practices and patterns for building UIs with shadcn/ui. Shadcn Space doesn’t implement all of them; instead, it experiments with these ideas in real products, learns from what works and what doesn’t, and evolves alongside the ecosystem. Think of Shadcn Space as a living playground for testing, refining, and sharing reusable UI blocks, not a finished blueprint or strict implementation of every guideline in this handbook.

Who might find Shadcn Space useful?

Shadcn Space may be useful if you:

  • Are building with shadcn/ui in production
  • Want real block-level examples
  • Prefer learning from code, not abstractions
  • Value flexibility over completeness

It’s designed to be forked, modified, and improved just like shadcn/ui itself.


Final Thoughts

shadcn/ui is designed to invite developers to take ownership of their UI, giving them the flexibility to build, extend, and adapt components rather than relying on rigid frameworks.

Shadcn Space is one approach to exploring what that ownership can look like in practice: a collection of reusable, production-ready UI blocks that demonstrate real-world patterns and possibilities.

Its purpose isn’t to dictate how you should build but to inspire experimentation, refinement, and iteration. Use it as-is, modify it to fit your needs, or create something entirely new. The goal is to empower teams to craft maintainable, scalable, and intentional UI.

block to block redirection

Summarize with AI

Share Instantly