shadcn/ui Component Patterns
This document covers architectural patterns used in shadcn/ui components.
Table of Contents
- Compound Component Pattern
- asChild / Slot Polymorphism
- Controlled vs Uncontrolled State
- Context for Complex Components
- data-slot CSS Targeting
- has() Selector Usage
Compound Component Pattern
Compound components split complex UI into multiple related components that work together.
Card Example
// Export multiple related components
export {
Card, // Container
CardHeader, // Header section
CardTitle, // Title element
CardDescription, // Description text
CardContent, // Main content area
CardFooter, // Footer section
CardAction, // Action area (optional)
}
// Usage - compose as needed
<Card>
<CardHeader>
<CardTitle>Dashboard</CardTitle>
<CardDescription>Overview of your account</CardDescription>
<CardAction>
<Button>Settings</Button>
</CardAction>
</CardHeader>
<CardContent>
Main content here
</CardContent>
<CardFooter>
Footer content
</CardFooter>
</Card>
Key Benefits:
- Flexible composition - use only needed parts
- Clear semantic structure
- Each component handles its own styling
- Type-safe with independent prop types
Dialog Example
export {
Dialog, // Root component (state container)
DialogTrigger, // Opens the dialog
DialogContent, // Modal content wrapper
DialogHeader, // Header section
DialogTitle, // Title (accessibility required)
DialogDescription, // Description (accessibility)
DialogFooter, // Footer for actions
DialogClose, // Close button
}
// Usage
<Dialog>
<DialogTrigger asChild>
<Button>Open</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Action</DialogTitle>
<DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Sidebar Context Example
Complex compound components use Context for shared state:
// 1. Define context type
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
toggleSidebar: () => void
isMobile: boolean
}
// 2. Create context
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
// 3. Custom hook for consuming context
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
// 4. Provider component
function SidebarProvider({ children, defaultOpen = true, ...props }) {
const [open, setOpen] = React.useState(defaultOpen)
const isMobile = useIsMobile()
const toggleSidebar = React.useCallback(() => {
setOpen((open) => !open)
}, [])
const contextValue = React.useMemo(
() => ({
state: open ? "expanded" : "collapsed",
open,
setOpen,
toggleSidebar,
isMobile,
}),
[open, setOpen, toggleSidebar, isMobile]
)
return (
<SidebarContext.Provider value={contextValue}>
{children}
</SidebarContext.Provider>
)
}
// 5. Child components consume context
function SidebarTrigger({ ...props }) {
const { toggleSidebar } = useSidebar()
return (
<Button {...props}>
Toggle
</Button>
)
}
// Usage
<SidebarProvider>
<Sidebar>
<SidebarHeader>Header</SidebarHeader>
<SidebarContent>Content</SidebarContent>
</Sidebar>
<SidebarTrigger />
</SidebarProvider>
asChild / Slot Polymorphism
The asChild pattern allows components to render as different elements while preserving styling and behavior.
Basic Pattern
import { Slot } from "@radix-ui/react-slot"
function Button({
asChild = false,
className,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ className }))}
{...props}
/>
)
}
Usage Examples
// Renders as <button>
<Button>Click me</Button>
// Renders as <a> with button styling
<Button asChild>
<a href="/home">Home</a>
</Button>
// Renders as Next.js Link
<Button asChild>
<Link href="/dashboard">Dashboard</Link>
</Button>
// Renders as custom component
<Button asChild>
<motion.div whileHover={{ scale: 1.05 }}>
Animated Button
</motion.div>
</Button>
How Slot Works
The Slot component from Radix UI merges props and classes from the wrapper onto the child:
// With asChild=true
<Button asChild className="custom-class"
<a href="/home">Home</a>
</Button>
// Renders as:
<a
href="/home"
className="button-variant-classes custom-class"
>
Home
</a>
Badge with asChild
function Badge({
asChild = false,
variant,
className,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> &
{ asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
// Usage
<Badge asChild variant="destructive">
<a href="/alerts">5 Alerts</a>
</Badge>
Controlled vs Uncontrolled State
Components support both controlled (parent manages state) and uncontrolled (internal state) patterns.
Uncontrolled Pattern
function Checkbox({ defaultChecked = false, ...props }) {
const [checked, setChecked] = React.useState(defaultChecked)
return (
<input
type="checkbox"
checked={checked}
=> setChecked(e.target.checked)}
{...props}
/>
)
}
// Usage - component manages own state
<Checkbox defaultChecked={true} />
Controlled Pattern
function Checkbox({ checked, onCheckedChange, ...props }) {
return (
<input
type="checkbox"
checked={checked}
=> onCheckedChange?.(e.target.checked)}
{...props}
/>
)
}
// Usage - parent controls state
const [checked, setChecked] = useState(false)
<Checkbox checked={checked} />
Hybrid Pattern (Sidebar)
Support both controlled and uncontrolled usage:
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
...props
}) {
// Internal state
const [_open, _setOpen] = React.useState(defaultOpen)
// Use prop if provided, otherwise internal state
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
// Call prop callback if provided
if (setOpenProp) {
setOpenProp(openState)
} else {
// Otherwise update internal state
_setOpen(openState)
}
},
[setOpenProp, open]
)
return (
<SidebarContext.Provider value={{ open, setOpen }}>
{children}
</SidebarContext.Provider>
)
}
// Uncontrolled usage
<SidebarProvider defaultOpen={false}>
<Sidebar />
</SidebarProvider>
// Controlled usage
const [sidebarOpen, setSidebarOpen] = useState(true)
<SidebarProvider open={sidebarOpen}
<Sidebar />
</SidebarProvider>
Context for Complex Components
Use React Context for components with multiple children sharing state.
When to Use Context
- Multiple child components need access to shared state
- Props drilling would be excessive
- State logic is complex (e.g., Sidebar open/collapsed/mobile states)
- Component has plugin/extension architecture
Form Context Example
type FormContextValue = {
formId: string
errors: Record<string, string>
register: (name: string) => void
unregister: (name: string) => void
}
const FormContext = React.createContext<FormContextValue | null>(null)
function useFormContext() {
const context = React.useContext(FormContext)
if (!context) {
throw new Error("Form components must be used within Form")
}
return context
}
function Form({ children, onSubmit }: FormProps) {
const [errors, setErrors] = React.useState({})
const formId = React.useId()
const contextValue = React.useMemo(
() => ({
formId,
errors,
register: (name) => { /* ... */ },
unregister: (name) => { /* ... */ },
}),
[formId, errors]
)
return (
<FormContext.Provider value={contextValue}>
<form id={formId}
{children}
</form>
</FormContext.Provider>
)
}
function FormField({ name, ...props }) {
const { formId, errors, register } = useFormContext()
React.useEffect(() => {
register(name)
return () => unregister(name)
}, [name])
return (
<div>
<input id={`${formId}-${name}`} {...props} />
{errors[name] && <span>{errors[name]}</span>}
</div>
)
}
Best Practices
- Memoize context value to prevent unnecessary re-renders:
const contextValue = React.useMemo(
() => ({ state, setState, helpers }),
[state, setState, helpers]
)
- Type the context properly:
const Context = React.createContext<ContextType | null>(null)
- Provide helpful error messages:
if (!context) {
throw new Error("useComponent must be used within ComponentProvider")
}
- Use custom hooks for consuming context:
function useComponent() {
const context = React.useContext(ComponentContext)
if (!context) throw new Error("...")
return context
}
data-slot CSS Targeting
Every shadcn/ui component includes a data-slot attribute for CSS targeting.
Basic Usage
function Button({ ...props }) {
return <button data-slot="button" {...props} />
}
function Card({ ...props }) {
return <div data-slot="card" {...props} />
}
CSS Targeting:
/* Target all buttons */
[data-slot="button"] {
/* styles */
}
/* Target buttons within cards */
[data-slot="card"] [data-slot="button"] {
/* styles */
}
Tailwind Usage:
<div className="[&_[data-slot=button]]:shadow-lg">
<Button>Styled via parent</Button>
</div>
Advanced Patterns
Conditional Layouts Based on Slots
function CardHeader({ className, ...props }) {
return (
<div
data-slot="card-header"
className={cn(
"grid gap-2 px-6",
// If CardAction is present, use two columns
"has-data-[slot=card-action]:grid-cols-[1fr_auto]",
className
)}
{...props}
/>
)
}
// Usage
<CardHeader>
<CardTitle>Title</CardTitle>
{/* When CardAction is added, layout changes automatically */}
<CardAction>
<Button>Action</Button>
</CardAction>
</CardHeader>
Parent Selectors with Slots
function CardFooter({ className, ...props }) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center px-6",
// If parent has .border-t class, add top padding
"[.border-t]:pt-6",
className
)}
{...props}
/>
)
}
// Usage
<Card className="border-t">
<CardFooter>Footer gets top padding</CardFooter>
</Card>
Multiple data-* Attributes
Components can have multiple data attributes for different purposes:
function Sidebar({ variant, side, collapsible, ...props }) {
return (
<div
data-slot="sidebar"
data-variant={variant}
data-side={side}
data-collapsible={collapsible}
className={cn(
"group",
"group-data-[variant=floating]:rounded-lg",
"group-data-[side=left]:border-r",
"group-data-[collapsible=icon]:w-12"
)}
{...props}
/>
)
}
has() Selector Usage
Modern CSS :has() selector enables parent styling based on children.
Basic Pattern
// Button with icon adjusts padding
<button className="px-4 has-[>svg]:px-3">
<Icon />
Text
</button>
How it works:
has-[>svg]- if button has direct child<svg>- Apply
px-3instead of basepx-4 - Automatically adjusts based on content
Size Variants with Icons
const buttonVariants = cva("...", {
variants: {
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 px-3 has-[>svg]:px-2.5",
lg: "h-10 px-6 has-[>svg]:px-4",
},
},
})
// Padding adjusts automatically
<Button size="sm">
<Icon />
Text
</Button>
CardHeader Grid Layout
function CardHeader({ className, ...props }) {
return (
<div
className={cn(
"grid gap-2",
// Single column by default
"grid-rows-[auto_auto]",
// When CardAction exists, add second column
"has-data-[slot=card-action]:grid-cols-[1fr_auto]",
className
)}
{...props}
/>
)
}
// Layout adapts based on children
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Description</CardDescription>
{/* Adding this changes layout to two columns */}
<CardAction>
<Button>Action</Button>
</CardAction>
</CardHeader>
Conditional Border Padding
function CardFooter({ className, ...props }) {
return (
<div
className={cn(
"flex items-center px-6",
// Add padding only if parent has border-t class
"[.border-t]:pt-6",
className
)}
{...props}
/>
)
}
Group-based Conditional Styling
function SidebarGroupLabel({ className, ...props }) {
return (
<div
className={cn(
"flex h-8 items-center px-2",
// Hide when sidebar is collapsed (icon mode)
"group-data-[collapsible=icon]:-mt-8",
"group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
// Parent controls child visibility
<div className="group" data-collapsible="icon">
<SidebarGroupLabel>Hidden in icon mode</SidebarGroupLabel>
</div>
Peer-based Interactions
function SidebarMenuButton({ size, isActive, className, ...props }) {
return (
<button
data-slot="sidebar-menu-button"
data-size={size}
data-active={isActive}
className={cn(
"peer/menu-button",
"flex items-center gap-2",
className
)}
{...props}
/>
)
}
function SidebarMenuAction({ showOnHover, className, ...props }) {
return (
<button
data-slot="sidebar-menu-action"
className={cn(
"absolute right-1",
// Show when peer (menu button) is hovered
showOnHover && "peer-hover/menu-button:opacity-100 md:opacity-0",
// Highlight when peer is active
"peer-data-[active=true]/menu-button:text-accent-foreground",
className
)}
{...props}
/>
)
}
// Usage
<div>
<SidebarMenuButton isActive={true}>Menu</SidebarMenuButton>
<SidebarMenuAction showOnHover />
</div>
Browser Support
The :has() selector is supported in all modern browsers (Chrome 105+, Safari 15.4+, Firefox 121+). For older browsers, provide fallback styling:
className={cn(
// Fallback for browsers without :has() support
"px-4",
// Modern browsers with :has() support
"has-[>svg]:px-3",
)}