Children and Slot Patterns
When to Use
When a component needs to render content it doesn't control — layouts, wrappers, trigger+panel pairs.
Decision
| If you need... | Use... | Why |
|---|---|---|
| Simple wrapper rendering one block of content | children: React.ReactNode |
Simplest; no pattern overhead |
| Named regions (header + body + footer) | Compound component dot notation | Caller places slots explicitly in JSX; readable |
| Caller-controlled rendering with access to internal state | Render props / function children | Pass internal state out for advanced customization |
| Unstyled trigger wrapped by Radix primitive | asChild prop (Radix pattern) |
Merges behavior onto caller's element without wrapper div |
| Multiple optional content areas | Named slot props (headerSlot?, footerSlot?) |
Simple; avoids compound component overhead for 2-3 slots |
| Wrap a lazy-loaded component in a Radix Slot | Slottable component |
Radix 1.2.x supports lazy component children via React 19 use internally |
Pattern
asChild pattern — verified from @radix-ui/react-slot 1.2.4 source:
import { Slot } from '@radix-ui/react-slot';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return <Comp ref={ref} className={cn(buttonVariants(), className)} {...props} />;
}
);
// Usage: <Button asChild><Link href="/page">Go</Link></Button>
// Renders a styled <a> — not a <button> wrapping an <a>
How Slot merges props — from mergeProps() in the source (important nuances):
// Slot merges slotProps + childProps with these rules:
// 1. Event handlers: BOTH are called (slot handler runs after child handler)
// onClick from Slot AND onClick from child both fire — they compose, not override
// 2. style: shallow merge — { ...slotStyle, ...childStyle }
// 3. className: simple string join with space — NOT cn() / tailwind-merge
// Use cn() in your component before passing to Slot if you need conflict resolution
// 4. All other props: child props win (childProps override slotProps)
Named slot props (simple alternative for 2-3 regions):
interface DialogProps {
title: React.ReactNode;
description?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
}
// Callers: <Dialog title={<h2>Confirm</h2>} footer={<DialogActions />}>content</Dialog>
Common Mistakes
- Using
React.Children.map()to iterate children for slot identification → fragile; children type changes break it; use compound components or named slot props instead - Wrapping
asChildcontent in an extra div → negates the entire point;asChildshould merge directly onto the child element - Expecting Slot to resolve Tailwind class conflicts → Slot joins classNames with a space, not
cn(); conflicting Tailwind classes (e.g.,p-2from slot andp-4from child) both survive; resolve before passing to Slot - Deeply nested render props → impossible to read; compound components with Context solve the same problem more declarably
- Forgetting to forward refs when using
asChild→ Radix primitives need refs for positioning; always useReact.forwardRef(or passrefdirectly in React 19) - Adding a second event handler and expecting it to override the Slot's → both fire; this is intentional composition behavior, not a bug
See Also
- Props Patterns
- Composition Patterns
- Reference:
@radix-ui/react-slot1.2.4 —node_modules/@radix-ui/react-slot/dist/index.js - Reference: Radix UI — Composition (asChild)
- Reference: shadcn/ui Button with asChild