shadcn/ui in Practice: Owning Copied Components, Tailwind v4 Theming, asChild and Forms

Key takeaways

shadcn/ui is not a dependency you upgrade; it is source code the CLI writes into your repo. That gives you full control over markup and styles, and it also makes you the maintainer of every component you add.

Why shadcn/ui is different

Most UI libraries ship as an npm package: you install @mui/material or antd, import components, and customize them through a theme object, props, or CSS overrides. shadcn/ui works the other way around. Its CLI copies component source files into your project, typically under components/ui/, and from that moment they are your files. You edit the JSX, change Tailwind classes, delete variants you do not need.

The appeal is obvious once you have fought a component library over specificity or tried to change markup it does not expose. The trade-off is less obvious and matters more over time: you are now the maintainer of those components. There is no npm update that brings in bug fixes. If Radix fixes a focus issue, you get it through the Radix dependency. If the shadcn/ui version of a component’s markup or classes changes, you only get that if you pull it in yourself.

Under the hood, each component is usually three things:

  • A Radix UI primitive (or a plain element) that provides behavior and accessibility: focus management, keyboard navigation, ARIA attributes, portal rendering.
  • Tailwind classes for styling, often organized with class-variance-authority (cva) for variants.
  • A small cn() helper in lib/utils.ts that combines clsx with tailwind-merge, so a className you pass can override a default class instead of conflicting with it.

Setting up with the CLI

The CLI package is shadcn. Older tutorials use npx shadcn-ui@latest, which is the deprecated name.

npx shadcn@latest init
npx shadcn@latest add button dialog form input label

init detects your framework (Next.js, Vite, and others), writes components.json, adds the cn helper, installs a few dependencies, and adds the theme variables to your global CSS. components.json tells later add commands where to put files and how to write them:

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

The tailwind.config field is empty in Tailwind v4 projects because v4 is configured in CSS. The aliases must match the path aliases in your tsconfig.json. If they do not, the generated files import @/lib/utils and the build fails with Cannot find module '@/lib/utils', which is the most common first error in Vite projects where the @ alias was never configured.

What “copy-in” means for updates

Because there is no version to bump, updating a component is a source-control workflow:

  1. Start from a clean git working tree.
  2. Re-run npx shadcn@latest add <component> --overwrite for the component you want to refresh.
  3. Review the diff and restore the customizations you want to keep.

For lightly customized components this is quick. For heavily customized ones, reading the upstream change and applying it by hand is often easier than resolving a diff against a file you rewrote.

I learned to keep my edits to components/ui/ deliberately small. The first time I overwrote a button I had modified in place, the diff was a mix of upstream changes and my own tweaks, and it took longer to untangle than it would have taken to write the button from scratch. Now I prefer wrapping: leave components/ui/button.tsx close to upstream, and put project-specific behavior in components/app-button.tsx or in added cva variants, which are easy to spot and re-apply.

Upstream changes are also not purely cosmetic. When the project moved to Tailwind v4 and React 19, components were updated to drop forwardRef and to add data-slot attributes for styling. Mixing components added before and after such a change in one project works, but they will not look or behave identically, so it is worth refreshing a whole set together rather than one at a time over several months.

Theming with CSS variables and Tailwind v4

shadcn/ui components never hard-code colors like bg-blue-600. They use semantic tokens such as bg-primary, text-muted-foreground and border-input, which resolve to CSS variables. Changing the theme means changing variables, not components.

In a Tailwind v4 project the global stylesheet looks roughly like this (trimmed):

@import "tailwindcss";
@import "tw-animate-css";

@custom-variant dark (&:is(.dark *));

:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --brand: oklch(0.62 0.19 260);
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-brand: var(--brand);
  --radius-lg: var(--radius);
}

Three pieces cooperate here. :root and .dark hold the raw values. @theme inline tells Tailwind to generate utilities (bg-primary, text-brand) that point to those variables. @custom-variant dark makes the dark: prefix respond to a .dark class on an ancestor instead of the OS preference.

The pitfalls follow from that split:

  • A variable without a @theme inline entry produces no utility. Add --brand to :root, write bg-brand, and nothing happens. Tailwind does not warn about unknown classes; it just emits no CSS.
  • v3 and v4 color formats do not mix. Tailwind v3 setups stored bare HSL channels (--primary: 222.2 47.4% 11.2%) and wrapped them as hsl(var(--primary)) in the config. Pasting those values into a v4 stylesheet that uses the variables directly gives an invalid color, and the element falls back to transparent or inherited. Convert the values (v4 setups use OKLCH) instead of copying old snippets.
  • The animation plugin changed. v3 projects used the tailwindcss-animate plugin; v4 setups import tw-animate-css. If dialogs and dropdowns suddenly appear without transitions after an upgrade, check which one your CSS loads.

For dark mode in Next.js, next-themes toggles the .dark class:

// app/layout.tsx
import { ThemeProvider } from 'next-themes';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

suppressHydrationWarning on <html> is needed because next-themes sets the class before React hydrates, so the server and client markup legitimately differ. In a toggle button, use resolvedTheme rather than theme to decide the next value: theme is "system" when following the OS, so theme === 'dark' ? 'light' : 'dark' always picks dark on the first click.

Accessibility you get, and how to keep it

Radix primitives handle a lot that hand-built components usually get wrong: focus is trapped inside an open dialog and restored to the trigger when it closes, Escape closes overlays, menus support arrow-key navigation, and the right ARIA roles and attributes are set. That is a real reason to prefer shadcn/ui over copying a Tailwind snippet for a modal.

It only holds if you keep the structure Radix expects. The common way to break it is deleting parts that look optional:

<Dialog>
  <DialogTrigger asChild>
    <Button variant="outline">Edit profile</Button>
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Edit profile</DialogTitle>
      <DialogDescription>Changes are saved when you click Save.</DialogDescription>
    </DialogHeader>
    {/* form fields */}
  </DialogContent>
</Dialog>

Remove DialogTitle because the design has no heading, and Radix logs an error in development: DialogContent requires a DialogTitle for the component to be accessible for screen reader users. Remove the description and you get a warning about a missing Description or aria-describedby={undefined}. The fix for a design without a visible title is to keep the element and hide it visually (Radix provides a VisuallyHidden component), not to delete it. If there truly is no description, pass aria-describedby={undefined} to DialogContent to state that explicitly.

Icon-only buttons are the other frequent gap. <Button size="icon"><Trash2 /></Button> has no accessible name; add aria-label="Delete" or a <span className="sr-only"> label.

asChild: rendering your element with the component’s behavior

Many shadcn/ui components accept asChild. It comes from Radix’s Slot: instead of rendering its own element, the component renders its single child and merges its props, event handlers, className and ref into it.

import Link from 'next/link';

// A Link that looks like a Button, still a real <a> element
const settingsLink = (
  <Button asChild>
    <Link href="/settings">Settings</Link>
  </Button>
);

// A dialog trigger that is our Button, not a nested <button><button>
const trigger = (
  <DialogTrigger asChild>
    <Button>Open</Button>
  </DialogTrigger>
);

Without asChild, DialogTrigger renders its own <button>, and putting a Button inside gives you a button nested in a button, which is invalid HTML and produces a hydration warning in React.

asChild has two requirements that cause confusing failures:

  • Exactly one child element. Passing text plus an element, or two elements, fails with React.Children.only expected to receive a single React element child.
  • The child must pass props and the ref through to a DOM element. If your custom component takes props but only renders <div>{props.children}</div>, the merged onClick never reaches the DOM, so the trigger does nothing. If it does not accept a ref, positioned components such as Popover and Tooltip cannot measure the trigger and render in the wrong place; on React 18 you will see Function components cannot be given refs in the console. On React 19, ref is a regular prop, so spreading ...props onto the element is enough.

Forms with react-hook-form and zod

The form component is a thin layer that connects react-hook-form’s state to accessible markup: FormLabel gets the right htmlFor, FormControl sets aria-invalid and aria-describedby, and FormMessage renders the field’s validation error.

npx shadcn@latest add form input select
npm install react-hook-form @hookform/resolvers zod
'use client';

import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';

const schema = z.object({
  username: z.string().min(2, 'At least 2 characters'),
  email: z.string().email('Enter a valid email'),
  role: z.enum(['viewer', 'editor', 'admin']),
});

type Values = z.infer<typeof schema>;

export function ProfileForm({ onSave }: { onSave: (v: Values) => Promise<void> }) {
  const form = useForm<Values>({
    resolver: zodResolver(schema),
    defaultValues: { username: '', email: '', role: 'viewer' },
  });

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSave)} className="space-y-6">
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="role"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Role</FormLabel>
              <Select onValueChange={field.onChange} value={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="Pick a role" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="viewer">Viewer</SelectItem>
                  <SelectItem value="editor">Editor</SelectItem>
                  <SelectItem value="admin">Admin</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit" disabled={form.formState.isSubmitting}>
          {form.formState.isSubmitting ? 'Saving…' : 'Save'}
        </Button>
      </form>
    </Form>
  );
}

Things that go wrong here:

  • Missing defaultValues. Without them, inputs start as undefined and React warns A component is changing an uncontrolled input to be controlled on the first keystroke. Give every field a default, including empty strings.
  • Spreading field onto a Radix Select. Input is a native input, so {...field} works. Radix Select does not emit native change events; wire onValueChange={field.onChange} and value={field.value} explicitly as above. The same applies to Checkbox (checked / onCheckedChange) and Switch.
  • Number inputs return strings. <Input type="number" {...field} /> still produces a string, so z.number() fails with an “expected number” error. Use z.coerce.number() or convert in onChange.
  • Validation only on the client. The zod schema runs in the browser; reuse the same schema on the server before trusting the data.

Recent versions of the shadcn/ui docs also show a lower-level Field component set used directly with react-hook-form’s Controller. Either approach ends up with the same accessible markup; the Form wrapper shown here remains a reasonable default when a project already uses it.

Toasts and other changed components

The original toast component (with a useToast hook and a <Toaster /> from components/ui/toaster) has been deprecated in favor of Sonner:

npx shadcn@latest add sonner
// app/layout.tsx: render <Toaster /> from '@/components/ui/sonner' once
import { toast } from 'sonner';

toast.success('Profile saved');
toast.error('Could not save profile', { description: 'Check your connection and try again.' });

This is a good example of the copy-in model in practice. Projects that added toast earlier still have working files; nothing forces a migration. You decide when, or whether, to switch.

When shadcn/ui is the wrong choice

shadcn/ui fits teams that are comfortable with Tailwind and want to own their component layer. It is a weaker fit when:

  • You want to stay current without effort. A packaged library with semantic versioning and changelogs will pull in fixes with a version bump; with shadcn/ui, someone on the team has to watch for upstream changes.
  • Several apps must share components. Copying the same components into five repositories gives you five diverging forks. Publish your adapted components as an internal package, or use a monorepo workspace, rather than running add in each app.
  • You need complex, data-heavy widgets out of the box. The data table is a recipe built on TanStack Table, not a finished grid with virtualization and column resizing. Enterprise-style grids are often better served by a dedicated library.

My working rule: if we are going to restyle and restructure components anyway, owning the source is a win. If we would mostly use them as shipped, a regular dependency is less work.

Official references

shadcn/ui documentation, Radix UI primitives (the behavior and accessibility layer under most components, and the place to look when a component’s props are unclear), and Tailwind CSS.