pnpm dlx shadcn@latest add https://react-slot.vercel.app/r/slot.jsonReact Slot
Bring Vue-style slots to React
Fine-grained control over component composition
Installation
CLI
Manual
Copy the content of Slot.tsx
'use client';
import * as React from 'react';
const DEFAULT_SLOT = 'DEFAULT';
type Slot = React.ReactNode;
type Slots = Record<string, Slot>;
type SlotName = typeof DEFAULT_SLOT | (string & {});
type SlotContext = { scope: string };
const SlotContext = React.createContext<SlotContext | null>(null);
export function useSlots(caller: string) {
const context = React.use(SlotContext);
if (!context) {
throw new Error(`${caller} must be used inside a Slottable`);
}
return context;
}
type Slottable = {
scope?: string;
strictDefault?: boolean;
children: React.ReactNode;
};
export function Slottable({ children, scope = 'Slottable', strictDefault = true }: Slottable) {
const { slots, template, defaultSlot } = React.useMemo(() => {
const slots: Slots = {};
const defaultSlot: React.ReactNode[] = [];
let template: TemplateElement | undefined;
React.Children.forEach(children, child => {
if (isSlot(child)) {
const { name: slot = DEFAULT_SLOT, children } = child.props;
if (slot in slots) {
throw new Error(`Duplicate slot: ${slot} was found`);
}
slots[slot] = children;
return;
}
if (isTemplate(child)) {
if (template) {
throw new Error(
'A duplicate Template was found. Only one direct Template child is allowed',
);
}
template = child;
return;
}
defaultSlot.push(child);
});
return {
slots,
template,
defaultSlot,
};
}, [children]);
if (template == null) {
throw new Error(`A single template is required as a direct child of ${scope}`);
}
const [tree, foundDefault] = React.useMemo(
() =>
traverse(template, {
placeholder(placeholder) {
const slot = placeholder.props.name ?? DEFAULT_SLOT;
const isDefaultSlot = slot === DEFAULT_SLOT;
const outlet = isDefaultSlot ? defaultSlot : slots[slot];
return outlet ?? null;
},
}),
[children],
);
const renderDefault = !strictDefault && !foundDefault;
const context: SlotContext = { scope };
return (
<SlotContext value={context}>
{tree}
{renderDefault && defaultSlot}
</SlotContext>
);
}
type Visitors = {
placeholder: (element: SlotPlaceholderElement) => React.ReactNode;
};
function traverse(template: TemplateElement, visitors: Visitors) {
const { placeholder } = visitors;
let foundDefault = false;
const traverseChildren = (children: React.ReactNode): React.ReactNode[] => {
return (
React.Children.map(children, child => {
if (isSlotPlaceholder(child)) {
const slot = child.props.name ?? DEFAULT_SLOT;
const isDefault = slot === DEFAULT_SLOT;
if (isDefault && !foundDefault) foundDefault = true;
return placeholder(child);
}
if (isParent(child)) {
const { children, ...props } = child.props;
return React.cloneElement(child, props, ...traverseChildren(children));
}
return child;
}) ?? []
);
};
return [traverseChildren(template.props.children), foundDefault as boolean] as const;
}
const TemplateContext = React.createContext(false);
type TemplateProps = {
children: React.ReactNode;
};
export function Template({ children }: TemplateProps) {
const hasTemplate = React.use(TemplateContext);
if (hasTemplate) {
throw new Error('A duplicate Template was found. Only one direct Template child is allowed');
}
return <TemplateContext.Provider value={true}>{children}</TemplateContext.Provider>;
}
type SlotPlaceholderProps = {
name?: SlotName;
children?: React.ReactNode;
};
export function SlotPlaceholder(_props: SlotPlaceholderProps) {
return null;
}
type SlotProps = {
name?: SlotName;
children: React.ReactNode;
};
export function Slot({ name = DEFAULT_SLOT, children }: SlotProps) {
const { scope } = useSlots(`Slot: ${name}`);
React.useEffect(() => {
throw new Error(`Slot: ${name} must be a direct child of ${scope}`);
}, [name, scope, children]);
return null;
}
type SlotElement = React.ReactElement<SlotProps>;
function isSlot(child: React.ReactNode): child is SlotElement {
const isElement = React.isValidElement<typeof Slot>(child);
return isElement && child.type === Slot;
}
type TemplateElement = React.ReactElement<TemplateProps>;
function isTemplate(child: React.ReactNode): child is TemplateElement {
const isElement = React.isValidElement<typeof Template>(child);
return isElement && child.type === Template;
}
type SlotPlaceholderElement = React.ReactElement<SlotPlaceholderProps>;
function isSlotPlaceholder(child: React.ReactNode): child is SlotPlaceholderElement {
const isElement = React.isValidElement<typeof SlotPlaceholder>(child);
return isElement && child.type === SlotPlaceholder;
}
type ParentElement = React.ReactElement<Required<React.PropsWithChildren>>;
function isParent(child: React.ReactNode): child is ParentElement {
const isElement = React.isValidElement<React.PropsWithChildren>(child);
return isElement && child.props.children !== undefined;
}
// TODO: later add ref to the Provider after
// TODO: support for Fragment refs' is added
Usage
action-button.tsx
import { Button } from '@/components/ui/button';
import { Slottable, Template, SlotPlaceholder } from '@/components/slot';
type ActionButtonProps = React.ComponentProps<typeof Button>;
export function ActionButton({ type = 'button', children, ...props }: ActionButtonProps) {
return (
<Button type={type} {...props}>
<Slottable>
{children}
<Template>
<SlotPlaceholder name='before' />
<SlotPlaceholder />
<SlotPlaceholder name='after' />
</Template>
</Slottable>
</Button>
);
}
App.tsx
'use client';
import { CheckIcon } from 'lucide-react';
import { ActionButton } from '@/components/action-button';
import { Slot } from '@/components/slot';
export function App() {
return (
<div className='flex flex-col items-center justify-center gap-4 p-4'>
<ActionButton>
<Slot name='before'>
<CheckIcon />
</Slot>
Submit
</ActionButton>
<ActionButton>
<Slot name='after'>
<CheckIcon />
</Slot>
Submit
</ActionButton>
<ActionButton>
<Slot name='before'>
<CheckIcon />
</Slot>
Submit
<Slot name='after'>
<CheckIcon />
</Slot>
</ActionButton>
</div>
);
}
Props
Slottable
Template
Slot
SlotPlaceholder
Rules & Behaviour
SSR Compatibility
When using this library with Next.js, your components must be client components. Make sure to mark them with 'use client' at the top of the file to avoid confusing or misleading errors.
Default Slot
By default, any content that is not wrapped in a <Slot /> is treated as part of the <Slot name='DEFAULT' /> slot and is appended to the end of the rendered children.
When strictDefault is set to true, the default slot is ignored unless it is explicitly defined using either <SlotPlaceholder /> or <SlotPlaceholder name='DEFAULT' />.
When strictDefault is set to false, the default slot behaves more flexibly:
- If a default slot placeholder is explicitly defined, the default slot is rendered at that location.
- If no default slot placeholder is defined, the default slot content is appended to the end of the children.
Template
A single <Template /> component is required as a direct child of <Slottable />
This component defines the final rendered structure and determines where each slot is placed using <SlotPlaceholder /> components.
If you already have a <Template /> as a direct child of <Slottable />, but still encounter the error A single template is required as a direct child of Slottable, make sure your component is marked as a client component by adding 'use client' at the top of the file.