Skip to main content

Slots: Compose Components With Intelligent Placement

The slot pattern designates named areas where consumers can inject content, similar to Vue slots or web component <slot> elements. Rather than using ambiguous props for every possible piece of content (title, subtitle, actions, footer), you define semantic slots and let consumers place content into them. A card component might have slots for header, body, and actions, making the structure and intent crystal clear. React doesn't have built-in syntax like Vue's <slot>, but you can achieve the same effect with props and clever composition.

After years of managing sprawling prop APIs, I switched to slots whenever a component had more than three content areas. The API became self-documenting: consumers could see exactly where content would appear.

Understanding Slots: Named Placement Areas​

Basic Slot Pattern with Props​

// Card with named content slots
function Card({ header, body, footer }) {
return (
<div className="card" style={{ border: '1px solid #ddd', padding: '1rem' }}>
{header && <div className="card-header">{header}</div>}
<div className="card-body">{body}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}

// Usage: pass content to each slot
export function Example() {
return (
<Card
header={<h2>Card Title</h2>}
body={<p>Card content goes here</p>}
footer={<button>Action</button>}
/>
);
}

This pattern is clearer than passing generic children and relying on consumers to order elements correctly.

Compound Components as Slots​

A more semantic approach uses compound components to represent slots:

// Card with slot components
function Card({ children }) {
return (
<div className="card" style={{ border: '1px solid #ddd', padding: '1rem' }}>
{children}
</div>
);
}

// Named slot components
Card.Header = function({ children }) {
return <div className="card-header" style={{ marginBottom: '1rem' }}>{children}</div>;
};

Card.Body = function({ children }) {
return <div className="card-body">{children}</div>;
};

Card.Footer = function({ children }) {
return <div className="card-footer" style={{ marginTop: '1rem' }}>{children}</div>;
};

// Usage: clear, semantic, self-documenting
export function Example() {
return (
<Card>
<Card.Header>
<h2>Card Title</h2>
</Card.Header>
<Card.Body>
<p>Card content</p>
</Card.Body>
<Card.Footer>
<button>Cancel</button>
<button>Save</button>
</Card.Footer>
</Card>
);
}

This combines the clarity of slot semantics with React's component model. Consumers immediately see the structure and intent.

Real-World: Dialog with Multiple Slots​

A dialog (modal) is perfect for the slot pattern because it typically has several distinct areas:

// Dialog component with named slots
function Dialog({ children, isOpen, onClose }) {
if (!isOpen) return null;

return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={onClose}
>
<div
style={{
background: 'white',
borderRadius: '8px',
overflow: 'hidden',
minWidth: '400px',
}}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
}

// Title slot
Dialog.Title = function({ children }) {
return (
<div style={{ padding: '1.5rem', borderBottom: '1px solid #eee' }}>
<h2 style={{ margin: 0 }}>{children}</h2>
</div>
);
};

// Content slot
Dialog.Content = function({ children }) {
return <div style={{ padding: '1.5rem' }}>{children}</div>;
};

// Actions slot (typically buttons)
Dialog.Actions = function({ children }) {
return (
<div
style={{
padding: '1rem',
borderTop: '1px solid #eee',
display: 'flex',
gap: '0.5rem',
justifyContent: 'flex-end',
}}
>
{children}
</div>
);
};

// Usage: semantic, clear structure
export function ConfirmDialog() {
const [isOpen, setIsOpen] = React.useState(false);

return (
<>
<button onClick={() => setIsOpen(true)}>Delete Item</button>

<Dialog isOpen={isOpen} onClose={() => setIsOpen(false)}>
<Dialog.Title>Confirm Delete</Dialog.Title>
<Dialog.Content>
Are you sure? This action cannot be undone.
</Dialog.Content>
<Dialog.Actions>
<button onClick={() => setIsOpen(false)}>Cancel</button>
<button
onClick={() => {
// Delete logic
setIsOpen(false);
}}
style={{ background: '#dc3545', color: 'white', padding: '0.5rem 1rem' }}
>
Delete
</button>
</Dialog.Actions>
</Dialog>
</>
);
}

The Dialog component doesn't decide how the title, content, or actions look—consumers define that by placing content in the appropriate slots.

Flexible Slots with Fallbacks​

Sometimes you want to provide default content if a slot isn't used:

// Form with optional slots and defaults
function Form({ children, onSubmit }) {
return <form onSubmit={onSubmit}>{children}</form>;
}

Form.Group = function({ children, label }) {
return (
<div style={{ marginBottom: '1rem' }}>
{label && <label style={{ display: 'block', marginBottom: '0.5rem' }}>{label}</label>}
{children}
</div>
);
};

Form.SubmitButton = function({ children = 'Submit', ...props }) {
return (
<button
type="submit"
style={{
background: '#007bff',
color: 'white',
padding: '0.5rem 1rem',
border: 'none',
borderRadius: '4px',
}}
{...props}
>
{children}
</button>
);
};

// Usage: submit button has a sensible default
export function SimpleForm() {
return (
<Form onSubmit={(e) => { e.preventDefault(); console.log('submitted'); }}>
<Form.Group label="Name">
<input type="text" placeholder="Enter your name" />
</Form.Group>

<Form.Group label="Email">
<input type="email" placeholder="Enter your email" />
</Form.Group>

{/* Submit button uses default text */}
<Form.SubmitButton />
</Form>
);
}

// Usage: custom submit button text
export function CustomForm() {
return (
<Form onSubmit={(e) => { e.preventDefault(); }}>
<Form.Group label="Name">
<input type="text" />
</Form.Group>

{/* Override the default button text */}
<Form.SubmitButton>Register Now</Form.SubmitButton>
</Form>
);
}

Slots with Context Sharing​

Combine slots with Context to share state across slot components:

// Tabs with slot pattern
const TabsContext = React.createContext();

function Tabs({ children, defaultActive = 0 }) {
const [activeIndex, setActiveIndex] = React.useState(defaultActive);

return (
<TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
<div>{children}</div>
</TabsContext.Provider>
);
}

Tabs.List = function({ children }) {
return <div role="tablist" style={{ display: 'flex', borderBottom: '1px solid #ddd' }}>{children}</div>;
};

Tabs.Trigger = function({ children, index }) {
const { activeIndex, setActiveIndex } = React.useContext(TabsContext);

return (
<button
onClick={() => setActiveIndex(index)}
role="tab"
aria-selected={activeIndex === index}
style={{
padding: '0.5rem 1rem',
background: activeIndex === index ? '#007bff' : 'transparent',
color: activeIndex === index ? 'white' : 'black',
border: 'none',
cursor: 'pointer',
}}
>
{children}
</button>
);
};

Tabs.Panel = function({ children, index }) {
const { activeIndex } = React.useContext(TabsContext);

return activeIndex === index && <div style={{ padding: '1rem' }}>{children}</div>;
};

// Usage: slots with shared context
export function DocumentTabs() {
return (
<Tabs defaultActive={0}>
<Tabs.List>
<Tabs.Trigger index={0}>Profile</Tabs.Trigger>
<Tabs.Trigger index={1}>Settings</Tabs.Trigger>
<Tabs.Trigger index={2}>Billing</Tabs.Trigger>
</Tabs.List>

<Tabs.Panel index={0}>Profile information</Tabs.Panel>
<Tabs.Panel index={1}>Account settings</Tabs.Panel>
<Tabs.Panel index={2}>Billing details</Tabs.Panel>
</Tabs>
);
}

Slots vs. Render Props vs. Compound Components​

PatternSyntaxBest For
Slots (compound)<Card><Card.Header>...</Card.Header></Card>Semantic, multi-part components
Render Props<Card header={() => ...} body={() => ...} />Flexible rendering, dynamic content
Compound<Dialog.Header> inside <Dialog>Cohesive, related sub-components
Props<Card title="..." subtitle="..." />Simple, few content areas

Key Takeaways​

  • Slots define named areas where consumers inject content, making component structure clear and self-documenting.
  • In React, slots are typically implemented as compound components (e.g., <Card.Header>) or named props.
  • Slots work especially well for layout components (cards, dialogs, panels) where the structure is fixed but content varies.
  • Combining slots with Context lets multiple slot components share state without prop drilling.
  • Slots provide semantic clarity that generic children or ambiguous props cannot match.

Frequently Asked Questions​

Is slot pattern the same as compound components?​

Slots are usually implemented using compound components. Slots are the pattern (designated placement areas); compound components are the mechanism. The terms are often used interchangeably, but compound components can exist without slots (e.g., a command palette where all children are treated the same).

Can I use slots with render props?​

Yes. A slot component can accept a render prop, giving you both patterns' benefits. Example: <Dialog.Actions render={(state) => <button>...</button>} />.

What if I don't know all the slots a component needs?​

Use a more flexible pattern (render props or headless components). Slots work best when the component structure is relatively fixed and predictable.

How do I handle optional slots?​

Check if the slot component was rendered. In compound form, React.Children.map lets you find specific slot types and render them conditionally. Or provide sensible defaults.

Should I use slots for every component?​

No. Slots add complexity and are best for components with 3+ distinct content areas. Simple components with 1-2 content areas are fine with basic props or children.

Further Reading​