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.