Back to Blog
Building Beautiful UIs with shadcn/ui, Radix, and v0: A Complete Guide

Building Beautiful UIs with shadcn/ui, Radix, and v0: A Complete Guide

January 3, 2026
Stefan Mentović
shadcn-uiradixv0reacttailwind

Master modern React UI development with shadcn/ui components, Radix primitives, and AI-powered generation using v0. From setup to production-ready interfaces.

#Building Beautiful UIs with shadcn/ui, Radix, and v0: A Complete Guide

You need a dashboard. Not just any dashboard — one with accessible forms, interactive charts, responsive layouts, and dark mode support. Building all of this from scratch means weeks of work, countless edge cases, and inevitable accessibility gaps.

Or you could have it done in an afternoon.

shadcn/ui changed how developers think about component libraries. Instead of installing a package with fixed styles you fight against, you copy components directly into your project and own the code. Combine this with Radix UI primitives for accessibility, v0 for AI-powered generation, and you have a stack that produces production-quality interfaces faster than ever before.

#Understanding the Stack

Before diving into code, understand what each piece brings to the table.

#shadcn/ui

shadcn/ui is not a component library in the traditional sense — you don't install it as a dependency. Instead, it's a collection of beautifully designed, accessible components built on Radix UI primitives that you copy into your project. You own the code, customize freely, and never worry about breaking changes from upstream.

#Radix UI

Radix provides unstyled, accessible UI primitives. These handle the hard parts — keyboard navigation, focus management, ARIA attributes, and screen reader support — while you control the styling. shadcn/ui builds on Radix, so you get battle-tested accessibility without extra effort.

#v0 by Vercel

v0 is an AI-powered development platform that generates production-ready React components from natural language descriptions. It outputs shadcn/ui components with Tailwind CSS, meaning generated code integrates seamlessly with your existing stack.

#Project Setup

#Initialize shadcn/ui

Start with a Next.js project and initialize shadcn/ui:

npx create-next-app@latest my-app --typescript --tailwind --eslint
cd my-app
npx shadcn@latest init

During initialization, you'll configure:

  • Style: Choose between "Default" or "New York" (denser, more refined)
  • Base color: Zinc, Slate, Stone, Gray, or Neutral
  • CSS variables: Recommended for theming flexibility

This creates a components.json configuration file that controls how components are added:

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

#Add Components

Install components as needed:

npx shadcn@latest add button card input label
npx shadcn@latest add form        # Includes react-hook-form + zod
npx shadcn@latest add chart       # Includes recharts
npx shadcn@latest add dialog dropdown-menu tabs

Components are copied to @/components/ui/, giving you full control over the source code.

#The Utility Function

shadcn/ui includes a utility for merging Tailwind classes:

// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

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

This cn() function combines clsx for conditional classes with tailwind-merge to handle conflicting utilities intelligently.

#Theming with CSS Variables

shadcn/ui uses CSS variables for theming, following a background/foreground naming convention. Your globals.css defines the color palette:

@layer base {
	:root {
		--background: oklch(1 0 0);
		--foreground: oklch(0.145 0 0);
		--card: oklch(1 0 0);
		--card-foreground: oklch(0.145 0 0);
		--primary: oklch(0.205 0 0);
		--primary-foreground: oklch(0.985 0 0);
		--secondary: oklch(0.97 0 0);
		--secondary-foreground: oklch(0.205 0 0);
		--muted: oklch(0.97 0 0);
		--muted-foreground: oklch(0.556 0 0);
		--accent: oklch(0.97 0 0);
		--accent-foreground: oklch(0.205 0 0);
		--destructive: oklch(0.577 0.245 27.325);
		--destructive-foreground: oklch(0.985 0 0);
		--border: oklch(0.922 0 0);
		--input: oklch(0.922 0 0);
		--ring: oklch(0.708 0 0);
		--radius: 0.5rem;

		/* Chart colors */
		--chart-1: oklch(0.646 0.222 41.116);
		--chart-2: oklch(0.6 0.118 184.704);
		--chart-3: oklch(0.398 0.07 227.392);
		--chart-4: oklch(0.828 0.189 84.429);
		--chart-5: oklch(0.769 0.188 70.08);
	}

	.dark {
		--background: oklch(0.145 0 0);
		--foreground: oklch(0.985 0 0);
		--card: oklch(0.145 0 0);
		--card-foreground: oklch(0.985 0 0);
		--primary: oklch(0.985 0 0);
		--primary-foreground: oklch(0.205 0 0);
		/* ... dark mode variants */
	}
}

The OKLCH color space provides perceptually uniform colors — adjusting lightness produces predictable results across hues.

#Using Theme Colors

Reference variables through Tailwind utilities:

<div className='bg-background text-foreground'>
	<button className='bg-primary text-primary-foreground'>Primary Action</button>
	<p className='text-muted-foreground'>Secondary text</p>
</div>

#Form Validation with Zod and React Hook Form

shadcn/ui's form component integrates React Hook Form with Zod for type-safe validation. This combination provides compile-time safety, runtime validation, and excellent developer experience.

#Define Your Schema

// schemas/contact.schema.ts
import { z } from 'zod';

export const contactSchema = z.object({
	name: z.string().min(2, 'Name must be at least 2 characters').max(100, 'Name cannot exceed 100 characters'),
	email: z.string().email('Please enter a valid email address'),
	company: z.string().optional(),
	message: z
		.string()
		.min(10, 'Message must be at least 10 characters')
		.max(1000, 'Message cannot exceed 1000 characters'),
});

export type ContactFormData = z.infer<typeof contactSchema>;

#Build the Form Component

'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { contactSchema, type ContactFormData } from '@/schemas/contact.schema';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';

export function ContactForm() {
	const form = useForm<ContactFormData>({
		resolver: zodResolver(contactSchema),
		defaultValues: {
			name: '',
			email: '',
			company: '',
			message: '',
		},
	});

	async function onSubmit(data: ContactFormData) {
		// Handle form submission
		console.log(data);
	}

	return (
		<Form {...form}>
			<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
				<FormField
					control={form.control}
					name='name'
					render={({ field }) => (
						<FormItem>
							<FormLabel>Name</FormLabel>
							<FormControl>
								<Input placeholder='John Doe' {...field} />
							</FormControl>
							<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='company'
					render={({ field }) => (
						<FormItem>
							<FormLabel>Company (optional)</FormLabel>
							<FormControl>
								<Input placeholder='Acme Inc.' {...field} />
							</FormControl>
							<FormMessage />
						</FormItem>
					)}
				/>

				<FormField
					control={form.control}
					name='message'
					render={({ field }) => (
						<FormItem>
							<FormLabel>Message</FormLabel>
							<FormControl>
								<Textarea
									placeholder='Tell us about your project...'
									className='min-h-[120px]'
									{...field}
								/>
							</FormControl>
							<FormMessage />
						</FormItem>
					)}
				/>

				<Button type='submit' disabled={form.formState.isSubmitting}>
					{form.formState.isSubmitting ? 'Sending...' : 'Send Message'}
				</Button>
			</form>
		</Form>
	);
}

#How the Form Components Work

The shadcn/ui form components wrap React Hook Form Controller with automatic error handling:

  • FormField: Wraps Controller and provides field context
  • FormItem: Container that generates unique IDs for accessibility
  • FormLabel: Connects to the input via htmlFor and shows error states
  • FormControl: Uses Radix's Slot to pass props to the input
  • FormMessage: Displays validation errors from the field state

#Toast Notifications and Error Handling

Form submissions need feedback. Users should know when their action succeeded, failed, or requires attention. shadcn/ui recommends Sonner for toast notifications — a lightweight, accessible toast library that integrates seamlessly with the stack.

#Setting Up Sonner

Install the sonner component:

npx shadcn@latest add sonner

Add the Toaster component to your root layout:

// app/layout.tsx
import { Toaster } from '@/components/ui/sonner';

export default function RootLayout({ children }: { children: React.ReactNode }) {
	return (
		<html lang='en'>
			<body>
				{children}
				<Toaster richColors position='top-right' />
			</body>
		</html>
	);
}

#Using Toasts in Forms

Update your form submission to show feedback:

'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { contactSchema, type ContactFormData } from '@/schemas/contact.schema';

export function ContactForm() {
	const form = useForm<ContactFormData>({
		resolver: zodResolver(contactSchema),
		defaultValues: {
			name: '',
			email: '',
			message: '',
		},
	});

	async function onSubmit(data: ContactFormData) {
		try {
			const response = await fetch('/api/contact', {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify(data),
			});

			if (!response.ok) {
				throw new Error('Failed to send message');
			}

			toast.success('Message sent!', {
				description: "We'll get back to you within 24 hours.",
			});

			form.reset();
		} catch (error) {
			toast.error('Something went wrong', {
				description: 'Please try again or contact us directly.',
			});
		}
	}

	return <Form {...form}>{/* Form fields */}</Form>;
}

#Toast Variants

Sonner provides semantic variants for different message types:

// Success - green indicator
toast.success('Changes saved successfully');

// Error - red indicator
toast.error('Failed to save changes');

// Warning - yellow indicator
toast.warning('Your session will expire soon');

// Info - blue indicator
toast.info('New features available');

// Loading - with spinner, can be updated
const toastId = toast.loading('Saving...');
// Later, update the same toast
toast.success('Saved!', { id: toastId });

// With action button
toast.error('Failed to delete', {
	action: {
		label: 'Retry',
		onClick: () => handleRetry(),
	},
});

// With description
toast.success('Project created', {
	description: 'Your new project is ready to use.',
});

#Handling API Errors

For consistent error handling across your application, create a utility function:

// lib/handle-error.ts
import { toast } from 'sonner';

interface ApiError {
	message: string;
	code?: string;
	field?: string;
}

export function handleApiError(error: unknown, fallbackMessage = 'Something went wrong') {
	// Handle known API error format
	if (error && typeof error === 'object' && 'message' in error) {
		const apiError = error as ApiError;
		toast.error(apiError.message, {
			description: apiError.code ? `Error code: ${apiError.code}` : undefined,
		});
		return;
	}

	// Handle network errors
	if (error instanceof TypeError && error.message === 'Failed to fetch') {
		toast.error('Connection failed', {
			description: 'Please check your internet connection.',
		});
		return;
	}

	// Fallback for unknown errors
	toast.error(fallbackMessage);
}

#Combining with Form Field Errors

Form validation errors appear inline via FormMessage, while submission errors use toasts. This separation keeps the UI clear:

  • Field-level errors (validation): Displayed next to the input using FormMessage
  • Form-level errors (API failures): Displayed as toast notifications
  • Success feedback: Displayed as toast notifications

This pattern ensures users always know what went wrong and where to fix it.

#Building Charts with Recharts

shadcn/ui's chart component wraps Recharts with theme-aware styling and consistent tooltips.

#Chart Configuration

Define a configuration object that maps data keys to labels and colors:

import { type ChartConfig } from '@/components/ui/chart';

const chartConfig: ChartConfig = {
	revenue: {
		label: 'Revenue',
		color: 'var(--chart-1)',
	},
	expenses: {
		label: 'Expenses',
		color: 'var(--chart-2)',
	},
	profit: {
		label: 'Profit',
		color: 'var(--chart-3)',
	},
};

#Area Chart Example

'use client';

import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';

const data = [
	{ month: 'Jan', revenue: 4000, expenses: 2400, profit: 1600 },
	{ month: 'Feb', revenue: 3000, expenses: 1398, profit: 1602 },
	{ month: 'Mar', revenue: 5000, expenses: 3800, profit: 1200 },
	{ month: 'Apr', revenue: 4780, expenses: 3908, profit: 872 },
	{ month: 'May', revenue: 5890, expenses: 4800, profit: 1090 },
	{ month: 'Jun', revenue: 6390, expenses: 3800, profit: 2590 },
];

export function RevenueChart() {
	return (
		<ChartContainer config={chartConfig} className='min-h-[300px] w-full'>
			<AreaChart data={data} accessibilityLayer>
				<CartesianGrid strokeDasharray='3 3' />
				<XAxis dataKey='month' tickLine={false} axisLine={false} />
				<YAxis tickLine={false} axisLine={false} />
				<ChartTooltip content={<ChartTooltipContent />} />
				<Area
					type='monotone'
					dataKey='revenue'
					stackId='1'
					stroke='var(--color-revenue)'
					fill='var(--color-revenue)'
					fillOpacity={0.6}
				/>
				<Area
					type='monotone'
					dataKey='expenses'
					stackId='2'
					stroke='var(--color-expenses)'
					fill='var(--color-expenses)'
					fillOpacity={0.6}
				/>
			</AreaChart>
		</ChartContainer>
	);
}

The ChartContainer component:

  • Injects CSS variables for colors based on your config
  • Provides responsive sizing with ResponsiveContainer
  • Applies consistent styling to Recharts elements
  • Handles light/dark mode automatically

#Using v0 for AI-Powered UI Generation

v0 generates production-ready shadcn/ui components from natural language descriptions. Instead of building components from scratch, describe what you need and iterate on the output.

#Effective v0 Prompts

The quality of generated UI depends on your prompt. Be specific about:

  • Layout structure: Grid, flex, sidebar, split view
  • Components needed: Cards, tables, forms, charts
  • Interactive elements: Buttons, dropdowns, modals
  • Visual style: Minimal, data-dense, spacious

Example prompt for a dashboard:

Create a SaaS dashboard with a sidebar navigation, top header with user avatar and notifications dropdown, and a main content area. The main area should have a 3-column stat card row showing revenue, users, and conversion rate with trend indicators. Below that, add a line chart for monthly revenue and a recent activity feed in a two-column layout. Use shadcn/ui components with the New York style. Include dark mode support.

Example prompt for a settings page:

Build a user settings page with tabs for Profile, Notifications, and Security. The Profile tab should have a form with avatar upload, name, email, and bio fields using shadcn/ui form components with zod validation. Include a save button with loading state. The Notifications tab should have toggle switches for email preferences. The Security tab should have password change form and two-factor authentication setup.

#v0 MCP Integration

v0 offers an MCP (Model Context Protocol) server that integrates directly with your development environment. This allows AI agents like Claude Code to generate UI components within your IDE context.

#Getting Your v0 API Key

  1. Go to v0.dev and sign in with your Vercel account
  2. Navigate to your account settings or the API section
  3. Generate a new API key and copy it securely

#Installing the v0 MCP Server

Add the v0 MCP server to your Claude Code configuration:

claude mcp add v0 -- npx -y @anthropic-ai/v0-mcp@latest --v0-api-key YOUR_V0_API_KEY

Alternatively, add it manually to your .claude/mcp.json configuration:

{
	"mcpServers": {
		"v0": {
			"command": "npx",
			"args": ["-y", "@anthropic-ai/v0-mcp@latest"],
			"env": {
				"V0_API_KEY": "your-v0-api-key-here"
			}
		}
	}
}

Security Note: Never commit your API key to version control. Use environment variables or a .env file that's excluded from git.

Once configured, the v0 MCP server enables:

  • Generating components that match your existing codebase style
  • Iterating on designs without leaving your editor
  • Automatic code insertion into your project
  • AI-assisted UI refinement based on your feedback

#Iterating on Generated Code

v0 output is a starting point. After generation:

  1. Review the structure — Does the layout match your design system?
  2. Check accessibility — Are labels, ARIA attributes, and focus states correct?
  3. Adjust styling — Modify spacing, colors, and typography to match your brand
  4. Add business logic — Wire up event handlers, API calls, and state management

#Component Patterns

#Button Variants

shadcn/ui buttons use Class Variance Authority (CVA) for variant management:

import { Button } from '@/components/ui/button';

export function ActionButtons() {
	return (
		<div className='flex gap-4'>
			<Button>Default</Button>
			<Button variant='secondary'>Secondary</Button>
			<Button variant='destructive'>Delete</Button>
			<Button variant='outline'>Outline</Button>
			<Button variant='ghost'>Ghost</Button>
			<Button variant='link'>Link</Button>
			<Button size='sm'>Small</Button>
			<Button size='lg'>Large</Button>
			<Button size='icon'>
				<PlusIcon className='h-4 w-4' />
			</Button>
		</div>
	);
}

#Card Composition

Cards follow a compositional pattern:

import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card';

export function MetricCard({ title, value, change, description }) {
	return (
		<Card>
			<CardHeader>
				<CardTitle className='text-sm font-medium text-muted-foreground'>{title}</CardTitle>
			</CardHeader>
			<CardContent>
				<div className='text-2xl font-bold'>{value}</div>
				<p className='text-xs text-muted-foreground'>
					<span className={change >= 0 ? 'text-green-600' : 'text-red-600'}>
						{change >= 0 ? '+' : ''}
						{change}%
					</span>{' '}
					{description}
				</p>
			</CardContent>
		</Card>
	);
}

#Dialog with Form

Combine dialog and form components for modal forms:

'use client';

import { useState } from 'react';
import {
	Dialog,
	DialogContent,
	DialogDescription,
	DialogHeader,
	DialogTitle,
	DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';

export function CreateProjectDialog() {
	const [open, setOpen] = useState(false);

	function handleSubmit(data: ProjectFormData) {
		// Create project
		setOpen(false);
	}

	return (
		<Dialog open={open} onOpenChange={setOpen}>
			<DialogTrigger asChild>
				<Button>New Project</Button>
			</DialogTrigger>
			<DialogContent className='sm:max-w-[425px]'>
				<DialogHeader>
					<DialogTitle>Create Project</DialogTitle>
					<DialogDescription>Add a new project to your workspace.</DialogDescription>
				</DialogHeader>
				<ProjectForm onSubmit={handleSubmit} />
			</DialogContent>
		</Dialog>
	);
}

#Responsive Patterns

#Mobile-First Layout

export function DashboardLayout({ children }) {
	return (
		<div className='flex min-h-screen flex-col lg:flex-row'>
			{/* Sidebar - hidden on mobile, visible on large screens */}
			<aside className='hidden w-64 border-r bg-card lg:block'>
				<Navigation />
			</aside>

			{/* Mobile header with menu toggle */}
			<header className='flex items-center justify-between border-b p-4 lg:hidden'>
				<Logo />
				<MobileMenu />
			</header>

			{/* Main content */}
			<main className='flex-1 p-4 lg:p-8'>{children}</main>
		</div>
	);
}

#Responsive Grid

export function StatCards() {
	return (
		<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-4'>
			<MetricCard title='Revenue' value='$45,231' change={12.5} />
			<MetricCard title='Users' value='2,350' change={8.2} />
			<MetricCard title='Orders' value='1,234' change={-2.4} />
			<MetricCard title='Conversion' value='3.2%' change={1.1} />
		</div>
	);
}

#Dark Mode Implementation

shadcn/ui supports dark mode through the .dark class. Combine with next-themes for seamless switching:

// app/providers.tsx
'use client';

import { ThemeProvider } from 'next-themes';

export function Providers({ children }) {
	return (
		<ThemeProvider attribute='class' defaultTheme='system' enableSystem>
			{children}
		</ThemeProvider>
	);
}
// components/theme-toggle.tsx
'use client';

import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
import { Moon, Sun } from 'lucide-react';

export function ThemeToggle() {
	const { theme, setTheme } = useTheme();

	return (
		<Button variant='ghost' size='icon' onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
			<Sun className='h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0' />
			<Moon className='absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100' />
			<span className='sr-only'>Toggle theme</span>
		</Button>
	);
}

#Key Takeaways

  • shadcn/ui gives you ownership of component code — copy, customize, and maintain without dependency conflicts
  • Radix UI primitives handle accessibility so you can focus on design
  • CSS variables with OKLCH provide flexible theming with perceptually uniform colors
  • Zod + React Hook Form deliver type-safe validation with excellent developer experience
  • Recharts integration creates theme-aware charts with consistent styling
  • v0 accelerates development by generating production-ready components from descriptions
  • Compositional patterns make components flexible and maintainable

The combination of owned code, accessible primitives, and AI-powered generation creates a development experience where beautiful, accessible interfaces are the default — not the exception.

Ready to build beautiful interfaces? Check out our development services or get in touch to discuss your project.

Enjoyed this article? Stay updated: