---
title: "shadcn/ui: Composition"
slug: "shadcn-composition"
category: "frontend"
tags: ["frontend", "shadcn", "react", "radix", "composition", "compound-components", "accessibility"]
status: "stable"
last_updated: 2026-05-14
summary: "Compose shadcn with compound components and asChild; extend variants with cva; avoid wrapping components when Slot achieves the same result."
related: ["[[frontend/shadcn]]", "[[frontend/shadcn-installation]]", "[[frontend/shadcn-theming]]", "[[frontend/shadcn-forms]]", "[[frontend/react]]", "[[frontend/react-hooks]]", "[[frontend/accessibility]]"]
---

## 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.

```tsx
<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.

```tsx
// 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 [[frontend/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.

```tsx
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 [[frontend/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.

```tsx
// 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 [[frontend/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.

```tsx
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 [[frontend/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.

```tsx
// 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 [[frontend/accessibility]] for the keyboard requirements.

## Related

- [[frontend/shadcn]]
- [[frontend/shadcn-installation]]
- [[frontend/shadcn-theming]]
- [[frontend/shadcn-forms]]
- [[frontend/react]]
- [[frontend/react-hooks]]
- [[frontend/accessibility]]
