
Shadcn Ui
- 99 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
shadcn-ui is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- shadcn-ui
- AI & Agent Building
- AI-coding skill
Shadcn Ui by the numbers
- 99 all-time installs (skills.sh)
- +3 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,419 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill shadcn-uiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 99 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
shadcn/ui
Overview
Guides building accessible, customizable UI with shadcn/ui as a code generation platform (not an npm dependency). Components are copied into your project via the CLI and fully owned. Supports Radix UI or Base UI primitives, Tailwind CSS 4 with oklch color tokens, React 19 direct ref patterns, and type-safe form validation with the Field component and Zod.
When to use: Adding shadcn/ui components, theming with CSS variables and oklch colors, building forms with React Hook Form or TanStack Form, creating custom registries, composing accessible component variants, setting up dark mode.
When NOT to use: Non-React frameworks (use shadcn-vue or shadcn-svelte instead), projects using a different component library (MUI, Chakra), projects not using Tailwind CSS.
Quick Reference
| Pattern | API / Approach | Key Points |
|---|---|---|
| Init project | npx shadcn@latest init | Auto-detects framework, configures CSS variables |
| Add component | npx shadcn@latest add [name] | Copies source code into your project |
| Custom project | npx shadcn@latest create | Pick library (Radix/Base UI), style, theme, fonts |
| Check updates | npx shadcn@latest diff [component] | Shows upstream changes for your components |
| Search registry | npx shadcn@latest search @registry | Browse and install from namespaced registries |
| Build registry | npx shadcn@latest build | Generate registry JSON from registry.json |
| Theme tokens | :root vars + @theme inline mapping | oklch color values, dark mode via .dark class |
| Dark mode | @custom-variant dark (&:is(.dark *)) | Swaps CSS variables under .dark selector |
| Component variant | cva() from class-variance-authority | Type-safe variant definitions |
| Polymorphic slot | asChild prop with <Slot> from Radix | Merge props onto child element |
| Direct ref | ref as a regular prop | forwardRef is deprecated in React 19 |
| Form (current) | <Field /> + React Hook Form + Zod | Replaces legacy <Form /> component |
| Form (legacy) | <Form /> + <FormField /> + Zod | Still works but no longer actively developed |
| Class merging | cn() utility wrapping clsx + twMerge | Safely merge Tailwind classes without conflicts |
| Toast / notification | Sonner (npx shadcn@latest add sonner) | Default notification approach; auto-dismiss, stacking |
| Init with blocks | npx shadcn@latest init sidebar-01 | Bootstrap project with pre-built page blocks |
| RTL support | dir="rtl" on root element | Built-in RTL layout support for all components |
| New components | Spinner, Kbd, ButtonGroup | Recently added utility and layout components |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
| Installing shadcn/ui as an npm dependency | Use the CLI (npx shadcn@latest add) to copy source into your project |
Using forwardRef with React 19 components | Pass ref directly as a prop -- forwardRef is deprecated in React 19 |
| Using HSL values for color tokens | Use oklch format: --primary: oklch(0.205 0 0) |
Wrapping variables with hsl() or oklch() in utilities | Reference CSS variables directly: var(--primary) |
Putting tokens directly in @theme {} | Use :root for values + @theme inline to map them to Tailwind |
Using legacy <Form /> for new forms | Use <Field /> component with React Hook Form or TanStack Form |
| Skipping Zod validation in form components | Always pair forms with Zod schemas for type-safe validation |
Using div elements for interactive controls | Use Radix or Base UI primitives for semantic HTML and accessibility |
Modifying components in node_modules | Components live in your source tree -- own and customize directly |
Using old CLI command shadcn-ui@canary | Use npx shadcn@latest for all CLI operations |
Delegation
- Component discovery and primitive lookup: Use
Exploreagent - Theme migration or multi-file refactoring: Use
Taskagent - Design system architecture planning: Use
Planagent - Registry setup and distribution: Use
Taskagent
If the tanstack-form skill is available, delegate advanced form state management (array fields, linked fields, async validation) to it.If the tailwind skill is available, delegate utility class patterns and design token architecture to it.References
- Theming and dark mode
- Component patterns and variants
- Form patterns with Field, React Hook Form, and Zod
- CLI and registry
CLI and Registry
The shadcn CLI manages component installation, project setup, and registry distribution. It auto-detects your framework and adapts accordingly.
CLI Commands
init -- Initialize a Project
npx shadcn@latest initSets up configuration, installs dependencies, adds the cn utility, and configures CSS variables. Supports framework auto-detection.
npx shadcn@latest init --defaults
npx shadcn@latest init --base-color slate --template next
npx shadcn@latest init button card dialogKey flags:
| Flag | Description |
|---|---|
--defaults | Use default settings (Next.js, TypeScript, neutral) |
-t, --template | Framework template (next, next-monorepo) |
-b, --base-color | Base color (neutral, gray, zinc, stone, slate) |
-y, --yes | Skip confirmation prompt |
-f, --force | Overwrite existing configuration |
add -- Add Components
npx shadcn@latest add button
npx shadcn@latest add dialog sheet field
npx shadcn@latest add @acme/custom-buttonCopies component source code into your project and resolves all dependencies.
| Flag | Description |
|---|---|
-a, --all | Install all available components |
-o, --overwrite | Overwrite existing files |
-y, --yes | Skip confirmation |
-s, --silent | Mute output |
create -- Custom Project Setup
npx shadcn@latest createInteractive setup that lets you choose:
- Component library: Radix or Base UI
- Visual style: Vega, Nova, Maia, Lyra, or Mira
- Icons, fonts, base color, and theme
This goes beyond theming -- the config rewrites component code to match your setup.
diff -- Check for Updates
npx shadcn@latest diff
npx shadcn@latest diff buttonShows what has changed upstream since you added a component. Useful before customizing components to see if updates are available.
build -- Build Registry
npx shadcn@latest build
npx shadcn@latest build ./registry.json --output ./public/rReads registry.json and generates registry JSON files for distribution.
| Flag | Description |
|---|---|
-o, --output | Destination directory (default: ./public/r) |
-c, --cwd | Working directory |
search -- Browse Registries
npx shadcn@latest search @acme
npx shadcn@latest list @acmeSearch and browse items from namespaced registries. list is an alias for search.
view -- Preview Before Installing
npx shadcn@latest view button
npx shadcn@latest view @acme/custom-cardPreview a component from the registry before adding it to your project.
Namespaced Registries
Install from community or private registries using the @namespace/item format:
npx shadcn@latest add @acme/custom-button
npx shadcn@latest add @shadcn-blocks/hero-01The CLI auto-detects your component library (Radix or Base UI) and applies the right transformations.
Creating a Custom Registry
Create a registry.json in your project root:
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "acme",
"homepage": "https://ui.acme.com",
"items": [
{
"name": "fancy-button",
"type": "registry:ui",
"title": "Fancy Button",
"description": "A button with animated gradient borders",
"files": [
{
"path": "registry/fancy-button.tsx",
"type": "registry:ui"
}
],
"dependencies": ["class-variance-authority"]
}
]
}Build and serve the registry:
npx shadcn@latest buildThis generates JSON files in public/r/ that can be served over HTTP for others to install from.
Universal Registry Items
Registry items can be distributed to any project -- no framework, components.json, Tailwind, or React required:
{
"name": "eslint-config",
"type": "registry:file",
"description": "Shared ESLint configuration",
"files": [
{
"path": "registry/eslint.config.js",
"type": "registry:file",
"target": "eslint.config.js"
}
]
}This enables distributing configs, rules, docs, and any code to any project.
Local File Support
The CLI supports local files for proprietary or private components:
npx shadcn@latest init --from ./local-registry.json
npx shadcn@latest add --from ./local-registry.json custom-componentMonorepo Setup
The CLI auto-detects monorepo structure. Running add installs components to the correct package:
npx shadcn@latest init --template next-monorepo
npx shadcn@latest add buttonIn a monorepo:
- UI components go to
packages/ui/ - Page-level compositions go to
apps/web/components/
Import from the shared package:
import { Button } from '@workspace/ui/components/button';components.json Configuration
The components.json file (created by init) stores your project configuration:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui"
}
}Updating Components
To update a component to the latest upstream version:
npx shadcn@latest diff button
npx shadcn@latest add button --overwriteAlways check diff first to see what changed, especially if you have customized the component.
Component Patterns and Variants
shadcn/ui components are source code you own. They combine Radix UI or Base UI primitives for accessibility with CVA for variant management and Tailwind for styling.
React 19 Direct Ref Pattern
forwardRef is deprecated in React 19. Pass ref directly as a prop:
import { type ButtonHTMLAttributes } 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 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',
},
},
);
interface ButtonProps
extends
ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export function Button({
className,
variant,
size,
asChild = false,
ref,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}Polymorphic Composition with asChild
The asChild pattern from Radix merges the component's props onto its child element:
import { Button } from '@/components/ui/button';
import Link from 'next/link';
function NavButton() {
return (
<Button asChild variant="ghost">
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
);
}This renders an <a> tag with all the Button styles and accessibility behavior.
CVA Variant Authoring
Class Variance Authority (CVA) provides type-safe variant definitions:
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive:
'border-transparent bg-destructive text-destructive-foreground',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
interface BadgeProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}The cn() Utility
The cn function safely merges Tailwind classes using clsx and tailwind-merge:
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}This prevents class conflicts like p-2 p-4 by keeping only the last value.
Accessible Dialog Pattern
Radix Dialog provides keyboard navigation, focus trapping, and ARIA attributes:
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
export function ConfirmDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete Account</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete your
account and remove your data from our servers.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2">
<Button variant="outline">Cancel</Button>
<Button variant="destructive">Delete</Button>
</div>
</DialogContent>
</Dialog>
);
}Component Library Choice
When initializing with npx shadcn@latest create, you can choose between:
- Radix UI -- The default. Headless primitives with built-in accessibility (ARIA, keyboard navigation, focus management).
- Base UI -- Alternative from the MUI team. Every shadcn/ui component has been rebuilt for Base UI with the same abstraction layer.
Both libraries are fully compatible. Components from remote registries auto-detect your library choice and apply the right transformations.
Component Prop Ordering Convention
Sort props in this order: reserved, boolean, data, callbacks:
interface CardProps {
className?: string;
ref?: React.Ref<HTMLDivElement>;
bordered?: boolean;
elevated?: boolean;
title: string;
description?: string;
onClick?: () => void;
}Adding New Components
Use the CLI to add components:
npx shadcn@latest add button
npx shadcn@latest add dialog
npx shadcn@latest add fieldComponents are copied to your configured components directory (typically src/components/ui/ or components/ui/). From that point, you own the source code and customize it directly.
To add all available components at once:
npx shadcn@latest add --allExtending Components
Since you own the source, extend components by editing them directly:
const buttonVariants = cva('...', {
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',
warning: 'bg-warning text-warning-foreground hover:bg-warning/90',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
xl: 'h-14 rounded-lg px-12 text-lg',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
});Use npx shadcn@latest diff button to check for upstream changes before customizing.
Monorepo Pattern
In monorepos, components are shared from a UI package:
import { Button } from '@workspace/ui/components/button';The CLI auto-detects monorepo structure and installs components to the correct package (e.g., packages/ui), while page-level compositions go to the app directory.
Form Patterns
shadcn/ui provides the <Field /> component as the current approach for building forms. The legacy <Form /> component still works but is no longer actively developed.
Field Component with React Hook Form
The recommended pattern using <Field />, <FieldError />, and React Hook Form with Zod:
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, Controller } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Field, FieldError } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
const formSchema = z.object({
username: z.string().min(2, 'Username must be at least 2 characters'),
email: z.string().email('Invalid email address'),
});
type FormValues = z.infer<typeof formSchema>;
export function ProfileForm() {
const {
control,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { username: '', email: '' },
});
function onSubmit(values: FormValues) {
console.log(values);
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Field>
<Label htmlFor="username">Username</Label>
<Controller
control={control}
name="username"
render={({ field }) => (
<Input id="username" placeholder="username" {...field} />
)}
/>
<FieldError
errors={
errors.username?.message ? [errors.username.message] : undefined
}
/>
</Field>
<Field>
<Label htmlFor="email">Email</Label>
<Controller
control={control}
name="email"
render={({ field }) => (
<Input id="email" placeholder="email@example.com" {...field} />
)}
/>
<FieldError
errors={errors.email?.message ? [errors.email.message] : undefined}
/>
</Field>
<Button type="submit">Save</Button>
</form>
);
}FieldError with Standard Schema
FieldError accepts issues from any validator implementing Standard Schema, including Zod, Valibot, and ArkType:
import { FieldError } from '@/components/ui/field';
<FieldError
errors={['Username is required', 'Must be at least 2 characters']}
/>;Server Action with useActionState
Combine useActionState with Zod validation for server-side form handling:
'use server';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
name: z.string().min(2),
});
type FormState = {
errors: Record<string, string[]>;
message?: string;
};
export async function createUser(
_prevState: FormState,
formData: FormData,
): Promise<FormState> {
const result = schema.safeParse({
email: formData.get('email'),
name: formData.get('name'),
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
return { errors: {}, message: 'User created' };
}'use client';
import { useActionState } from 'react';
import { createUser } from './actions';
import { Button } from '@/components/ui/button';
import { Field, FieldError } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export function CreateUserForm() {
const [state, formAction, isPending] = useActionState(createUser, {
errors: {},
});
return (
<form action={formAction} className="space-y-4">
<Field>
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" />
<FieldError errors={state.errors.name} />
</Field>
<Field>
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" />
<FieldError errors={state.errors.email} />
</Field>
<Button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create User'}
</Button>
{state.message ? (
<p className="text-sm text-muted-foreground">{state.message}</p>
) : null}
</form>
);
}Submit Button with useFormStatus
Use useFormStatus inside a child component of <form> for pending state:
import { useFormStatus } from 'react-dom';
import { Button } from '@/components/ui/button';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Save'}
</Button>
);
}useFormStatus must be called from a component that is a child of a <form>. It does not work if called in the same component that renders the form element.
Optimistic Updates
Use useOptimistic for instant UI feedback during long-running Server Actions:
'use client';
import { useOptimistic } from 'react';
import { type Todo } from '@/lib/types';
import { toggleTodo } from './actions';
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, setOptimisticTodo] = useOptimistic(
todos,
(state: Todo[], updatedId: string) =>
state.map((todo) =>
todo.id === updatedId ? { ...todo, completed: !todo.completed } : todo,
),
);
async function handleToggle(id: string) {
setOptimisticTodo(id);
await toggleTodo(id);
}
return (
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>
<button type="button" onClick={() => handleToggle(todo.id)}>
{todo.completed ? 'Done' : 'Pending'}: {todo.title}
</button>
</li>
))}
</ul>
);
}Legacy Form Component
The legacy <Form /> wrapper around React Hook Form still works but is no longer actively developed. For existing projects using it:
'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';
const formSchema = z.object({
username: z.string().min(2, 'Username must be at least 2 characters'),
});
type FormValues = z.infer<typeof formSchema>;
export function LegacyProfileForm() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { username: '' },
});
function onSubmit(values: FormValues) {
console.log(values);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="username" {...field} />
</FormControl>
<FormDescription>Your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save</Button>
</form>
</Form>
);
}Hook Selection Guide
| Pattern | Hook | Notes |
|---|---|---|
| Submit button loading state | useFormStatus | Must be child of <form> |
| Server Action with error state | useActionState | Returns [state, action, isPending] |
| Instant UI feedback | useOptimistic | Reverts on Server Action failure |
| Client-side validation | useForm + Zod | React Hook Form with zodResolver |
| Server-side validation | Zod in Server Action | Return flattened field errors |
Form Library Choice
shadcn/ui supports multiple form libraries:
| Library | Best For |
|---|---|
| React Hook Form | Client-heavy forms, complex validation, existing RHF projects |
| TanStack Form | Framework-agnostic projects, type-safe form state |
| Next.js Form | Progressive enhancement, Server Actions with Next.js <Form /> |
Native <form> | Simple forms with useActionState and server-side validation |
Theming and Dark Mode
shadcn/ui uses CSS variables with oklch color values for theming. Design tokens are defined in :root and .dark selectors, then mapped to Tailwind via @theme inline.
Global CSS Structure
The complete theme setup in globals.css for Tailwind CSS 4:
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-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.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.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);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}Theme Architecture
The two-layer approach separates concerns:
1. `:root` / `.dark` -- Define raw color values in oklch format. These are plain CSS custom properties that toggle between light and dark palettes.
2. `@theme inline` -- Maps raw variables to Tailwind's --color-* and --radius-* namespace. The inline keyword prevents Tailwind from hoisting values, keeping them responsive to the .dark selector.
This means bg-primary in Tailwind resolves to var(--primary), which changes based on whether .dark is applied.
Color Naming Convention
shadcn/ui uses a background/foreground pair convention:
| Variable | Purpose |
|---|---|
--primary | Background color of primary elements |
--primary-foreground | Text color on primary backgrounds |
--muted | Background for muted/subdued elements |
--muted-foreground | Text on muted backgrounds |
--destructive | Background for danger/error elements |
--destructive-foreground | Text on destructive backgrounds |
The background suffix is omitted -- --primary IS the background color.
Adding Custom Colors
To add a new semantic color (e.g., warning):
:root {
--warning: oklch(0.84 0.16 84);
--warning-foreground: oklch(0.28 0.07 46);
}
.dark {
--warning: oklch(0.72 0.19 64);
--warning-foreground: oklch(0.98 0.01 90);
}Then register it in the @theme inline block:
@theme inline {
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}Now use bg-warning and text-warning-foreground in components.
oklch Color Format
shadcn/ui uses oklch (Oklab Lightness Chroma Hue) instead of HSL:
/* oklch(lightness chroma hue) */
--primary: oklch(0.205 0 0); /* Near-black, no chroma */
--destructive: oklch(0.577 0.245 27.325); /* Saturated red */oklch provides perceptually uniform lightness and access to a wider color gamut on modern displays.
Dark Mode with next-themes
For Next.js projects, use next-themes to manage the .dark class:
import { ThemeProvider } from 'next-themes';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}Toggle with a button component:
'use client';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
{theme === 'dark' ? 'Light' : 'Dark'}
</Button>
);
}Custom Utilities
Define reusable utility classes with @utility:
@utility focus-ring {
@apply ring-2 ring-ring ring-offset-2;
}
@utility container-fluid {
@apply mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8;
}Use them as standard Tailwind classes: class="focus-ring".
Migration from Tailwind 3 / HSL
| Step | Action |
|---|---|
| 1 | Replace @tailwind base/components/utilities with @import "tailwindcss" |
| 2 | Add @import "tw-animate-css" |
| 3 | Add @custom-variant dark (&:is(.dark *)) |
| 4 | Convert HSL values to oklch in :root and .dark |
| 5 | Replace @theme { --color-x: oklch(...) } with :root vars + @theme inline mapping |
| 6 | Replace hsl(var(--x)) patterns with var(--x) |
| 7 | Add @layer base rules for border and body defaults |
| 8 | Remove tailwind.config.js (or keep only for unported plugins) |
Visual Styles
The npx shadcn@latest create command offers five visual styles that modify component structure beyond just colors:
| Style | Characteristics |
|---|---|
| Vega | Classic shadcn/ui look |
| Nova | Reduced padding for compact layouts |
| Maia | Soft and rounded with generous spacing |
| Lyra | Boxy and sharp, pairs with mono fonts |
| Mira | Compact, designed for dense interfaces |