Overview
shadcn components are not black boxes. They are readable source files built on Radix primitives, composed with sub-component namespaces, and extended through class-variance-authority variants. The composition model rewards engineers who read the component files before using them. This page covers the patterns that keep composition clean: compound components, the asChild prop, the Slot primitive, and variant extension.
Use compound components instead of a prop-soup component
Most shadcn components are exported as a namespace of coordinated sub-components rather than a single component with many props. Use the full structure.
<Card>
<CardHeader>
<CardTitle>Usage This Month</CardTitle>
<CardDescription>API calls across all endpoints</CardDescription>
</CardHeader>
<CardContent>
<Chart data={usageData} />
</CardContent>
<CardFooter>
<Button variant="outline">View details</Button>
</CardFooter>
</Card>The sub-components apply their own padding, typographic scale, and spacing tokens. Passing a single header prop to <Card> would re-invent this structure as a string or JSX blob, and you would lose the token system. Read the component file before deciding to pass a complex prop; a sub-component almost always exists for that slot.
Use asChild to merge styles onto a child element
The asChild prop, from Radix UI’s Slot primitive, clones the component’s props, className, and ref onto its single child element instead of rendering a wrapper element. Use it whenever a wrapper would produce invalid HTML or break semantics.
// Without asChild: renders <button><a href="/">Home</a></button>, invalid HTML.
<Button>
<a href="/">Home</a>
</Button>
// With asChild: renders <a href="/" class="...button classes...">Home</a>.
<Button asChild>
<a href="/">Home</a>
</Button>asChild also prevents double nesting of interactive elements, which causes keyboard navigation and screen-reader issues. A DialogTrigger asChild lets a Button open a Dialog without nesting <button> inside <button>. See accessibility for the underlying rule.
Understand Slot before building custom primitives
The Slot component from @radix-ui/react-slot is what asChild uses internally. When building a custom component that needs to optionally render as its child, reach for Slot directly.
import { Slot } from "@radix-ui/react-slot";
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
asChild?: boolean;
}
export function NavLink({ asChild, className, ...props }: LinkProps) {
const Comp = asChild ? Slot : "a";
return <Comp className={cn("nav-link", className)} {...props} />;
}The pattern is: import Slot; expose an asChild prop; pick Slot or the default element based on the prop. Keep it at this level. A component that needs more than one optional-element slot is a sign the API needs to be split into sub-components instead. See react for the broader component design rules.
Extend variants with cva inside the component file
class-variance-authority (cva) powers the variant system in every shadcn component. To add a variant, edit the cva call in the component file.
// In components/ui/button.tsx
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors ...",
{
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",
// Add new variants here:
brand: "bg-[--color-brand] text-white hover:bg-[--color-brand]/90",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
},
},
defaultVariants: { variant: "default", size: "default" },
}
);Add to cva; do not fork the entire component for a new visual variant. The VariantProps<typeof buttonVariants> type utility picks up new variants automatically. See shadcn-theming for where the CSS tokens for --color-brand live.
Forward refs so parent components can control focus
shadcn components use React.forwardRef so parents can call .focus(), scroll to the element, or pass the ref to a third-party library. When building a wrapper around a shadcn component, forward the ref.
const SearchInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentPropsWithoutRef<typeof Input>
>(({ className, ...props }, ref) => (
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2" />
<Input ref={ref} className={cn("pl-9", className)} {...props} />
</div>
));
SearchInput.displayName = "SearchInput";Dropping the ref is the most common mistake when wrapping a component. Without it, a form library or an autofocus hook targeting your wrapper silently fails. See react-hooks for the general useRef pattern.
Compose accessible dialogs and drawers at one level, not nested
Radix dialog primitives maintain a focus trap and aria ownership tree. When you nest a Dialog inside another Dialog, Radix manages the stack, but only when both are Radix primitives. Mixing a Radix Dialog with a plain <dialog> element or another library’s modal breaks the ownership tree.
// One confirmation dialog triggered from inside a sheet.
<Sheet>
<SheetTrigger asChild><Button>Edit record</Button></SheetTrigger>
<SheetContent>
<form>...</form>
<Dialog>
<DialogTrigger asChild><Button variant="destructive">Delete</Button></DialogTrigger>
<DialogContent>
<DialogTitle>Confirm deletion</DialogTitle>
<Button onClick={handleDelete}>Yes, delete</Button>
</DialogContent>
</Dialog>
</SheetContent>
</Sheet>Radix handles nested portals correctly. The constraint is: all overlay components in a nesting chain must come from the same Radix layer. Crossing overlay systems here produces focus-trap failures. See accessibility for the keyboard requirements.