Starting a React Project? shadcn/ui, Radix, and Base UI Explained

Starting a React Project? shadcn/ui, Radix, and Base UI Explained

Understand unstyled component libraries, compare Radix and Base UI, and learn how shadcn/ui and shadcn/create help you build React apps your way.

Aurora Scharff

Aurora Scharff

December 24, 2025

One of the first decisions you'll face when building a React app is how to handle UI components. Do you use a fully-styled library like Material UI that gets you moving fast but limits customization? Build your own design system from scratch? Or go with something more flexible that sits in between?

There's a running joke in the React community that every new app looks the same: familiar rounded corners, neutral grays, and consistent spacing. That's the "shadcn aesthetic," and it's become instantly recognizable. If you've noticed this too, you've seen shadcn/ui in action.

But what if you could pick your unstyled component library, icon set, theme, and more before writing a single line of code?

This is shadcn/create. It's built on top of shadcn/ui, which wraps unstyled component libraries like Radix and Base UI with pre-styled, customizable components. This article will explain what unstyled libraries are, compare Radix and Base UI, show how shadcn/ui brings them together, and walk through what shadcn/create offers.

What Are Unstyled Component Libraries?

Building accessible UI components from scratch is surprisingly hard. A simple dropdown menu needs to handle keyboard navigation, focus management, screen reader announcements, collision detection, and dozens of edge cases across different browsers and devices.

Unstyled component libraries solve this by providing the behavior and accessibility logic without any styling. They're the "brains" of your components, and you provide the "looks."

Here are some popular examples in the React ecosystem:

  • Radix Primitives: The most widely-adopted unstyled library, setting the standard for accessible React components
  • Base UI: The next evolution from the Radix team, combining lessons learned into a fresh codebase
  • React Aria: Adobe's library, best for apps that need internationalization (30+ languages, 13 calendar systems)
  • Ariakit: Lightweight and minimal, focused on giving you maximum control over component structure

All of these libraries share common goals: WAI-ARIA compliance, keyboard navigation, focus management, and the flexibility to work with any CSS solution.

This article focuses on Radix and Base UI, the two libraries now supported by shadcn/ui. Understanding their differences will help you make an informed choice when starting a new project.

Radix

Radix Primitives is one of the most popular unstyled libraries available. With over 130 million monthly downloads and adoption by companies like Vercel, Linear, and Supabase, it's battle-tested at scale.

Install the unified package or add individual components as needed:

      npm i radix-ui
# or install specific components
npm i @radix-ui/react-dialog @radix-ui/react-accordion

    

Radix uses a compound component pattern, which is common across many unstyled libraries. A parent component like Dialog.Root manages shared state, while child components like Dialog.Trigger and Dialog.Content access that state through React Context. This lets you compose components declaratively while keeping the API flexible:

      import { Dialog } from 'radix-ui';

<Dialog.Root>
  <Dialog.Trigger>Open</Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay className="dialog-overlay" />
    <Dialog.Content className="dialog-content">
      <Dialog.Title>Dialog Title</Dialog.Title>
      <Dialog.Close>Close</Dialog.Close>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

    

Because Context works regardless of nesting depth, you can insert your own wrapper elements or custom components between the compound parts without breaking the connection.

Radix primitives render with zero styling. You add all the CSS yourself:

      .dialog-overlay { /* overlay styles */ }
.dialog-content { /* content positioning and styles */ }

    

Components expose data attributes for styling different states, making it easy to handle open, closed, disabled, and checked states in pure CSS:

      .dialog-content[data-state="open"] { /* styles when open */ }
.dialog-content[data-state="closed"] { /* styles when closed */ }

    

If you're using Tailwind CSS, you can target these with the data-* variant:

      <Dialog.Overlay className="data-[state=open]:... data-[state=closed]:..." />

    

Radix is fully WAI-ARIA compliant with keyboard navigation, focus management, and screen reader support built in. Each component is independently versioned, so you can adopt them incrementally without a large rewrite. Years of production usage across thousands of applications means edge cases have been discovered and addressed.

However, Radix isn't without challenges. Despite its popularity, the library has a small maintainer team and some long-standing bugs that have remained open for years. This is part of what motivated the same creators to start a new project.

Base UI

Base UI represents the next evolution from the team behind Radix, Floating UI, and Material UI. It combines lessons learned from all three projects into a fresh codebase with modern patterns.

Base UI ships as a single tree-shakeable package:

      npm i @base-ui/react

    

Like Radix, Base UI primitives have no default styles. You bring your own CSS:

      import { Dialog } from '@base-ui/react/dialog';

<Dialog.Root>
  <Dialog.Trigger>Open</Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Backdrop className="dialog-overlay" />
    <Dialog.Popup className="dialog-content">
      <Dialog.Title>Dialog Title</Dialog.Title>
      <Dialog.Close>Close</Dialog.Close>
    </Dialog.Popup>
  </Dialog.Portal>
</Dialog.Root>

    

Data attributes work similarly for CSS-based styling, using [data-open] and [data-closed] instead of Radix's [data-state="open"]:

      .dialog-popup[data-open] { /* styles when open */ }
.dialog-popup[data-closed] { /* styles when closed */ }

    

Components also accept functions for className props that receive the component's current state. The attribute names match these state properties, so state.open corresponds to [data-open]:

      <Dialog.Popup className={(state) => (state.open ? 'dialog-open' : 'dialog-closed')} />

    

Components expose CSS variables for dynamic values like available height, making it easier to create responsive layouts:

      .dialog-popup { max-height: var(--available-height); }

    

Base UI also includes sophisticated patterns that aren't available in Radix. One example is detached triggers, which let you control a dialog from anywhere in your component tree:

      const dialog = Dialog.createHandle<{ text: string }>();

<Dialog.Trigger handle={dialog} payload={{ text: 'Button 1' }}>Open</Dialog.Trigger>
<Dialog.Root handle={dialog}>
  {({ payload }) => <Dialog.Popup>Opened by {payload?.text}</Dialog.Popup>}
</Dialog.Root>

    

Base UI reached v1 on December 11, 2025, bringing a stable API with simpler package structure and modern styling patterns.

Unstyled libraries give you full control, but sometimes you just want to build without defining every style yourself.

What is shadcn/ui?

This is where shadcn/ui comes in. If you haven't encountered it before, shadcn/ui is a unique approach to component libraries that has taken the React ecosystem by storm. With over 100k GitHub stars and adoption by companies like Vercel, it's become one of the most popular ways to build React applications.

As the docs put it: "This is not a component library. It is how you build your component library." Rather than installing a package from npm and importing components, shadcn/ui gives you the actual component code. When you run the CLI to add a component:

      npx shadcn@latest add dialog

    

It copies the component source code directly into your project. This is what shadcn calls open code.

If you've ever seen a project with a components/ui folder full of kebab-case files, chances are it's using shadcn/ui:

      components/
└── ui/
    ├── button.tsx
    ├── card.tsx
    ├── dialog.tsx
    ├── dropdown-menu.tsx
    ├── input.tsx
    ├── select.tsx
    └── ...


    

Under the hood, these components are built on the unstyled libraries we explored earlier. Until recently, shadcn/ui only supported Radix. Now you can choose between Radix and Base UI.

Usage

After adding a component, you import it from your own codebase. The components come pre-styled with Tailwind CSS:

      import { Button } from "@/components/ui/button"
import {
  Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog"

function EditProfileDialog() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="outline">Edit Profile</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Edit profile</DialogTitle>
        </DialogHeader>
        {/* Form content here */}
      </DialogContent>
    </Dialog>
  )
}

    

These components are ready to use: styled, accessible, and composable. But what makes shadcn/ui different is that you can see exactly how they're built. Here's a simplified version of the Button component:

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

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium ...",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground ...",
        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" },
  }
)

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

    

Notice how shadcn has structured this: the component uses class-variance-authority for type-safe variants, forwardRef for proper ref handling, and a cn utility for merging class names. These are established patterns that many developers would need to figure out on their own. shadcn/ui gives you well-architected code from the start.

Want a new variant? Add it. Need different padding? Change it. The code is yours.

Design Tokens and Theming

shadcn/ui uses CSS variables as design tokens, giving you a complete theming system out of the box. Your globals.css file includes semantic variables like --background, --foreground, --primary, and more:

      @import "tailwindcss";
@import "shadcn/tailwind.css";

@theme inline { /* Maps CSS variables to Tailwind classes like bg-primary */ }

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.60 0.13 163);
  --muted: oklch(0.97 0 0);
  --destructive: oklch(0.58 0.22 27);
  /* ... */
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --primary: oklch(0.70 0.15 162);
  /* ... */
}

    

Components reference these tokens through Tailwind classes like bg-primary and text-muted-foreground. To change your entire app's color scheme, you just update these CSS variables. Dark mode works automatically since the .dark class swaps in a different set of values.

Traditional component libraries have a common problem: customization. When you need to change something beyond what the API allows, you're stuck overriding styles or fighting specificity wars. shadcn/ui solves this by giving you the code directly. Your components don't break when a library updates, and LLMs can read and improve your component code since it lives in your project.

Ready to Build Something Different?

Now back to shadcn/create. Announced shortly after Base UI support, it lets you customize your setup before generating a single file:

  • Component library: Choose between Radix or Base UI. Every component was rebuilt for Base UI with the same abstractions, so you get full coverage of the same use cases.
  • Visual style: Five new styles to start from: Vega (classic shadcn/ui), Nova (compact layouts), Maia (soft and rounded), Lyra (boxy and sharp), and Mira (dense interfaces).
  • Icons: Pick from Lucide, Tabler Icons, or HugeIcons.
  • Theme: Select your base color, fonts, and border radius.

The tool supports Next.js, Vite, TanStack Start, and v0. Once you've made your choices, shadcn/create generates a project with all your preferences baked in. And when you add components later, they match your setup automatically. The CLI detects your config and applies the right transformations.

Conclusion

So, starting a new React project? Unstyled component libraries like Radix and Base UI handle the hard parts of building accessible UIs. shadcn/ui wraps them with pre-styled, customizable components that you actually own. And with shadcn/create, you can pick your component library, visual style, and theme before writing a single line of code. No more default "shadcn aesthetic" unless you want it.


Sources:

More certificates.dev articles

Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.

Looking for Certified Developers?

We can help you recruit Certified Developers for your organization or project. The team has helped many customers employ suitable resources from a pool of 100s of qualified Developers.

Let us help you get the resources you need.

Contact Us
Customer Testimonial for Hiring
like a breath of fresh air
Everett Owyoung
Everett Owyoung
Head of Talent for ThousandEyes
(a Cisco company)