Overview

shadcn components are readable source files: compound sub-components for structure, data-slot attributes for styling hooks, and class-variance-authority (cva) for variants. The primitive underneath is Radix, Base UI, or React Aria, chosen when you ran init (see shadcn), and that choice changes how you render a component as another element.

Use the full compound structure

Most components export a namespace of coordinated parts. Use them instead of passing a header or footer as a prop; the parts carry the spacing and type tokens.

<Card>
  <CardHeader>
    <CardTitle>Usage this month</CardTitle>
    <CardDescription>API calls across all endpoints</CardDescription>
  </CardHeader>
  <CardContent>{/* chart */}</CardContent>
  <CardFooter><Button variant="outline">View details</Button></CardFooter>
</Card>

Read the component file before deciding to pass a complex prop; a sub-component usually exists for that slot.

Render as another element without nesting interactive tags

A button wrapping a link is invalid HTML and breaks keyboard and screen-reader behavior. The fix depends on the base.

{/* Radix base */}
<Button asChild><a href="/">Home</a></Button>
<DialogTrigger asChild><Button>Open</Button></DialogTrigger>
 
{/* Base UI base */}
<DialogTrigger render={<Button />}>Open</DialogTrigger>

asChild merges props, class names, and ref onto the single child via Radix Slot. Base UI components take a render prop instead, and the React Aria base uses neither. Check which base the project selected at init before copying examples from the docs; the docs have a page per base.

On the Base UI base, do not render a link with <Button render={<a />} />: the Base UI Button always sets role="button", which overrides the link role. Use buttonVariants on a plain anchor.

<a href="/" className={buttonVariants({ variant: "secondary" })}>Home</a>

Extend variants with cva inside the component file

To add a variant, edit the cva call in the component and keep the existing keys.

const buttonVariants = cva("inline-flex items-center justify-center ...", {
  variants: {
    variant: {
      default: "bg-primary text-primary-foreground hover:bg-primary/90",
      brand: "bg-brand text-white hover:bg-brand/90",
    },
  },
  defaultVariants: { variant: "default" },
});

Define --color-brand in @theme (see shadcn-theming) so bg-brand exists. Avoid bg-[--color-brand]: that bracket syntax is Tailwind v3; in v4 write bg-(--color-brand) or define a token. VariantProps<typeof buttonVariants> picks up new variants automatically. Do not fork a whole component for a visual variant.

Pass ref as a prop when wrapping

Current components are plain function components typed with React.ComponentProps<...>, so ref flows through props in React 19 and forwardRef is not needed. Spread props onto the underlying component.

function SearchInput({ className, ...props }: React.ComponentProps<typeof Input>) {
  return <Input className={cn("pl-9", className)} {...props} />;
}

Dropping ...props is the common mistake; a form library or autofocus hook targeting the wrapper then fails silently. Projects scaffolded earlier may still use forwardRef; keep that style per file until you migrate.

Keep overlays on one primitive library

The primitive library manages stacking, focus, and Esc for nested overlays, such as a confirmation Dialog opened from a Sheet, when all parts come from the same layer. Mixing in a native <dialog> or another library’s modal can break focus trapping. For keyboard requirements see accessibility.