Radix UI - Slot
- 文章發表於
- ...
The Problem
Think about a scenario where we need a Button component that can also act as a link (<a>).
Conditional Rendering
The first approach that comes to mind is conditional rendering.
function Button({ href, children, ...props }) {if (href) {return <a href={href} {...props}>{children}</a>;}return <button {...props}>{children}</button>;}
The benefit of this approach is its simplicity. The downside is that it can’t be composed with other components like Next.js’s Link, and the component must know about every possible prop.
The "as" Prop Pattern
The
asprop allows you to override the default element type of a component. Instead of being locked into a specific HTML element, you can adapt the component to render as any valid HTML tag or even another React component.
Another way to think about it is as a prop that lets developers customise their elements.
type ButtonWithAsProps<E extends React.ElementType> = {as?: E;children?: React.ReactNode;} & React.ComponentPropsWithoutRef<E>;function ButtonWithAs<E extends React.ElementType = 'button'>({as,children,...props}: ButtonWithAsProps<E>) {const Element = as || 'button';return <Element {...props}>{children}</Element>;}
Usage
// As a button (default)<ButtonWithAs onClick={() => console.log('clicked')}>Click me</ButtonWithAs>// As a link<ButtonWithAs as="a" href="/home">Go Home</ButtonWithAs>// As a Next.js Link<ButtonWithAs as={Link} href="/about">About</ButtonWithAs>
Limitations
It's a relatively good approach, since it's more flexible compared with the first one. However, it still has critical limitations:
- Can't merge behaviors: If you pass an
onClicktoButtonWithAsand the component itself also definesonClick, only one will take effect. This is replacement, not composition. - TypeScript complexity: TypeScript support for polymorphic components is notoriously difficult to implement correctly.
That's why the slot pattern is widely used in headless UI libraries: it keeps simple cases simple while making complex cases possible.
The Slot Pattern
Radix UI provides a Slot component that offers a more powerful alternative to the "as" prop pattern. Instead of just changing the element type, Slot merges props with the child component, enabling true composition patterns.
The asChild pattern uses a boolean prop instead of specifying the element type. When asChild is true, the component's props are merged with its child element.
Implementation
import { Slot } from "@radix-ui/react-slot"import { cva, type VariantProps } from "class-variance-authority"import { cn } from "@/lib/utils"const buttonVariants = cva("inline-flex items-center justify-center rounded-md font-medium transition-colors",{variants: {variant: {default: "bg-slate-900 text-white hover:bg-slate-700",primary: "bg-blue-500 text-white hover:bg-blue-600",outline: "border border-slate-300 bg-transparent hover:bg-slate-100",},size: {default: "h-10 px-4 py-2",sm: "h-8 px-3 text-sm",lg: "h-12 px-6 text-lg",},},defaultVariants: {variant: "default",size: "default",},})function Button({className,variant = "default",size = "default",asChild = false,...props}: React.ComponentProps<"button"> &VariantProps<typeof buttonVariants> & { asChild?: boolean }) {const Comp = asChild ? Slot : "button"return (<CompclassName={cn(buttonVariants({ variant, size, className }))}{...props}/>)}
Usage
<Button variant="primary" size="lg" onClick={() => console.log('clicked')}>Click me</Button>// With Next.js Link - composition works!<Button asChild variant="outline"><Link href="/about">About</Link></Button>// Event handlers are MERGED, not replaced<ButtonasChildvariant="primary"onClick={() => console.log('Button clicked')}><ahref="/home"onClick={() => console.log('Link clicked')}>Both handlers fire!</a></Button>
Key Advantages
- Prop merging: Event handlers from both parent and child are composed together, not replaced
- Simpler API: Boolean
asChildprop is easier to understand than generic type parameters - Better TypeScript: No complex polymorphic type gymnastics required
- Composition over configuration: Works naturally with any component or element
Under the hood
The Slot component performs the following tasks:
- Clones the child element.
- Merges the component's props (
className,data attributes, etc.) with the child's props - Forwards refs correctly
- Composes and preserves event handlers.
Mental Model
At its core, asChild changes how a component renders. When set to true, instead of rendering its default DOM element, the component merges its props, behaviors, and event handlers with its immediate child element.
Think of <Slot> as a device that teleports props from a parent component directly to its immediate child element.
Without Slot┌──────────────┐│ <Button> │ ← Props (e.g., className, onClick) are stuck here.│ ┌────────┐ ││ │ <Link> │ │ ← This child <Link> cannot access the Button's props.│ └────────┘ │└──────────────┘<Button asChild onClick={trackAnalytics}><Link to="/about">Navigation</Link></Button>// This renders nested elements:<button onClick={trackAnalytics}><a href="/about">Navigation</a></button>
With Slot┌──────────────┐│ <Button> │ ──props──┐│ ┌────────┐ │ │ (className, onClick)│ │ <Slot> │ │ ←────────┘ (Slot receives the props)│ │ merge! │ ││ ┌────────┐ ││ │ <Link> │ │ ← The <Link> gets the merged props from <Button>│ └────────┘ │└──────────────┘<Button asChild onClick={trackAnalytics}><Link to="/about">Navigation</Link></Button>// This renders a single, merged element:<a href="/about" onClick={trackAnalytics}>Navigation</a>
Implementation
Let's implement a mini version to see how slots magically use composition to solve the issues that we mentioned above.
The core of the slot, as we mentioned a while ago, is the composition and merge props. Let's start looking at mergeProps and how we implement it.
function mergeProps(slotProps, childProps) {const overrideProps = {...childProps};for (const propName in childProps) {const slotValue = slotProps[propName];const childValue = childProps[propName];if (/^on[A-Z]/.test(propName)) {if (childValue && slotValue) {overrideProps[propName] = (...args) => {childValue(...args);slotValue(...args);}} else if (slotValue) {overrideProps[propName] = slotValue;}} else if (propName === 'style') {overrideProps[propName] = { ...slotValue, ...childValue };} else if (propName === 'className') {overrideProps[propName] = [slotValue, childValue].filter(Boolean).join(' ');}}return { ...slotProps, ...overrideProps };}
Once we have our mergeProps in place, then we can starts working on Slot itself
export function Slot({ children, ...slotProps }) {const child = React.Children.only(children);if (!React.isValidElement(child)) {return null;}const mergedProps = mergeProps(slotProps, child.props);return React.cloneElment(child, mergedProps);}
As a library maintainer, we can wrap Slot in the part of our component,
function Button({ asChild, children, className, onClick }) {const Comp = asChild ? Slot : 'button';return (<CompclassName={`btn ${className || ''}`}onClick={onClick}>{children}</Comp>);}
Summary
The Slot pattern is a fundamental concept in the world of composition UI. It is the most important component across the entire headless UI library. This article introduces the core concept and implements a mini version. For follow-up work, you can check the questions below.
What's the Slottable, and how to handle nested childrenfunction Button({ asChild, iconLeft, children }) {const Comp = asChild ? Slot : 'button';return (<Comp>{iconLeft}{children}</Comp>);}
How to handle nested children?<a href="/favorites" class="btn-primary"><div class="button-inner">Favorites<div class="icon">★</div></div></a>
