What is a Slot?
Have you ever struggled to create a truly flexible UI component? Imagine a Button that needs to render as a native <button>, an <a> tag for external links, or a Next.js <Link> for navigation—all while maintaining the exact same styles, hover states, and logic from your design system.
You might have tried these common but limited approaches:
-
Conditional Rendering: Using
if/elseinside your component to switch tags based on a prop (e.g.,as="a"). This often leads to "prop bloating" and complex types. - Utility Classes: Exporting CSS classes for others to use. This keeps styles consistent but loses the encapsulated behavior of your component.
The Slot pattern solves this through true composition. Your component provides the "skin" and behavior, while the consumer provides the underlying element. The result? No DOM pollution, no prop drilling, and clean, reusable code.
Building the Foundation
To create a Slot that feels "magic" to the end user, we first need to build a robust engine under the hood. The challenge of the Slot pattern is that we are essentially merging two different components into one DOM node. This means we have to manually handle things that React usually does for us automatically: managing references and merging properties.
💡 A Note on React version
This implementation is designed for React 19 and beyond. Starting with React 19,
refis treated as a standard prop, meaning we no longer need to wrap our components inforwardRefto access them.
If you are using React 18 or lower, the logic for passing and receiving refs will differ. You would need to useReact.forwardRefto capture thereffrom the consumer and pass it into ourcomposeRefsutility.
Combining Refs
A core requirement of the Slot pattern is that the final DOM node must be accessible to both the design system and the consumer. However, a React element can only accept a single ref prop.
The "problem" is that we need to "combine" the Slot's internal references and the child's potential references into one. To do this, we use a callback ref approach. This allows us to receive the DOM node and "broadcast" it to every reference that needs it.
function composeRefs<T>(...refs: (React.Ref<T> | undefined)[]) {
return (node: T) => {
refs.forEach((ref) => {
if (typeof ref === 'function') {
ref(node);
} else if (ref != null) {
ref.current = node;
}
});
};
}
How it works
The callback we return is executed as soon as the component is mounted. Within the loop, we check if the ref is a function (callback) to execute it, or an object to store the node in the .current property. This ensures that no matter how the consumer or the Slot defines their reference, the DOM node is stored correctly in all locations.
Merging Props
Now that we can synchronize references, we need to handle the rest of the props. The goal is simple: the Slot defines the "base" contract (styles and behavior), while the Child provides the specific implementation.
However, we can't just spread props blindly. We have to decide which props should be overridden and which should be merged.
-
Prop Prioritization: Default vs. Intentionality
In the
Slotpattern, we treat the Slot as the provider of defaults and theChildas the source of truth.
TheSlotdefines the base design system requirements (likearia-/data-attributes or styles), but the child element represents the specific use case. If there is a conflict, the child’s props should take precedence because they represent the developer's intentional choice for that specific implementation. -
The three special merge cases
While most props follow a "last one wins" rule, three specific types of props require a more surgical approach to avoid losing functionality:
Prop Type Merging Strategy Why? CSS Classes Concatenation We want to keep the Slot's layout styles and the child's custom utility classes. Inline Styles Object Merging To ensure styles from the Slot doesn't wipe out specific ones from the child. Event Handlers Chaining We need the Slot's default behavior (e.g., show an alert) and the child's specific action (e.g., onClick) to both fire.
To achieve this, we can create a mergeProps utility. Here is how we handle them:
function mergeProps(slotProps: AnyProps, childProps: AnyProps) {
const overrideProps = { ...childProps };
for (const propName in childProps) {
const slotValue = slotProps[propName];
const childValue = childProps[propName];
// Check if the current prop is an Event Handler
const isEventHandler = /^on[A-Z]/.test(propName);
if (isEventHandler && slotValue && childValue) {
overrideProps[propName] = (...args: unknown[]) => {
childValue(...args);
slotValue(...args);
};
continue;
}
// If both have the same CSS property, we prioritize the child's one
if (propName === 'style') {
overrideProps[propName] = { ...slotValue, ...childValue };
continue;
}
if (propName === 'className') {
overrideProps[propName] = [slotValue, childValue]
.filter(Boolean)
.join(' ');
}
}
return { ...slotProps, ...overrideProps };
}
💡 Tip:
When chaining event handlers, it’s often best practice to call the child’s handler first. If the child calls
event.preventDefault(), the Slot can then decide whether or not to proceed with its default behavior.
The Transformation: Cloning the Child
Now that we have our utility functions for merging props and combining refs, it’s time to build the component that performs the "transformation”.
The Slot is essentially a transparent wrapper. Its job isn't to render a <div> or a <span> of its own, but to "possess" its child. We take the setup defined on the Slot (styles, event handlers, and refs) and inject them directly into the child element. To the browser, the Slot doesn't exist—only the augmented child does.
How it works
To achieve this, we use React.cloneElement. This allows us to take the child component (the "slottable" element) and return a new version of it that merges the Slot’s properties with its own.
Key Benefits of this Approach:
- Zero DOM Pollution: You won't see any extra wrapper tags in your inspector.
-
Flat Hierarchy: The child remains the top-level element, preserving CSS selectors like
:first-child, flexbox layouts and so on. -
Prop Injection: We effectively "teleport" the design system logic from the Slot directly into the underlying element (be it a native
<button>, a Next.js<Link>, or a custom component).
type SlotProps = React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>;
function getElementRef(
element: React.ReactElement,
): React.Ref<unknown> | undefined {
return (element.props as { ref?: React.Ref<unknown> }).ref;
}
function createSlotClone(ownerName: string) {
const SlotClone = ({ children, ...slotProps }: SlotProps) => {
// Verify that 'children' is a single, valid React element (not a React Node)
if (React.isValidElement(children)) {
const childrenRef = getElementRef(children);
const mergedProps = mergeProps(slotProps, children.props as any);
if (children.type !== React.Fragment) {
mergedProps.ref = ref ? composeRefs(ref, childrenRef) : childrenRef;
}
return React.cloneElement(children, mergedProps);
}
// Fallback: If there's more than one child, we call React.Children.only.
// This will throw a descriptive error to enforce the "single-child" contract.
// If no children exist, we simply render nothing.
return React.Children.count(children) > 1
? React.Children.only(null)
: null;
};
// Attach the displayName for easier debugging in DevTools
SlotClone.displayName = `${ownerName}.SlotClone`;
return SlotClone;
}
Putting it Together: The Public Slot API
Now that we have the internal logic to handle cloning, refs, and props, we need to expose a clean, usable component. We do this using a Factory Function.
This factory, createSlot, generates a Slot component specifically tailored for a specific part of your design system (like a Button or a Card). It acts as the "entry point" for the pattern, delegating the heavy lifting to the SlotClone we built earlier.
function createSlot(ownerName: string) {
// Initialize the internal clone logic with the owner's name
const SlotClone = createSlotClone(ownerName);
// This is the actual component the developer will use
const Slot = ({ children, ...slotProps }: SlotProps) => {
return <SlotClone {...slotProps}>{children}</SlotClone>;
};
// Attach the displayName for easier debugging in DevTools
Slot.displayName = `${ownerName}.Slot`;
return Slot;
}
// Create and export the Slot component
export const Slot = createSlot('Slot');
Why this separation matters
By separating Slot from SlotClone, we create a clean boundary:
- The Slot: Is the simple interface. It accepts props and children just like any other React component.
- The SlotClone: Is the "engine." It handles the complex work of merging styles, chaining event handlers, and synchronizing refs.
This structure makes our code highly maintainable. If we ever need to change how props are merged, we only change the internal logic, and the public Slot API remains exactly the same.
The "Slottable" Component: Handling Complex Content
As the components grow, they often need more than just a single text string. In a Button, you might have an icon that should always be there, regardless of whether the button renders as a <button>, an <a>, or a <Link>.
The problem? If we pass multiple children to our Slot, the cloning engine will fail because it doesn't know which child is the "target" and which is just extra UI (like the icon).
// button.tsx
import React from 'react';
import { Slot } from '../slot';
interface Props {
children?: React.ReactNode;
icon?: React.ReactNode;
asChild?: boolean;
}
export function Button({ children, icon, asChild }: Props) {
const Component = asChild ? Slot : 'button';
return (
<Component
className='flex items-center gap-2 bg-blue-500 rounded-full px-4 py-2 text-white hover:bg-blue-600'
onClick={() => alert('Hey!')}
>
{children}
{icon}
</Component>
);
}
// component.tsx
import { BoardIcon } from '@icons/outline';
import { Button } from './button';
export default async function Home() {
return (
<Button icon={<BoardIcon />} asChild>
<div>Test</div>
</Button>
);
}
The Solution: The Slottable Marker
We need a way to "mark" the specific child that should receive the Slot's properties. We do this by creating a tiny utility component called Slottable. It doesn't do anything on its own; it simply acts as a lighthouse for our Slot engine.
Just like we used a factory for our Slot, we use a factory for the Slottable marker. This allows us to maintain a naming convention that links the two components together.
// Use a symbol as unique identifier and to ensure there are zero
// collisions with other libraries or user-defined properties
const SLOTTABLE_ID = Symbol('SLOTTABLE_ID');
export function createSlottable(ownerName: string) {
const Slottable = ({ children }: { children: React.ReactNode }) => (
<>{children}</>
);
Slottable.displayName = `${ownerName}.Slottable`;
Slottable.__id = SLOTTABLE_ID;
return Slottable;
}
function isSlottable(
child: any,
): child is React.ReactElement<SlotProps, typeof Slottable> {
return (
React.isValidElement(child) &&
typeof child.type === 'function' &&
'__id' in child.type &&
(child.type as any).__id === SLOTTABLE_ID
);
}
💡 Tip: Type Predicates for robust identification
You’ll notice our
isSlottablefunction uses a specific TypeScript syntax:child is React.ReactElement. This is known as a Type Predicate.
By using a Type Predicate combined with our uniqueSLOTTABLE_ID, we achieve two critical things:
- Runtime Certainty: We ensure that the component we are about to clone is definitely our
Slottablemarker and not just a random div or a third-party component that happens to have a similar structure.- Compile-Time Safety: Once this function returns
true, TypeScript "narrows" the type. This means inside ourifblock, the compiler knows exactly what properties are available on the child, allowing us to accesschild.props.childrenwithout using unsafe type assertions (likeas any).
Integrating it into the Slot Logic
With our isSlottable helper ready, we can now integrate it into the createSlot factory. This allows the Slot to scan its children, identify the 'real' element intended for cloning, and correctly re-map siblings like icons or labels.:
function createSlot(ownerName: string) {
const SlotClone = createSlotClone(ownerName);
const Slot = ({ children, ...slotProps }: SlotProps) => {
// Look for our Slottable component
const childrenArray = React.Children.toArray(children);
const slottable = childrenArray.find(isSlottable);
if (slottable) {
// The 'newElement' is the actual component
// wrapped inside the <Slottable> marker
const newElement = slottable.props.children;
const newChildren = childrenArray.map((child) => {
if (child === slottable) {
// Instead of rendering the Slottable or the newElement here,
// we only return the newElement's children
// This avoids duplicating the element when we clone it later
return React.isValidElement(newElement)
? (newElement.props as { children: React.ReactNode }).children
: null;
}
return child;
});
// Clone the newElement, but inject the 'newChildren'
// (which now includes the original siblings) back into it
return (
<SlotClone {...slotProps}>
{React.isValidElement(newElement)
? React.cloneElement(newElement, undefined, newChildren)
: null}
</SlotClone>
);
}
return <SlotClone {...slotProps}>{children}</SlotClone>;
};
Slot.displayName = `${ownerName}.Slot`;
return Slot;
}
// Create and export the Slottable component
export const Slottable = createSlottable('Slottable');
Putting it into Practice: The Polymorphic Button
Now that we have built the Slot and Slottable infrastructure, let's look at how this simplifies our daily development. We can create a single Button component that adapts to its context without messy conditional logic.
1. The Button Component
Notice how we use the asChild prop to determine whether to render our custom Slot or a native button. We also use the Slottable marker to ensure that even when the button is swapped for a link, our internal layout remains stable.
// button.tsx
import React from 'react';
import { createSlot, createSlottable } from '../slot';
const Slot = createSlot('Button');
const Slottable = createSlottable('Button');
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
icon?: React.ReactNode;
}
export function Button({ children, icon, asChild, ...props }: Props) {
// If asChild is true, we use our Slot engine; otherwise, a native button.
const Component = asChild ? Slot : 'button';
return (
<Component
{...props}
className="flex items-center gap-2 bg-blue-500 rounded-full px-4 py-2 text-white hover:bg-blue-600 font-semibold"
onClick={() => alert('Logic from Button component!')}
>
<Slottable>{children}</Slottable>
{icon}
</Component>
);
}
2. Three Implementations, One Component
Now, look at how the consumer can use this Button in three entirely different ways. Regardless of the underlying element, the CSS classes and the onClick alert will be applied to all of them.
// app.tsx
import { BoardIcon } from '@icons/outline';
import { Button } from './button';
import Link from 'next/link';
export default function Page() {
return (
<div className="flex flex-col gap-4">
<Button icon={<BoardIcon />}>
Native Button
</Button>
<Button asChild icon={<BoardIcon />}>
<a href="https://google.com" target="_blank">
External Link
</a>
</Button>
<Button asChild icon={<BoardIcon />}>
<Link href="/dashboard">
Client-side Navigation
</Link>
</Button>
</div>
);
}
Conclusion
The Slot pattern is a super useful tool for design systems. By leveraging Type Predicates, and Symbol-based identification, we have created a component that:
- Is Type-Safe: TypeScript knows exactly what is happening at every step.
- Is Highly Reusable: Consumers have total control over the underlying element while the design system maintains visual and behavioral consistency.




Top comments (0)