TIL

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 as prop 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:

  1. Can't merge behaviors: If you pass an onClick to ButtonWithAs and the component itself also defines onClick, only one will take effect. This is replacement, not composition.
  2. 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 (
<Comp
className={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
<Button
asChild
variant="primary"
onClick={() => console.log('Button clicked')}
>
<a
href="/home"
onClick={() => console.log('Link clicked')}
>
Both handlers fire!
</a>
</Button>

Key Advantages

  1. Prop merging: Event handlers from both parent and child are composed together, not replaced
  2. Simpler API: Boolean asChild prop is easier to understand than generic type parameters
  3. Better TypeScript: No complex polymorphic type gymnastics required
  4. 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 (
<Comp
className={`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 children
function 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>

Further Reading

If you enjoyed this article, please click the buttons below to share it with more people. Your support means a lot to me as a writer.
Share on X
Buy me a coffee