
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
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.
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:
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 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 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.
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.
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.
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.
Now back to shadcn/create. Announced shortly after Base UI support, it lets you customize your setup before generating a single file:
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.
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:
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.

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
Dec 24, 2025

Understanding try...catch in JavaScript: How to Handle Errors Properly
Learn how JavaScript errors work and how to prevent your app from crashing using try...catch. Includes simple examples, async caveats, custom errors, JSON parsing, and best practices for writing robust, user-friendly code.
Martin Ferret
Dec 21, 2025

Dynamic component creation with Angular
The article explains how to create and control Angular components dynamically at runtime, rather than relying on static components that are toggled visible/hidden in the DOM.
Alain Chautard
Dec 18, 2025
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.
