understanding and implementing the asChild pattern yourself
Slot based component APIs replace a component's root element with exactly one passed child element instead of inserting an extra DOM element. Radix popularized this approach under the name asChild, but the underlying slot component can also be built yourself in a few lines without any third party library.
Table of Contents
- 1. What slot based component APIs actually solve
- 2. The asChild prop core principle
- 3. Building your own slot component without Radix
- 4. Named slots: multiple insertion points
- 5. Props and ref merging for slots
- 6. Slot pattern versus children as a function
- 7. Accessibility for slot components
- 8. Common mistakes with slot APIs
- 9. The slot pattern compared to alternatives
- 10. Summary
- 11. FAQ
1. What slot based component APIs actually solve
A slot based component API solves a problem that often shows up with the as prop: sometimes a component should not replace its root element by an element name, but by a complete, already existing child element with its own props. The classic case is a button that visually looks like a button but should actually be a React Router link with its own to prop and its own onClick, without an extra, redundant <button> being rendered around the link.
Without slot based component APIs, you would either have to accept two nested elements, which can produce invalid HTML, for example a link inside a button, or write a separate variant for every combination. The slot pattern solves this by transferring the parent component's props and behavior directly onto the single passed child element instead of rendering its own wrapper element. This pattern became known through Radix UI under the name asChild, but it works independently of any specific library.
2. The asChild prop core principle
The core principle of a slot based component API is that a component accepts a boolean prop called asChild. If asChild is set, the component no longer renders its own DOM element, but takes children as the only allowed React element and clones it with cloneElement, transferring all of the parent component's props onto the cloned element. If asChild is not set, the component renders its default element as usual, usually a <button> or <div>.
The decisive difference from the as prop is the direction of control: with the as prop, a string or component reference determines what gets rendered. With a slot based component API, the actually passed child element itself determines what gets rendered, including all of its own props. This makes slot components particularly suitable for cases where the target element is already fully configured with its own props, for example a router link with several specific attributes.
// Slot.jsx — minimal slot implementation, framework agnostic
import { cloneElement, isValidElement } from "react";
function Slot({ children, ...slotProps }) {
if (!isValidElement(children)) {
// Slot requires exactly one valid React element as child
return null;
}
// Merge props: the child's own props win over the slot's props
return cloneElement(children, {
...slotProps,
...children.props,
className: [slotProps.className, children.props.className].filter(Boolean).join(" "),
});
}
function Button({ asChild, children, className, ...rest }) {
const Component = asChild ? Slot : "button";
return (
<Component className={`btn ${className ?? ""}`} {...rest}>
{children}
</Component>
);
}
// Usage — Link keeps its own "to" prop, no extra wrapper element rendered
// <Button asChild>
// <Link to="/pricing">View pricing</Link>
// </Button>
3. Building your own slot component without Radix
Whoever does not want to introduce a dependency on Radix can adopt the Slot component shown in the previous section directly and maintain it within their own design system. The core consists of three lines: check whether children is a valid React element, clone the element with cloneElement, and merge props while doing so. The actual complexity of a production ready slot based component API lies in the details of props merging, not in the core principle itself.
An important edge case concerns multiple child elements: if asChild is set and more than one child element is passed, for example text and an icon side by side, cloneElement fails because children is then no longer a single element but an array. A robust slot based component API checks this case explicitly and throws a clear error message in development mode, so the problem is noticed immediately instead of showing up as a silent rendering bug in production.
// Slot.jsx — with explicit validation for common misuse
import { Children, cloneElement, isValidElement } from "react";
function Slot({ children, ...slotProps }) {
const childArray = Children.toArray(children);
if (childArray.length !== 1 || !isValidElement(childArray[0])) {
if (process.env.NODE_ENV !== "production") {
console.error(
"Slot expects exactly one valid React element as its child. " +
"Received: " + childArray.length + " children."
);
}
return null;
}
const child = childArray[0];
return cloneElement(child, {
...slotProps,
...child.props,
});
}
4. Named slots: multiple insertion points
A single asChild slot is enough for a component with exactly one replaceable element. More complex components often need several such insertion points at once, for example a card component with a replaceable header element and a replaceable footer element. This is where the named slots pattern comes in: instead of a single asChild prop, the component defines several named props such as headerAs and footerAs, or it detects special child components via a displayName to distribute them to the correct position in the internal markup.
The second, in practice more common variant for slot based component APIs with multiple insertion points is distributing children by type. A component scans its child elements, filters out for example all elements of type Card.Header, and renders them at a fixed position, while the rest is passed through normally. This technique combines the slot principle with the compound component pattern and works well for layout components with several clearly named areas.
// Card.jsx — named slots via child type detection
import { Children, isValidElement } from "react";
function Card({ children }) {
const childArray = Children.toArray(children);
const header = childArray.find((c) => isValidElement(c) && c.type === CardHeader);
const footer = childArray.find((c) => isValidElement(c) && c.type === CardFooter);
const body = childArray.filter(
(c) => !(isValidElement(c) && (c.type === CardHeader || c.type === CardFooter))
);
return (
<div className="card">
{header && <div className="card-header">{header}</div>}
<div className="card-body">{body}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}
function CardHeader({ children }) {
return children;
}
function CardFooter({ children }) {
return children;
}
Card.Header = CardHeader;
Card.Footer = CardFooter;
export { Card };
5. Props and ref merging for slots
The hardest detail of every slot based component API is correct props merging. Event handlers such as onClick must not simply be overwritten, because otherwise either the parent component's logic or the child element's logic gets lost. The correct approach composes both handlers into a new function that calls first one and then the other. The same applies to className, which must be concatenated rather than replaced, and to style, which should be merged as an object.
Ref merging is the second critical point. When both the parent component and the caller want to set a ref on the same element, the slot based component API needs a function that merges multiple refs and updates all affected refs on every ref change, regardless of whether they are function refs or object refs. Without this merging, either the internal or the external ref would be lost, which especially in libraries that rely on internal DOM access leads to hard to find bugs.
6. Slot pattern versus children as a function
An alternative to slot based APIs is the children as a function technique, where the component expects a function as children and passes state and handlers to that function as arguments. The caller then decides for themselves how the result is rendered. The difference from a slot based component API is that children as a function explicitly exposes state, while the slot pattern implicitly transfers props onto an existing element.
In practice, children as a function suits better when the component has state that the caller needs for rendering, for example the current loading state of a form. A slot based component API suits better when no additional state is needed, only an extra wrapper element should be avoided, for example a button that is actually a link. Both patterns can be used side by side in the same library, depending on the requirements of each component.
7. Accessibility for slot components
With a slot based component API, accessibility tends to be easier to ensure than with the as prop, because the element that actually gets rendered is fully dictated by the caller and thus already brings the correct semantics. A link stays a link with all native keyboard and screen reader properties, regardless of a button component adding visual classes and a click handler to it. No intermediate element is created that would need additional ARIA semantics.
One risk remains, though: if the slot component overwrites props such as role or aria-* attributes without reflection instead of merging them with those of the child element, the original semantics can be lost. A careful slot based component API treats ARIA attributes like className: merge instead of replace, and when in doubt, prefer the child element's values, because it is closer to the actual use case.
8. Common mistakes with slot APIs
The most common mistake with slot based component APIs is passing more than one child element while asChild is active. Since cloneElement only works with a single element, an array of child elements leads either to a runtime error or to silent misbehavior, depending on the implementation. A second common mistake is overwriting instead of composing event handlers, causing either the component's internal logic or the caller's logic to be lost without any error pointing to it.
A third mistake concerns TypeScript typing: because cloneElement can apply arbitrary props to an arbitrary element at runtime, you quickly lose type safety without additional generics. A robust slot based component API in TypeScript explicitly restricts the allowed child type to elements with compatible props, for example via a generic ReactElement<ComponentPropsWithoutRef<"button">>, instead of typing children as an arbitrary ReactNode.
9. The slot pattern compared to alternatives
The following table compares the slot pattern with the two other important approaches for flexible component APIs.
| Approach | Extra DOM element | State exposable | Typical use |
|---|---|---|---|
| Slot / asChild | No | No, props merging only | Button as link, trigger as arbitrary element |
| as prop | Yes, the chosen element itself | No | Heading or text with variable tag |
| Children as a function | Optional, depending on return value | Yes, explicitly as arguments | Exposing form state, loading state |
In practice, these three patterns complement each other: the slot pattern suits when an existing element should keep its own props unchanged. The as prop suits when only the element name should vary. Children as a function suits when state from the component needs to be passed to the caller. Many production ready component libraries combine all three depending on the building block.
Mironsoft
React component APIs and design systems
Need flexible slot based components for your design system?
We implement the slot pattern, props merging, and ref merging robustly and type safely, so your components combine with arbitrary child elements without losing accessibility.
API design
Combining slot, as prop, and children as a function appropriately
Props and ref merging
Robust merge logic without losing handlers or refs
TypeScript safety
Generic types for slot children instead of any
10. Summary
Slot based component APIs solve a specific problem in component design: a child element should take over the props of a parent component without an extra wrapper element appearing in the DOM. Radix's asChild pattern made this approach well known, but the underlying slot component can be implemented yourself with cloneElement and clean props merging in a few lines, without any additional dependency.
The hardest part is not the core principle but the details: event handlers must be composed rather than overwritten, refs must be merged, and in TypeScript the allowed child type should be explicitly typed. Whoever masters these details has, with slot based component APIs, a powerful tool to flexibly combine components with arbitrary child elements without sacrificing accessibility or type safety.
Slot Based Component APIs — The Essentials
Core principle
asChild replaces the root element with the passed child element, no wrapper is rendered.
Implementation
cloneElement plus clean props merging is enough for a custom slot component without Radix.
Merging
Compose event handlers instead of overwriting, merge refs with a dedicated merge function.
Accessibility
No intermediate element needed, child semantics stay intact when ARIA props are merged.