We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide23 min read

shadcn/ui

shadcn/ui is a collection of re-usable React components built with Radix UI and Tailwind CSS. Full code ownership, accessibility, and modern design.

shadcn/ui - Kompletny przewodnik po najpopularniejszej bibliotece komponentów React

Czym jest shadcn/ui i dlaczego zrewolucjonizował frontend development?

shadcn/ui to nie jest typowa biblioteka komponentów. To fundamentalnie inne podejście do budowania interfejsów użytkownika w React. Zamiast instalować paczkę npm i importować komponenty, kopiujesz kod źródłowy bezpośrednio do swojego projektu. Brzmi dziwnie? To właśnie ta filozofia sprawiła, że shadcn/ui stał się jednym z najpopularniejszych narzędzi w ekosystemie React.

Projekt został stworzony przez shadcn (prawdziwe imię: Shad) i szybko zyskał ogromną popularność w społeczności developerów. W 2023 roku stał się de facto standardem dla nowych projektów Next.js, a w 2024 roku jego popularność tylko wzrosła.

Dlaczego shadcn/ui jest tak popularne?

Pełna kontrola nad kodem

Gdy instalujesz tradycyjną bibliotekę UI jak Material UI czy Chakra UI, komponenty są ukryte w node_modules. Jeśli potrzebujesz zmodyfikować zachowanie przycisku, musisz używać skomplikowanych overrides lub wrapper components. Z shadcn/ui kod jest w twoim projekcie - możesz zmienić dosłownie wszystko.

Brak vendor lock-in

Ponieważ kod należy do ciebie, nie jesteś uzależniony od aktualizacji biblioteki. Możesz zamrozić wersję komponentu, zmodyfikować go pod swoje potrzeby lub całkowicie przepisać - bez obawy o breaking changes w następnej wersji.

Dostępność (Accessibility) od podstaw

Pod spodem shadcn/ui używa Radix UI - biblioteki primitives, która jest liderem w kwestii dostępności. Każdy komponent jest w pełni dostępny dla czytników ekranowych, obsługuje nawigację klawiaturą i spełnia standardy WCAG.

Nowoczesny stack technologiczny

shadcn/ui jest zbudowany na:

  • Radix UI - headless primitives z pełną dostępnością
  • Tailwind CSS - utility-first styling
  • TypeScript - pełne typowanie
  • Class Variance Authority (CVA) - zarządzanie wariantami

Instalacja i konfiguracja

Wymagania wstępne

Przed instalacją shadcn/ui potrzebujesz projektu z:

  • React 18+
  • Tailwind CSS 3.4+
  • TypeScript (opcjonalnie, ale zalecane)

Inicjalizacja w Next.js

Code
Bash
npx shadcn@latest init

Podczas inicjalizacji zostaniesz zapytany o:

Code
TEXT
Which style would you like to use? › Default
Which color would you like to use as base color? › Slate
Where is your global CSS file? › app/globals.css
Would you like to use CSS variables for colors? › yes
Where is your tailwind.config.js located? › tailwind.config.js
Configure the import alias for components: › @/components
Configure the import alias for utils: › @/lib/utils

Struktura po inicjalizacji

Po inicjalizacji twój projekt będzie miał następującą strukturę:

Code
TEXT
project/
├── components/
│   └── ui/           # Tu trafią komponenty
├── lib/
│   └── utils.ts      # Funkcja cn() do łączenia klas
├── components.json   # Konfiguracja shadcn/ui
└── tailwind.config.js

Plik utils.ts

TSlib/utils.ts
TypeScript
// lib/utils.ts
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Funkcja cn() to serce stylowania w shadcn/ui. Łączy klasy CSS inteligentnie, rozwiązując konflikty Tailwind (np. bg-red-500 i bg-blue-500 - wygrywa ostatnia).

Dodawanie komponentów

CLI - zalecana metoda

Code
Bash
# Pojedynczy komponent
npx shadcn@latest add button

# Wiele komponentów naraz
npx shadcn@latest add button card dialog input textarea

# Wszystkie dostępne komponenty
npx shadcn@latest add --all

Manualne kopiowanie

Możesz też skopiować kod bezpośrednio z dokumentacji. Każdy komponent ma przycisk "Copy" z pełnym kodem źródłowym.

Przegląd najważniejszych komponentów

Button - podstawowy przycisk

Button to najprostszy, ale jednocześnie najczęściej używany komponent:

TScomponents/ui/button.tsx
TypeScript
// components/ui/button.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-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 hover:text-accent-foreground",
        secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button"
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = "Button"

export { Button, buttonVariants }

Użycie w praktyce:

Code
TypeScript
import { Button } from "@/components/ui/button"
import { Mail, Loader2 } from "lucide-react"

export function ButtonExamples() {
  return (
    <div className="flex flex-col gap-4">
      {/* Podstawowe warianty */}
      <div className="flex gap-2">
        <Button>Default</Button>
        <Button variant="secondary">Secondary</Button>
        <Button variant="destructive">Destructive</Button>
        <Button variant="outline">Outline</Button>
        <Button variant="ghost">Ghost</Button>
        <Button variant="link">Link</Button>
      </div>

      {/* Rozmiary */}
      <div className="flex items-center gap-2">
        <Button size="sm">Small</Button>
        <Button size="default">Default</Button>
        <Button size="lg">Large</Button>
        <Button size="icon"><Mail className="h-4 w-4" /></Button>
      </div>

      {/* Ze stanem loading */}
      <Button disabled>
        <Loader2 className="mr-2 h-4 w-4 animate-spin" />
        Please wait
      </Button>

      {/* Jako link */}
      <Button asChild>
        <a href="/login">Login</a>
      </Button>
    </div>
  )
}

Form - kompleksowe formularze z walidacją

shadcn/ui integruje się znakomicie z React Hook Form i Zod:

Code
Bash
npx shadcn@latest add form input label
npm install @hookform/resolvers zod
Code
TypeScript
"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,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { toast } from "@/components/ui/use-toast"

const formSchema = z.object({
  username: z.string().min(2, {
    message: "Username must be at least 2 characters.",
  }),
  email: z.string().email({
    message: "Please enter a valid email address.",
  }),
  password: z.string().min(8, {
    message: "Password must be at least 8 characters.",
  }).regex(/[A-Z]/, {
    message: "Password must contain at least one uppercase letter.",
  }).regex(/[0-9]/, {
    message: "Password must contain at least one number.",
  }),
})

type FormValues = z.infer<typeof formSchema>

export function RegistrationForm() {
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      username: "",
      email: "",
      password: "",
    },
  })

  async function onSubmit(values: FormValues) {
    try {
      // Wyślij dane do API
      const response = await fetch("/api/register", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(values),
      })

      if (!response.ok) throw new Error("Registration failed")

      toast({
        title: "Account created!",
        description: "You can now log in with your credentials.",
      })
    } catch (error) {
      toast({
        title: "Error",
        description: "Something went wrong. Please try again.",
        variant: "destructive",
      })
    }
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder="johndoe" {...field} />
              </FormControl>
              <FormDescription>
                This is your public display name.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input type="email" placeholder="john@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Password</FormLabel>
              <FormControl>
                <Input type="password" {...field} />
              </FormControl>
              <FormDescription>
                Min. 8 characters, one uppercase, one number.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit" className="w-full">
          Create account
        </Button>
      </form>
    </Form>
  )
}

Dialog - modalne okna dialogowe

Code
TypeScript
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
  DialogClose,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function EditProfileDialog() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="outline">Edit Profile</Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[425px]">
        <DialogHeader>
          <DialogTitle>Edit profile</DialogTitle>
          <DialogDescription>
            Make changes to your profile here. Click save when you're done.
          </DialogDescription>
        </DialogHeader>
        <div className="grid gap-4 py-4">
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="name" className="text-right">
              Name
            </Label>
            <Input id="name" defaultValue="John Doe" className="col-span-3" />
          </div>
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="username" className="text-right">
              Username
            </Label>
            <Input id="username" defaultValue="@johndoe" className="col-span-3" />
          </div>
        </div>
        <DialogFooter>
          <DialogClose asChild>
            <Button type="button" variant="secondary">
              Cancel
            </Button>
          </DialogClose>
          <Button type="submit">Save changes</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Data Table - zaawansowane tabele z TanStack Table

Code
Bash
npx shadcn@latest add table
npm install @tanstack/react-table
Code
TypeScript
"use client"

import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
  getPaginationRowModel,
  getSortedRowModel,
  SortingState,
  getFilteredRowModel,
  ColumnFiltersState,
} 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 { useState } from "react"
import { ArrowUpDown, MoreHorizontal } from "lucide-react"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

// Definicja typu danych
type User = {
  id: string
  email: string
  name: string
  status: "active" | "inactive" | "pending"
  createdAt: Date
}

// Definicja kolumn
const columns: ColumnDef<User>[] = [
  {
    accessorKey: "name",
    header: ({ column }) => {
      return (
        <Button
          variant="ghost"
          onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
        >
          Name
          <ArrowUpDown className="ml-2 h-4 w-4" />
        </Button>
      )
    },
  },
  {
    accessorKey: "email",
    header: "Email",
  },
  {
    accessorKey: "status",
    header: "Status",
    cell: ({ row }) => {
      const status = row.getValue("status") as string
      return (
        <span className={`px-2 py-1 rounded-full text-xs ${
          status === "active" ? "bg-green-100 text-green-800" :
          status === "pending" ? "bg-yellow-100 text-yellow-800" :
          "bg-gray-100 text-gray-800"
        }`}>
          {status}
        </span>
      )
    },
  },
  {
    id: "actions",
    cell: ({ row }) => {
      const user = row.original
      return (
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="ghost" className="h-8 w-8 p-0">
              <MoreHorizontal className="h-4 w-4" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuLabel>Actions</DropdownMenuLabel>
            <DropdownMenuItem onClick={() => navigator.clipboard.writeText(user.id)}>
              Copy ID
            </DropdownMenuItem>
            <DropdownMenuItem>Edit</DropdownMenuItem>
            <DropdownMenuItem className="text-red-600">Delete</DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      )
    },
  },
]

export function UsersTable({ data }: { data: User[] }) {
  const [sorting, setSorting] = useState<SortingState>([])
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    state: {
      sorting,
      columnFilters,
    },
  })

  return (
    <div className="space-y-4">
      <Input
        placeholder="Filter by email..."
        value={(table.getColumn("email")?.getFilterValue() as string) ?? ""}
        onChange={(event) =>
          table.getColumn("email")?.setFilterValue(event.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}>
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                  </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-end space-x-2">
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.previousPage()}
          disabled={!table.getCanPreviousPage()}
        >
          Previous
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.nextPage()}
          disabled={!table.getCanNextPage()}
        >
          Next
        </Button>
      </div>
    </div>
  )
}

Theming i customizacja

System kolorów z CSS Variables

shadcn/ui używa CSS variables, co umożliwia łatwe theming:

app/globals.css
CSS
/* app/globals.css */
@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%;
    --popover: 222.2 84% 4.9%;
    --popover-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%;
  }
}

Tworzenie własnych wariantów

Code
TypeScript
// Rozszerz istniejący komponent
const buttonVariants = cva(
  "inline-flex items-center justify-center...",
  {
    variants: {
      variant: {
        // Istniejące warianty...
        default: "bg-primary text-primary-foreground hover:bg-primary/90",

        // Twoje własne warianty
        gradient: "bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600",
        success: "bg-green-500 text-white hover:bg-green-600",
        warning: "bg-yellow-500 text-black hover:bg-yellow-600",
      },
      size: {
        // Własne rozmiary
        xs: "h-7 rounded px-2 text-xs",
        xl: "h-14 rounded-lg px-10 text-lg",
      },
    },
  }
)

Porównanie z innymi bibliotekami UI

Cechashadcn/uiMaterial UIChakra UIAnt Design
Ownership koduPełnaBrakBrakBrak
Bundle sizeTylko używane~300KB~150KB~400KB
CustomizacjaBezpośredniaTheme/OverridePropsTheme
AccessibilityRadix (AAA)DobraBardzo dobraDobra
TypeScriptNatywnyDobryDobryDobry
Learning curveNiskaŚredniaNiskaWysoka
Design systemTailwindMaterialWłasnyAnt

Najlepsze praktyki

1. Organizacja komponentów

Code
TEXT
components/
├── ui/                 # Komponenty shadcn/ui (nie modyfikuj nazw)
│   ├── button.tsx
│   ├── card.tsx
│   └── dialog.tsx
├── forms/              # Komponenty formularzy
│   ├── login-form.tsx
│   └── register-form.tsx
└── shared/             # Własne komponenty używające shadcn/ui
    ├── user-avatar.tsx
    └── navigation.tsx

2. Nie modyfikuj oryginalnych plików

Zamiast modyfikować button.tsx, stwórz wrapper:

TScomponents/shared/primary-button.tsx
TypeScript
// components/shared/primary-button.tsx
import { Button, ButtonProps } from "@/components/ui/button"
import { cn } from "@/lib/utils"

export function PrimaryButton({ className, ...props }: ButtonProps) {
  return (
    <Button
      className={cn("bg-brand-500 hover:bg-brand-600", className)}
      {...props}
    />
  )
}

3. Używaj composable patterns

Code
TypeScript
// Zamiast jednego dużego komponentu
function UserCard({ user, showActions, showBadge, variant }) {
  // Skomplikowana logika warunkowa
}

// Preferuj composition
function UserCard({ children }) {
  return <Card>{children}</Card>
}

function UserCardHeader({ user }) { /* ... */ }
function UserCardActions({ onEdit, onDelete }) { /* ... */ }
function UserCardBadge({ status }) { /* ... */ }

// Użycie
<UserCard>
  <UserCardHeader user={user} />
  <UserCardBadge status={user.status} />
  <UserCardActions onEdit={handleEdit} onDelete={handleDelete} />
</UserCard>

Często zadawane pytania (FAQ)

Czy shadcn/ui jest darmowe?

Tak, shadcn/ui jest w pełni darmowe i open-source na licencji MIT. Możesz używać go w projektach komercyjnych bez żadnych opłat.

Czy mogę używać shadcn/ui bez Tailwind CSS?

Nie, shadcn/ui wymaga Tailwind CSS do stylowania. Komponenty są zbudowane z klasami Tailwind i nie działają bez niego.

Jak aktualizować komponenty?

Ponieważ kod jest w twoim projekcie, nie ma automatycznych aktualizacji. Możesz:

  1. Ręcznie skopiować nową wersję z dokumentacji
  2. Użyć npx shadcn@latest add button --overwrite (uwaga: nadpisze twoje zmiany)

Czy shadcn/ui działa z Next.js App Router?

Tak, shadcn/ui jest w pełni kompatybilne z Next.js App Router. Komponenty klienckie używają dyrektywy "use client".

Jak dodać dark mode?

shadcn/ui wspiera dark mode przez CSS variables. Użyj next-themes:

Code
Bash
npm install next-themes
TSapp/providers.tsx
TypeScript
// app/providers.tsx
"use client"
import { ThemeProvider } from "next-themes"

export function Providers({ children }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
      {children}
    </ThemeProvider>
  )
}

Podsumowanie

shadcn/ui to rewolucyjne podejście do komponentów UI w React. Zamiast być zależnym od zewnętrznej biblioteki, masz pełną kontrolę nad kodem. To idealne rozwiązanie dla projektów, które wymagają wysokiego poziomu customizacji, dostępności i nowoczesnego designu.

Główne zalety:

  • Pełna kontrola nad kodem
  • Doskonała dostępność dzięki Radix UI
  • Nowoczesny stack (Tailwind, TypeScript)
  • Brak vendor lock-in
  • Aktywna społeczność i dokumentacja

Jeśli zaczynasz nowy projekt React lub Next.js, shadcn/ui to zdecydowanie warte rozważenia jako fundament twojego design systemu.


shadcn/ui - Complete Guide to the Most Popular React Component Collection

What is shadcn/ui and Why Did It Revolutionize Frontend Development?

shadcn/ui is not a typical component library. It's a fundamentally different approach to building user interfaces in React. Instead of installing an npm package and importing components, you copy the source code directly into your project. Sounds strange? It's precisely this philosophy that made shadcn/ui one of the most popular tools in the React ecosystem.

The project was created by shadcn (real name: Shad) and quickly gained enormous popularity in the developer community. In 2023, it became the de facto standard for new Next.js projects, and in 2024, its popularity only grew.

Why is shadcn/ui So Popular?

Full Control Over Code

When you install a traditional UI library like Material UI or Chakra UI, components are hidden in node_modules. If you need to modify a button's behavior, you have to use complex overrides or wrapper components. With shadcn/ui, the code is in your project - you can change literally everything.

No Vendor Lock-in

Because the code belongs to you, you're not dependent on library updates. You can freeze a component version, modify it to your needs, or completely rewrite it - without fear of breaking changes in the next version.

Accessibility from the Ground Up

Under the hood, shadcn/ui uses Radix UI - a primitives library that is a leader in accessibility. Every component is fully accessible to screen readers, supports keyboard navigation, and meets WCAG standards.

Modern Technology Stack

shadcn/ui is built on:

  • Radix UI - headless primitives with full accessibility
  • Tailwind CSS - utility-first styling
  • TypeScript - full typing
  • Class Variance Authority (CVA) - variant management

Installation and Configuration

Prerequisites

Before installing shadcn/ui you need a project with:

  • React 18+
  • Tailwind CSS 3.4+
  • TypeScript (optional, but recommended)

Initialization in Next.js

Code
Bash
npx shadcn@latest init

During initialization you'll be asked about:

Code
TEXT
Which style would you like to use? › Default
Which color would you like to use as base color? › Slate
Where is your global CSS file? › app/globals.css
Would you like to use CSS variables for colors? › yes
Where is your tailwind.config.js located? › tailwind.config.js
Configure the import alias for components: › @/components
Configure the import alias for utils: › @/lib/utils

Structure After Initialization

After initialization your project will have the following structure:

Code
TEXT
project/
├── components/
│   └── ui/           # Components will go here
├── lib/
│   └── utils.ts      # cn() function for combining classes
├── components.json   # shadcn/ui configuration
└── tailwind.config.js

The utils.ts File

TSlib/utils.ts
TypeScript
// lib/utils.ts
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

The cn() function is the heart of styling in shadcn/ui. It combines CSS classes intelligently, resolving Tailwind conflicts (e.g., bg-red-500 and bg-blue-500 - the last one wins).

Adding Components

CLI - Recommended Method

Code
Bash
# Single component
npx shadcn@latest add button

# Multiple components at once
npx shadcn@latest add button card dialog input textarea

# All available components
npx shadcn@latest add --all

Manual Copying

You can also copy code directly from the documentation. Each component has a "Copy" button with full source code.

Overview of Key Components

Button - Basic Button

Button is the simplest yet most frequently used component:

TScomponents/ui/button.tsx
TypeScript
// components/ui/button.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-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 hover:text-accent-foreground",
        secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button"
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = "Button"

export { Button, buttonVariants }

Practical usage:

Code
TypeScript
import { Button } from "@/components/ui/button"
import { Mail, Loader2 } from "lucide-react"

export function ButtonExamples() {
  return (
    <div className="flex flex-col gap-4">
      {/* Basic variants */}
      <div className="flex gap-2">
        <Button>Default</Button>
        <Button variant="secondary">Secondary</Button>
        <Button variant="destructive">Destructive</Button>
        <Button variant="outline">Outline</Button>
        <Button variant="ghost">Ghost</Button>
        <Button variant="link">Link</Button>
      </div>

      {/* Sizes */}
      <div className="flex items-center gap-2">
        <Button size="sm">Small</Button>
        <Button size="default">Default</Button>
        <Button size="lg">Large</Button>
        <Button size="icon"><Mail className="h-4 w-4" /></Button>
      </div>

      {/* With loading state */}
      <Button disabled>
        <Loader2 className="mr-2 h-4 w-4 animate-spin" />
        Please wait
      </Button>

      {/* As link */}
      <Button asChild>
        <a href="/login">Login</a>
      </Button>
    </div>
  )
}

Form - Complex Forms with Validation

shadcn/ui integrates excellently with React Hook Form and Zod:

Code
Bash
npx shadcn@latest add form input label
npm install @hookform/resolvers zod
Code
TypeScript
"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,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { toast } from "@/components/ui/use-toast"

const formSchema = z.object({
  username: z.string().min(2, {
    message: "Username must be at least 2 characters.",
  }),
  email: z.string().email({
    message: "Please enter a valid email address.",
  }),
  password: z.string().min(8, {
    message: "Password must be at least 8 characters.",
  }).regex(/[A-Z]/, {
    message: "Password must contain at least one uppercase letter.",
  }).regex(/[0-9]/, {
    message: "Password must contain at least one number.",
  }),
})

type FormValues = z.infer<typeof formSchema>

export function RegistrationForm() {
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      username: "",
      email: "",
      password: "",
    },
  })

  async function onSubmit(values: FormValues) {
    try {
      const response = await fetch("/api/register", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(values),
      })

      if (!response.ok) throw new Error("Registration failed")

      toast({
        title: "Account created!",
        description: "You can now log in with your credentials.",
      })
    } catch (error) {
      toast({
        title: "Error",
        description: "Something went wrong. Please try again.",
        variant: "destructive",
      })
    }
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder="johndoe" {...field} />
              </FormControl>
              <FormDescription>
                This is your public display name.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input type="email" placeholder="john@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Password</FormLabel>
              <FormControl>
                <Input type="password" {...field} />
              </FormControl>
              <FormDescription>
                Min. 8 characters, one uppercase, one number.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit" className="w-full">
          Create account
        </Button>
      </form>
    </Form>
  )
}

Dialog - Modal Dialogs

Code
TypeScript
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
  DialogClose,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function EditProfileDialog() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="outline">Edit Profile</Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[425px]">
        <DialogHeader>
          <DialogTitle>Edit profile</DialogTitle>
          <DialogDescription>
            Make changes to your profile here. Click save when you're done.
          </DialogDescription>
        </DialogHeader>
        <div className="grid gap-4 py-4">
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="name" className="text-right">
              Name
            </Label>
            <Input id="name" defaultValue="John Doe" className="col-span-3" />
          </div>
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="username" className="text-right">
              Username
            </Label>
            <Input id="username" defaultValue="@johndoe" className="col-span-3" />
          </div>
        </div>
        <DialogFooter>
          <DialogClose asChild>
            <Button type="button" variant="secondary">
              Cancel
            </Button>
          </DialogClose>
          <Button type="submit">Save changes</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Theming and Customization

Color System with CSS Variables

shadcn/ui uses CSS variables, enabling easy theming:

app/globals.css
CSS
/* app/globals.css */
@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%;
    --popover: 222.2 84% 4.9%;
    --popover-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%;
  }
}

Creating Custom Variants

Code
TypeScript
// Extend existing component
const buttonVariants = cva(
  "inline-flex items-center justify-center...",
  {
    variants: {
      variant: {
        // Existing variants...
        default: "bg-primary text-primary-foreground hover:bg-primary/90",

        // Your custom variants
        gradient: "bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600",
        success: "bg-green-500 text-white hover:bg-green-600",
        warning: "bg-yellow-500 text-black hover:bg-yellow-600",
      },
      size: {
        // Custom sizes
        xs: "h-7 rounded px-2 text-xs",
        xl: "h-14 rounded-lg px-10 text-lg",
      },
    },
  }
)

Comparison with Other UI Libraries

Featureshadcn/uiMaterial UIChakra UIAnt Design
Code ownershipFullNoneNoneNone
Bundle sizeOnly used~300KB~150KB~400KB
CustomizationDirectTheme/OverridePropsTheme
AccessibilityRadix (AAA)GoodVery goodGood
TypeScriptNativeGoodGoodGood
Learning curveLowMediumLowHigh
Design systemTailwindMaterialCustomAnt

Best Practices

1. Component Organization

Code
TEXT
components/
├── ui/                 # shadcn/ui components (don't modify names)
│   ├── button.tsx
│   ├── card.tsx
│   └── dialog.tsx
├── forms/              # Form components
│   ├── login-form.tsx
│   └── register-form.tsx
└── shared/             # Custom components using shadcn/ui
    ├── user-avatar.tsx
    └── navigation.tsx

2. Don't Modify Original Files

Instead of modifying button.tsx, create a wrapper:

TScomponents/shared/primary-button.tsx
TypeScript
// components/shared/primary-button.tsx
import { Button, ButtonProps } from "@/components/ui/button"
import { cn } from "@/lib/utils"

export function PrimaryButton({ className, ...props }: ButtonProps) {
  return (
    <Button
      className={cn("bg-brand-500 hover:bg-brand-600", className)}
      {...props}
    />
  )
}

3. Use Composable Patterns

Code
TypeScript
// Instead of one large component
function UserCard({ user, showActions, showBadge, variant }) {
  // Complex conditional logic
}

// Prefer composition
function UserCard({ children }) {
  return <Card>{children}</Card>
}

function UserCardHeader({ user }) { /* ... */ }
function UserCardActions({ onEdit, onDelete }) { /* ... */ }
function UserCardBadge({ status }) { /* ... */ }

// Usage
<UserCard>
  <UserCardHeader user={user} />
  <UserCardBadge status={user.status} />
  <UserCardActions onEdit={handleEdit} onDelete={handleDelete} />
</UserCard>

Frequently Asked Questions (FAQ)

Is shadcn/ui free?

Yes, shadcn/ui is fully free and open-source under the MIT license. You can use it in commercial projects without any fees.

Can I use shadcn/ui without Tailwind CSS?

No, shadcn/ui requires Tailwind CSS for styling. Components are built with Tailwind classes and don't work without it.

How do I update components?

Since the code is in your project, there are no automatic updates. You can:

  1. Manually copy the new version from the documentation
  2. Use npx shadcn@latest add button --overwrite (warning: will overwrite your changes)

Does shadcn/ui work with Next.js App Router?

Yes, shadcn/ui is fully compatible with Next.js App Router. Client components use the "use client" directive.

How do I add dark mode?

shadcn/ui supports dark mode through CSS variables. Use next-themes:

Code
Bash
npm install next-themes
TSapp/providers.tsx
TypeScript
// app/providers.tsx
"use client"
import { ThemeProvider } from "next-themes"

export function Providers({ children }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
      {children}
    </ThemeProvider>
  )
}

Summary

shadcn/ui is a revolutionary approach to UI components in React. Instead of being dependent on an external library, you have full control over the code. It's the ideal solution for projects that require a high level of customization, accessibility, and modern design.

Main advantages:

  • Full code control
  • Excellent accessibility thanks to Radix UI
  • Modern stack (Tailwind, TypeScript)
  • No vendor lock-in
  • Active community and documentation

If you're starting a new React or Next.js project, shadcn/ui is definitely worth considering as the foundation of your design system.