Skip to content

Building a UI with shadcn/ui

Aman Kumar Singh8 min read
Part 25 of 50From the React & Next.js Complete Guide series
Building a UI with shadcn/ui — article by Aman Kumar Singh

In Styling with Tailwind CSS I covered utility-first styling: how Tailwind's constraint-based classes keep a design system consistent without a separate CSS file per component. That approach gets you most of the way to a good-looking app, but it doesn't give you interactive primitives. A dialog needs focus trapping. A dropdown needs keyboard navigation and portal rendering. A combobox needs to coordinate an input, a listbox, and typeahead filtering. Writing all of that correctly from scratch is a real project, not an afternoon task.

This is part of the React & Next.js Complete Guide series. shadcn/ui sits at the intersection of the last article and this one: it's Tailwind classes wrapped around accessible component primitives, distributed in a way that deliberately avoids the usual tradeoffs of a component library dependency.

The pitch sounds almost too simple: you don't install shadcn/ui as a package. You run a CLI command, it copies a component's source code into your repository, and from that point on it's your code. No black box, no waiting on a maintainer to expose the one prop you need. That model has real consequences, some good and some worth planning around, and this article works through both.

What shadcn/ui actually is

Most component libraries ship as compiled packages: you npm install them, import a <Button>, and the implementation lives in node_modules, invisible unless you go digging. shadcn/ui inverts that. Running its CLI generates a Button.tsx file directly in your project, built from Radix UI primitives for behavior and accessibility, styled with Tailwind classes, and using class-variance-authority (CVA) to manage variants like size and intent.

The result: "the library" is really a catalog of well-built reference implementations you copy into your project. This matters for a specific reason: when a designer asks for a button variant that doesn't exist, or a dialog that needs an extra prop for analytics tracking, you open the file and change it. There's no !important override needed and no wrapper component fighting the original API. You don't file a GitHub issue and wait for someone to triage it. It's plain TypeScript and JSX, and you can read it top to bottom.

The tradeoff is ownership. A traditional library gives you upgrades for free when you bump a version number. With shadcn/ui, if Radix ships an accessibility fix or the reference implementation gets a bug fix upstream, nothing pulls that in automatically. You own the code, which means you also own keeping it current if you care about upstream improvements.

Setting it up in a Next.js and Tailwind project

Assuming Tailwind is already configured (as covered in the previous article), initializing shadcn/ui is a single command:

npx shadcn@latest init

The CLI asks a handful of questions: which style variant to use, whether you want CSS variables for theming versus hardcoded Tailwind color classes, and where your components and utility functions should live. It writes the answers to a components.json file at the project root:

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "new-york",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.ts",
    "css": "app/globals.css",
    "baseColor": "zinc",
    "cssVariables": true
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui"
  }
}

rsc: true matters if you're on the App Router: it tells the CLI to generate components without a blanket "use client" directive where one isn't strictly needed, keeping as much of the tree server-rendered as the underlying Radix primitive allows. Interactive primitives like Dialog or Select still need the directive because they rely on browser APIs and event handlers, but a Badge or a Separator doesn't, and the generator respects that distinction.

Once initialized, adding a component is another CLI call:

npx shadcn@latest add button dialog form

This drops button.tsx, dialog.tsx, and form.tsx into components/ui/, along with any Radix packages those components depend on, added to package.json automatically. Nothing is hidden: open any of those files and you're reading the exact code that renders in your app.

Composing generated components

A generated Button looks like this, trimmed for brevity:

import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { forwardRef, type ButtonHTMLAttributes } from "react";

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline: "border border-input bg-background hover:bg-accent",
        ghost: "hover:bg-accent hover:text-accent-foreground",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
      },
    },
    defaultVariants: { variant: "default", size: "default" },
  },
);

export interface ButtonProps
  extends ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, ...props }, ref) => (
    <button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
  ),
);
Button.displayName = "Button";

export { Button, buttonVariants };

This is worth reading closely because it's the pattern you'll repeat every time you extend or write a new primitive-based component. CVA's cva() call defines a base class string plus named variant groups; the generated buttonVariants function then resolves the right combination of Tailwind classes at render time based on the props passed in. The cn() helper (a thin wrapper around clsx and tailwind-merge) lets a caller pass a className prop that overrides conflicting Tailwind classes instead of just appending to them, which is what keeps <Button className="w-full"> from fighting with the component's own width classes.

Because this is plain code, adding a new variant, say a "link" style that renders like an anchor, means adding one entry to the variants.variant object. No wrapper component, no CSS specificity battle.

Theming with CSS variables

The cssVariables: true option in components.json changes how colors resolve. Instead of Tailwind classes referencing fixed color values, they reference CSS custom properties defined once in your global stylesheet:

:root {
  --background: 0 0% 100%;
  --foreground: 240 10% 3.9%;
  --primary: 240 5.9% 10%;
  --primary-foreground: 0 0% 98%;
  --destructive: 0 84.2% 60.2%;
  --destructive-foreground: 0 0% 98%;
}

.dark {
  --background: 240 10% 3.9%;
  --foreground: 0 0% 98%;
  --primary: 0 0% 98%;
  --primary-foreground: 240 5.9% 10%;
}

tailwind.config.ts maps those variables into the color palette (primary: "hsl(var(--primary))", and so on), so every generated component automatically respects light and dark mode without any per-component dark mode class. Toggling <html class="dark"> (typically driven by a small hook backed by localStorage and the system's prefers-color-scheme) is enough to reskin the entire generated component set at once. This is the right default for a real product: brand colors live in one file, so a rebrand only touches CSS variables instead of dozens of component files.

Building a real form

Forms are where shadcn/ui earns its keep, because the form.tsx primitive wires React Hook Form and Zod together with accessible label, description, and error message associations already handled:

"use client";

import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";

const inviteSchema = z.object({
  email: z.string().email("Enter a valid email address"),
  role: z.enum(["admin", "member", "viewer"]),
});

type InviteFormValues = z.infer<typeof inviteSchema>;

function InviteMemberForm({ onSubmit }: { onSubmit: (values: InviteFormValues) => void }) {
  const form = useForm<InviteFormValues>({
    resolver: zodResolver(inviteSchema),
    defaultValues: { email: "", role: "member" },
  });

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email address</FormLabel>
              <FormControl>
                <Input placeholder="teammate@company.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit" disabled={form.formState.isSubmitting}>
          Send invite
        </Button>
      </form>
    </Form>
  );
}

The FormField render prop connects a single field to React Hook Form's controller API, and FormMessage automatically renders the Zod validation error tied to that field, with aria-describedby wired up so screen readers announce the error when it appears. Without this primitive, wiring accessible error association by hand for every field in every form is exactly the kind of repetitive, easy-to-skip work that quietly degrades over a codebase's lifetime.

Tradeoffs and production pitfalls

The most common mistake teams make is treating shadcn/ui like a normal dependency and never opening the generated files. If you never touch them, you're carrying the maintenance burden of an owned codebase (no automatic upgrades) without taking the benefit (editing to fit your exact needs). Read the generated component at least once before shipping it; you're now responsible for it.

Version drift across a team is a real risk. If one engineer runs add dialog today and another runs it in six months, they can get subtly different base implementations if the upstream registry has changed. Commit the generated files to version control immediately (the CLI does this by default, since it just writes files) and treat changes to components/ui/ as reviewable code, not generated noise to ignore in a diff.

Heavy customization can erode the accessibility guarantees Radix provides. It's easy to strip a role attribute or override a focus-trap behavior while chasing a design tweak, and because there's no library boundary stopping you, nothing warns you when you've broken it. Keep the Radix primitive's DOM structure and ARIA attributes intact unless you have a specific, tested reason to change them.

Finally, don't add every component in the catalog "just in case." Each add command pulls in a Radix package and adds files you now own. A Tooltip or HoverCard you're not using yet is dead code sitting in your bundle and your review surface. Add components when a screen actually needs them.

Key takeaways

  • shadcn/ui copies component source into your repository instead of shipping a compiled package, trading automatic upgrades for full ownership and editability.
  • Components are built on Radix UI primitives for accessibility and behavior, styled with Tailwind and managed with class-variance-authority for variants.
  • CSS variables (enabled via `components.json`) centralize theming and dark mode so a rebrand touches one stylesheet instead of every component.
  • The `form.tsx` primitive pairs React Hook Form and Zod with accessible label and error wiring already handled, which is real, easy-to-skip work otherwise.
  • Treat generated files as code you own and review, not generated noise; read them before shipping and keep ARIA attributes intact when customizing.
  • Add components only when a screen needs them; every `add` command increases both bundle size and review surface.

Frequently asked questions

Is shadcn/ui a component library I install with npm?

No. It's a CLI that generates component source files directly into your project using Radix UI primitives, Tailwind classes, and class-variance-authority. You don't import a package version; you own and edit the generated code directly.

How do I upgrade shadcn/ui components when the registry changes?

There's no automatic upgrade path, since the components live in your repository as regular code. If you want a newer reference implementation, re-run the `add` command for that component and manually reconcile any customizations you've made, similar to resolving a merge conflict.

Does shadcn/ui work with the Next.js App Router and Server Components?

Yes. The `rsc: true` setting in `components.json` generates components without an unnecessary `"use client"` directive where the underlying primitive doesn't require browser APIs. Interactive primitives like dialogs and dropdowns still need the directive because Radix relies on client-side state and portals.

Can I use shadcn/ui without Tailwind CSS?

Not really. The generated components use Tailwind utility classes directly in their `className` props, and CVA's variant system is built around composing those classes. Swapping to a different styling approach would mean rewriting each generated component's styling layer.

How does shadcn/ui handle accessibility compared to building components from scratch?

It inherits accessibility behavior from Radix UI primitives: focus trapping in dialogs, roving tabindex in menus, correct ARIA roles and states. That's the main reason to reach for it over hand-rolled components, since getting keyboard navigation and screen reader behavior right from scratch is genuinely time-consuming. Heavy customization can still break these guarantees if you strip attributes or restructure the DOM.

What happens if the design changes significantly after a component is already generated?

Since the component is your own TypeScript file, you edit the CVA variant definitions or the JSX structure directly, the same way you'd edit any other component in your codebase. There's no library API constraint blocking the change.

Related articles

Dark Mode Done Properly — Aman Kumar Singh
Compound Components and Slots — Aman Kumar Singh
An Enterprise Folder Structure for Next.js — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.