Next.js course ยท Module 3: Styling and Visual Optimization
Shadcn/UI - Components for Next.js 16
In this lesson9
In Metropolis Quantum, advanced research laboratories developed modular, prefabricated building elements that can be easily combined and customized as needed. Thanks to this, architects can quickly create buildings with a consistent aesthetic while maintaining flexibility in designing unique spaces. A similar role in the world of frontend development is played by the Shadcn/UI library - a collection of carefully designed and accessible user interface components that can be easily integrated and customized to meet project needs.
What is Shadcn/UI?
Shadcn/UI is a collection of UI components built with Radix UI, Tailwind CSS, and React in mind. Unlike traditional component libraries, Shadcn/UI is not installed as an npm dependency. Instead, components are copied directly into your project, giving you full control over the code and enabling easy modifications as needed.
Key Features of Shadcn/UI:
- No external library dependency - components are part of your code
- Full control over styles - easy modification of component appearance
- Built on Radix UI - solid, accessible primitives for interactive components
- Styled with Tailwind CSS - consistent and easy to customize design system
- Dark mode by default - built-in support for light and dark themes
- Strict TypeScript typing - full type support
- Modularity - install only the components you need
- Responsiveness - components adapt to different screen sizes
Installing and Configuring Shadcn/UI in Next.js 16
To get started with Shadcn/UI in a Next.js 16 project, you need to follow a few steps:
Step 1: Installing the Shadcn/UI CLI
npx shadcn@latest initAfter running this command, the CLI detects the Next.js project, saves its settings in the components.json file and asks a few questions. Older versions of the tool, published under the shadcn-ui package name, asked, among other things, about:
- Style choice - usually "Default" is a good choice
- Components path choice - typically "src/components/ui"
- Color scheme choice - you can choose default or custom
- Do you want to enable dark mode? - usually "yes"
- Component prefix choice - typically no prefix
- Globals.css file path - typically "app/globals.css"
- Place CSS variables in globals.css? - usually "yes"
Step 2: Checking the Tailwind CSS Configuration
In a project with Tailwind CSS 4, which create-next-app installs in Next.js 16, the CLI saves colors and radii as CSS variables in globals.css (in an @theme inline block) and does not create a tailwind.config.js file. In projects with Tailwind CSS 3, the CLI updates exactly that file - then it is worth checking its contents:
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}Step 3: Checking the CSS Variables
The CLI will also add CSS variables to the globals.css file:
@tailwind base;
@tailwind components;
@tailwind utilities;
@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%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}Step 4: Adding Shadcn/UI Components
Now you can install selected components using the CLI:
npx shadcn@latest add buttonThis command will add the Button component to the components/ui directory:
// 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 }The CLI will also automatically add a lib/utils.ts file containing utility functions:
// lib/utils.ts
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}Main Shadcn/UI Components
Shadcn/UI offers many components, from simple buttons to advanced interaction components. Here are some of the most important ones:
Basic Components
- Button - buttons with different variants
- Input - input fields
- Checkbox - checkbox fields
- RadioGroup - groups of radio options
- Select - dropdown select lists
- Textarea - multiline text fields
- Label - labels for form controls
Layout Components
- Card - cards for grouping content
- AspectRatio - maintaining element proportions
- ScrollArea - scrollable areas
- Separator - dividing lines
- Sheet - slide-out side panels
Navigation Components
- Tabs - tabs
- Navigation Menu - navigation menus
- Breadcrumb - navigation breadcrumbs
- Pagination - page navigation
Dialog Components
- Dialog - dialog windows
- Alert Dialog - alerts requiring confirmation
- Drawer - slide-out panels
- Popover - popup bubbles
- Toast - notifications
Data Components
- Table - tables
- Accordion - collapsible sections
- Calendar - calendars
- Carousel - carousels
Form Components
- Form - comprehensive form solution
- Combobox - advanced select fields with search
- Slider - sliders
- Switch - toggles
Usage Examples of Shadcn/UI in a Next.js 16 Project
Example 1: Login Form
// app/login/page.tsx
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as 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 { toast } from "@/components/ui/use-toast"
const formSchema = z.object({
email: z.string().email({
message: "Please enter a valid email address.",
}),
password: z.string().min(8, {
message: "Password must contain at least 8 characters.",
}),
})
export default function LoginPage() {
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
},
})
async function onSubmit(values: z.infer<typeof formSchema>) {
setIsLoading(true)
try {
// In a real application, we would call the login API here
console.log(values)
// Simulating delay
await new Promise(resolve => setTimeout(resolve, 1000))
toast({
title: "Logged in successfully",
description: "Redirecting you to the main dashboard",
})
router.push("/dashboard")
} catch (error) {
toast({
variant: "destructive",
title: "An error occurred",
description: "Unable to log in. Check your credentials and try again.",
})
} finally {
setIsLoading(false)
}
}
return (
<div className="mx-auto max-w-md space-y-6 p-6">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">Login</h1>
<p className="text-gray-500 dark:text-gray-400">
Enter your credentials to log in to the Metropolis Quantum panel
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="email@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Logging in..." : "Log in"}
</Button>
</form>
</Form>
<div className="text-center text-sm">
<a href="/forgot-password" className="text-primary hover:underline">
Forgot your password?
</a>
</div>
</div>
)
}Example 2: Panel with Tabs
// app/dashboard/page.tsx
"use client"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
export default function DashboardPage() {
return (
<div className="container mx-auto py-10">
<h1 className="text-3xl font-bold mb-6">Dashboard Metropolis Quantum</h1>
<Tabs defaultValue="overview" className="space-y-4">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="analytics">Analytics</TabsTrigger>
<TabsTrigger value="reports">Reports</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 text-muted-foreground"
>
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">$45,231.89</div>
<p className="text-xs text-muted-foreground">
+20.1% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Subscriptions</CardTitle>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 text-muted-foreground"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+2350</div>
<p className="text-xs text-muted-foreground">
+180.1% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Sales</CardTitle>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 text-muted-foreground"
>
<rect width="20" height="14" x="2" y="5" rx="2" />
<path d="M2 10h20" />
</svg>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+12,234</div>
<p className="text-xs text-muted-foreground">
+19% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Users</CardTitle>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 text-muted-foreground"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+573</div>
<p className="text-xs text-muted-foreground">
+201 from last month
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="col-span-4">
<CardHeader>
<CardTitle>Overview</CardTitle>
</CardHeader>
<CardContent className="pl-2">
{/* A chart could go here */}
<div className="h-[200px] bg-muted rounded-md flex items-center justify-center">
Revenue Chart
</div>
</CardContent>
</Card>
<Card className="col-span-3">
<CardHeader>
<CardTitle>Recent Transactions</CardTitle>
<CardDescription>
24 transactions completed this month.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-8">
{/* Example transaction */}
<div className="flex items-center">
<div className="mr-4 rounded-full bg-primary/10 p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="h-4 w-4 text-primary"
>
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
</div>
<div className="space-y-1">
<p className="text-sm font-medium">Quantum Plan (Annual)</p>
<p className="text-xs text-muted-foreground">April 14, 2024</p>
</div>
<div className="ml-auto font-medium">+$4,999.00</div>
</div>
{/* More transactions */}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="analytics" className="space-y-4">
<Card className="col-span-4">
<CardHeader>
<CardTitle>Analytics</CardTitle>
<CardDescription>
Detailed traffic and conversion analysis.
</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px] bg-muted rounded-md flex items-center justify-center">
Analytics Data
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="reports" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Reports</CardTitle>
<CardDescription>
Browse and generate activity reports.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Monthly Report (April 2024)</p>
<p className="text-xs text-muted-foreground">Created May 1, 2024</p>
</div>
<Button variant="outline" size="sm">Download</Button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Quarterly Report (Q1 2024)</p>
<p className="text-xs text-muted-foreground">Created April 5, 2024</p>
</div>
<Button variant="outline" size="sm">Download</Button>
</div>
</div>
</CardContent>
<CardFooter>
<Button>Generate New Report</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="settings" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Settings</CardTitle>
<CardDescription>
Manage your account settings.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Example settings */}
<div className="space-y-1">
<p className="text-sm font-medium">Email Notifications</p>
<p className="text-xs text-muted-foreground">Receive notifications about important updates.</p>
</div>
{/* More settings */}
</CardContent>
<CardFooter>
<Button>Save Changes</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
</div>
)
}Example 3: Dialog with Form
// components/quantum-project-dialog.tsx
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { toast } from "@/components/ui/use-toast"
export function QuantumProjectDialog() {
const [open, setOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [formData, setFormData] = useState({
name: "",
description: "",
category: "",
priority: "",
})
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
// Simulating data saving
await new Promise(resolve => setTimeout(resolve, 1000))
toast({
title: "Project created",
description: `Project "${formData.name}" has been successfully created.`,
})
setIsLoading(false)
setOpen(false)
// Reset the form
setFormData({
name: "",
description: "",
category: "",
priority: "",
})
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="default">New Project</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Create New Project</DialogTitle>
<DialogDescription>
Enter project details. Click save when you are done.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Project name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
placeholder="Project name"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleChange("description", e.target.value)}
placeholder="Describe your project"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="category">Category</Label>
<Select
value={formData.category}
onValueChange={(value) => handleChange("category", value)}
required
>
<SelectTrigger id="category">
<SelectValue placeholder="Choose a category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="development">Development</SelectItem>
<SelectItem value="research">Research</SelectItem>
<SelectItem value="infrastructure">Infrastructure</SelectItem>
<SelectItem value="marketing">Marketing</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="priority">Priority</Label>
<Select
value={formData.priority}
onValueChange={(value) => handleChange("priority", value)}
required
>
<SelectTrigger id="priority">
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? "Saving..." : "Save project"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}Customizing Shadcn/UI Components
One of the greatest advantages of Shadcn/UI is the ease of customizing components. Since the components are part of your code, you can modify them as needed.
Customizing Styles
You can modify the appearance of components in several ways:
- Changing CSS variables - update variable values in
globals.css
:root {
--primary: 240 5.9% 10%; /* Changing the primary color */
--radius: 0.75rem; /* Changing corner rounding */
}- Modifying component definition - edit the component file directly
// components/ui/button.tsx
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
{
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 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: "underline-offset-4 hover:underline text-primary",
// Adding a new variant
gradient: "bg-gradient-to-r from-blue-500 to-purple-500 text-white hover:opacity-90",
},
// Other definitions
},
}
)
// Update the interface to include the new variant
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}- Extending components - create your own components based on Shadcn/UI
// components/quantum/quantum-card.tsx
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { cn } from "@/lib/utils"
interface QuantumCardProps {
className?: string
children: React.ReactNode
gradient?: boolean
hoverable?: boolean
}
export function QuantumCard({
className,
children,
gradient = false,
hoverable = false,
}: QuantumCardProps) {
return (
<Card
className={cn(
"overflow-hidden",
hoverable && "transition-transform hover:-translate-y-1 hover:shadow-lg",
gradient && "bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-950 dark:to-indigo-950",
className
)}
>
{children}
</Card>
)
}
// Export remaining components
export { CardContent, CardFooter, CardHeader }Customizing Functionality
You can also extend the functionality of components:
// components/quantum/quantum-button.tsx
import { forwardRef } from "react"
import { Button, ButtonProps } from "@/components/ui/button"
import { Loader2 } from "lucide-react"
interface QuantumButtonProps extends ButtonProps {
isLoading?: boolean
loadingText?: string
icon?: React.ReactNode
iconPosition?: "left" | "right"
}
export const QuantumButton = forwardRef<HTMLButtonElement, QuantumButtonProps>(
({
children,
isLoading,
loadingText,
icon,
iconPosition = "left",
...props
}, ref) => {
return (
<Button
ref={ref}
disabled={isLoading || props.disabled}
{...props}
>
{isLoading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{!isLoading && icon && iconPosition === "left" && (
<span className="mr-2">{icon}</span>
)}
{isLoading && loadingText ? loadingText : children}
{!isLoading && icon && iconPosition === "right" && (
<span className="ml-2">{icon}</span>
)}
</Button>
)
}
)
QuantumButton.displayName = "QuantumButton"Creating Themes and Design Systems
With Shadcn/UI, you can easily create and manage your own design systems:
Defining Your Own Theme
- First, define CSS variables for different themes:
/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
/* Default theme (light) */
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/* Colors for the Quantum brand */
--quantum-blue: 210 100% 50%;
--quantum-purple: 270 100% 60%;
--quantum-teal: 180 100% 40%;
/* Other variables */
/* ... */
}
/* Dark theme */
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* ... */
}
/* Neon theme */
.theme-neon {
--primary: 120 100% 50%;
--secondary: 300 100% 50%;
--accent: 60 100% 50%;
/* ... */
}
/* Vintage theme */
.theme-vintage {
--primary: 30 80% 60%;
--secondary: 20 60% 40%;
--accent: 40 70% 50%;
/* ... */
}
}- Next, create a context for theme switching:
// components/theme-provider.tsx
"use client"
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "light" | "dark" | "neon" | "vintage" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "quantum-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
// Remove previous theme classes
root.classList.remove("light", "dark", "theme-neon", "theme-vintage")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
if (theme === "neon") {
root.classList.add("dark", "theme-neon")
return
}
if (theme === "vintage") {
root.classList.add("light", "theme-vintage")
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}- Use the theme provider in your layout:
// app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider"
import "./globals.css"
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="pl" suppressHydrationWarning>
<body>
<ThemeProvider defaultTheme="system" storageKey="quantum-theme">
{children}
</ThemeProvider>
</body>
</html>
)
}Theme Switcher Component
// components/theme-switcher.tsx
"use client"
import { Moon, Sun, Sparkles, Clock } from "lucide-react"
import { useTheme } from "@/components/theme-provider"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ThemeSwitcher() {
const { theme, setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 h-4 w-4" />
<span>Light</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 h-4 w-4" />
<span>Dark</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("neon")}>
<Sparkles className="mr-2 h-4 w-4" />
<span>Neon</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("vintage")}>
<Clock className="mr-2 h-4 w-4" />
<span>Vintage</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<span>System</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}Best Practices for Working with Shadcn/UI
1. Component Organization
Maintain a clear directory structure:
components/
โโโ ui/ # Basic Shadcn/UI components
โ โโโ button.tsx
โ โโโ card.tsx
โ โโโ
โโโ quantum/ # Your own business components
โ โโโ quantum-button.tsx
โ โโโ quantum-card.tsx
โ โโโ
โโโ blocks/ # Complex interface blocks
โโโ dashboard-stats.tsx
โโโ user-profile-form.tsx
โโโ2. Consistent Naming
Maintain consistent naming conventions for your own components:
Button- the base Shadcn/UI componentQuantumButton- custom extension of a base componentUserProfileForm- complex business component
3. Using Component Generators
Use the Shadcn/UI CLI to generate new components:
npx shadcn@latest add alert4. Optimizing Reusability
For frequently used combinations, create your own higher-level components:
// components/quantum/quantum-section.tsx
import { cn } from "@/lib/utils"
interface QuantumSectionProps {
title: string
description?: string
children: React.ReactNode
className?: string
}
export function QuantumSection({
title,
description,
children,
className,
}: QuantumSectionProps) {
return (
<section className={cn("space-y-4", className)}>
<div className="space-y-1">
<h2 className="text-2xl font-bold">{title}</h2>
{description && (
<p className="text-muted-foreground">{description}</p>
)}
</div>
<div>{children}</div>
</section>
)
}5. Theme Management
Use CSS variables to manage themes:
// lib/themes.ts
export const themes = {
default: {
primary: "222.2 47.4% 11.2%",
// ...other colors
},
quantum: {
primary: "240 100% 50%",
// ...other colors
},
// ...other themes
}
export function setTheme(theme: keyof typeof themes) {
const root = document.documentElement
Object.entries(themes[theme]).forEach(([key, value]) => {
root.style.setProperty(`--${key}`, value)
})
}Integration with Other Tools
1. Integration with Form Libraries
Shadcn/UI integrates well with libraries such as React Hook Form and Zod:
npx shadcn@latest add form// components/signup-form.tsx
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as 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"
const formSchema = z.object({
username: z.string().min(2).max(50),
email: z.string().email(),
password: z.string().min(8),
})
export function SignupForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
email: "",
password: "",
},
})
function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Enter your username" {...field} />
</FormControl>
<FormDescription>
The name that will be displayed on your profile.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Other form fields */}
<Button type="submit">Sign Up</Button>
</form>
</Form>
)
}2. Integration with Animation Libraries
Shadcn/UI can be easily integrated with animation libraries:
// components/animated-card.tsx
"use client"
import { motion } from "framer-motion"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface AnimatedCardProps {
title: string
children: React.ReactNode
}
export function AnimatedCard({ title, children }: AnimatedCardProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
</motion.div>
)
}3. Integration with State Management
Shadcn/UI works well with state management libraries:
// store/theme-store.ts
import { create } from 'zustand'
type Theme = 'light' | 'dark' | 'system'
interface ThemeState {
theme: Theme
setTheme: (theme: Theme) => void
}
export const useThemeStore = create<ThemeState>((set) => ({
theme: 'system',
setTheme: (theme) => set({ theme }),
}))
// components/theme-toggle.tsx
"use client"
import { Moon, Sun } from "lucide-react"
import { useThemeStore } from "@/store/theme-store"
import { Button } from "@/components/ui/button"
import { useEffect } from "react"
export function ThemeToggle() {
const { theme, setTheme } = useThemeStore()
useEffect(() => {
const root = document.documentElement
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches ? "dark" : "light"
root.classList.remove("light", "dark")
root.classList.add(systemTheme)
} else {
root.classList.remove("light", "dark")
root.classList.add(theme)
}
}, [theme])
return (
<Button
variant="outline"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
)
}Summary
Shadcn/UI is a powerful tool for creating beautiful, accessible, and consistent user interfaces in Next.js 16. Instead of being a black box that is hard to customize, Shadcn/UI provides high-quality, modifiable components that become part of your code.
The most important advantages of Shadcn/UI:
- Control - components are part of your code, so you have full control over them
- Modularity - you download only the components you need
- Styling - easy customization of appearance with Tailwind CSS
- Accessibility - components built on Radix UI that ensure a high level of accessibility
- Built-in dark mode - support for light and dark themes by default
- TypeScript - full type support
Unlike traditional UI libraries that may limit your creativity and make it difficult to customize to unique project needs, Shadcn/UI provides solid foundations on which you can build your own unique user experiences - just like the modular building system in Metropolis Quantum allows architects to create unique buildings while maintaining the consistent aesthetic of the city.
In the next chapter, we will look at the Image component and image optimization in Next.js 16, which will allow us to further improve the performance and user experience of our applications.
Code for this lesson: app/page.tsx
// Shadcn/UI Components Demo - Next.js 16
'use client';
import { useState } from 'react';
// Simplified Shadcn/UI-style components for demonstration
function Button({ variant = 'default', size = 'default', children, onClick, className = '' }) {
const baseStyles = 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2';
const variants = {
default: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
destructive: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
outline: 'border-2 border-gray-300 bg-transparent hover:bg-gray-100 focus:ring-gray-500',
ghost: 'hover:bg-gray-100 focus:ring-gray-500',
};
const sizes = {
default: 'h-10 px-4 py-2',
sm: 'h-8 px-3 text-sm',
lg: 'h-12 px-8 text-lg',
icon: 'h-10 w-10',
};
return (
<button
onClick={onClick}
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
>
{children}
</button>
);
}
function Card({ title, description, children }) {
return (
<div className="rounded-lg border border-gray-200 bg-white shadow-sm">
<div className="p-6">
{title && <h3 className="text-lg font-semibold mb-1">{title}</h3>}
{description && <p className="text-sm text-gray-500 mb-4">{description}</p>}
{children}
</div>
</div>
);
}
function Badge({ variant = 'default', children }) {
const variants = {
default: 'bg-blue-100 text-blue-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
destructive: 'bg-red-100 text-red-800',
};
return (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${variants[variant]}`}>
{children}
</span>
);
}
export default function ShadcnDemo() {
const [count, setCount] = useState(0);
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-2">Shadcn/UI Components</h1>
<p className="text-gray-500 mb-8">Modular, customizable UI components for Next.js 16</p>
{/* Button Variants */}
<Card title="Button Variants" description="Different button styles for various actions">
<div className="flex flex-wrap gap-3">
<Button>Default</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
</div>
<div className="flex flex-wrap gap-3 mt-4">
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
</div>
</Card>
{/* Badges */}
<Card title="Badges" description="Status indicators and labels" className="mt-6">
<div className="flex flex-wrap gap-2">
<Badge>Default</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="destructive">Error</Badge>
</div>
</Card>
{/* Interactive Counter */}
<Card title="Interactive Example" description="Components working together" className="mt-6">
<div className="flex items-center gap-4">
<Button variant="outline" onClick={() => setCount(c => c - 1)}>-</Button>
<span className="text-2xl font-bold w-12 text-center">{count}</span>
<Button variant="outline" onClick={() => setCount(c => c + 1)}>+</Button>
<Button variant="destructive" size="sm" onClick={() => setCount(0)}>
Reset
</Button>
</div>
</Card>
</div>
</div>
);
}Docs entries used in this lesson
Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. The main advantage of CSS Modules is:
2. Tailwind CSS is:
Hands-on tasks in the game
- Code editor
Review (topic from module 1): Implement a dashboard with a responsive CSS layout
- Vertical ordering
Arrange CSS selectors from lowest to highest specificity:
- Click in order
Arrange the correct CSS Grid declaration syntax
- Code editor
Create a Button component with its own CSS Module file containing styles for primary and secondary button