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