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.