ComponentsGENERALFRAMEWORK-SPECIFIC

Composition and Slots

Children are the mechanism that stops a component growing a prop for every possible variation. Compound components and scoped slots are what you reach for when the frame needs to talk to the filling.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

When a component needs to vary, should the caller pass another prop or pass content?

The user intent

Someone needs a panel that looks like every other panel in the product but contains something no panel has contained before. They should not have to fork it.

The obvious build

Add a prop. The card needs an optional footer, so add footer. It needs a badge, so add badge. Each addition is one line and looks entirely harmless at the time.

Why it breaks

Props multiply faster than variants. A card with header, subtitle, badge, icon, actions, footer and empty-state props has seven inputs that are really one input — "what goes inside" — expressed seven times.

How it breaks in a real browser
  • Props multiply faster than variants. A card with header, subtitle, badge, icon, actions, footer and empty-state props has seven inputs that are really one input — "what goes inside" — expressed seven times.
  • Node-valued props lose their place in the tree. Passing footer={<Actions />} means the footer is created in the caller's scope and rendered in the component's, which quietly changes when it is evaluated and what context it can read (Prop Drilling, Context and Global State).
  • Combinations explode. variant="danger" plus compact plus withIcon plus dismissible is sixteen visual states, and nobody has looked at more than four of them.
  • Boolean props encode decisions the caller wanted to make differently. showHeader answers "is there a header"; it cannot answer "what is in it".
  • The component ends up importing everything it might contain, so a card that supports charts pulls a charting library into every bundle that has a card (Tree Shaking).
  • Semantic structure gets fixed in the wrong place. A component that hardcodes h3 for its title is wrong on any page where the heading level is different, and heading level is a document-level concern (Document Structure and Reading Order).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Slots invert the direction of the decision. Instead of the component enumerating what may appear, the caller supplies it and the component decides only where it goes and how it is framed. The component stops needing to know the space of possibilities.
  • Content passed as children is created in the caller's scope. It reads the caller's context, closes over the caller's variables, and is already-built content by the time the component sees it — which is why passing children through a component does not make that component re-render its children in most virtual-DOM frameworks (What a Component Costs to Render).
  • Named slots exist because most components have more than one hole. The mechanism differs — a slot element and v-slot in Vue and Web Components, ng-content with a selector in Angular, snippets or named slot props in Svelte, and plain node-valued props or sub-components in React and Solid — but the shape is the same: several holes, addressed by name.
  • Compound components solve the case where the pieces need to share state: <Tabs> with <TabList>, <Tab> and <TabPanel> looks like free-form composition to the caller, while the parent quietly coordinates selection through context (Prop Drilling, Context and Global State).
  • Render props and scoped slots solve the case where the frame has data the filling needs. The component calls back into the caller with its internal state, so the caller writes the markup and the component keeps the logic. This is the framework-specific corner of composition: it is a function child in React and Solid, v-slot="{ ... }" in Vue, a snippet parameter in modern Svelte, and a template with let- bindings in Angular.

What this makes the browser do

And which of it is avoidable.

  • A slot is not an element. Rendering children where they are placed produces the same DOM as if the caller had written them inline — the composition is erased before the browser sees anything.
  • Except in Web Components, where <slot> is a real element in the shadow tree and light-DOM children are projected rather than moved. Style inheritance and selector matching behave differently there, and that difference is the entire subject of the shadow-DOM lesson (Shadow DOM and the Composed Tree).
  • A component that renders a wrapper around each slot costs one element per slot per instance. In a card grid with a hundred cards and four slots, that is four hundred nodes whose only job is to be a hook.
  • Composition can reduce browser work: content that is never passed is never created, so a card with an unused footer slot renders no footer element at all, where a showFooter prop often still renders an empty container.

Prop explosion, and what it is really asking for

Prop explosion has a recognisable trajectory. The component starts with two props. A designer asks for a badge, so a badge prop appears. Then a footer, then an icon, then footerAlign, then hideDivider. No individual step is wrong, and after twelve of them the component has become a small, badly specified layout language with no documentation.

The signal to watch for is a prop whose value is content rather than a decision. variant="danger" is a decision the component should own. footerText="Cancel anytime" is content the caller owns and has been forced to hand over through a keyhole.

Twelve props, or one slot each
Configuration
<Card
  title="Invoice 4021"
  subtitle="Due 14 March"
  badge="Overdue"
  badgeTone="danger"
  icon="receipt"
  showDivider
  footerText="Pay now"
  footerHref="/pay"
  footerAlign="right"
  compact
/>
Composition
<Card compact>
  <Card.Header>
    <Icon name="receipt" />
    <Card.Title as="h2">Invoice 4021</Card.Title>
    <Badge tone="danger">Overdue</Badge>
  </Card.Header>
  <Card.Body>Due 14 March</Card.Body>
  <Card.Footer align="right">
    <Button href="/pay">Pay now</Button>
  </Card.Footer>
</Card>

The second version removes the component's need to know what a footer can contain, which is the actual source of the prop growth. It also fixes two things the first cannot: the caller chooses the heading level, so the document outline stays correct, and the footer action is a real Button with the product's focus and keyboard behaviour rather than a string the card renders as it pleases.

When the frame needs to talk to the filling

FRAMEWORK-SPECIFICWritten as React/Solid JSX. Vue expresses the second form as <template #default="{ item, selected, toggle }">, Svelte as a snippet parameter (or a slot with let: before snippets existed), and Angular as an ng-template with let-item let-selected="selected" consumed via ngTemplateOutlet. The pattern is identical; none of this code ports across without rewriting.

Plain slots are one-directional: the caller hands content down and the component places it. That fails as soon as the caller needs something the component knows — which item is being rendered, whether the field is invalid, whether the disclosure is open.

Two patterns answer this. Compound components share state implicitly through context, so the caller writes flat markup and never sees the wiring. Scoped slots and render props share state explicitly, by handing it to a function the caller wrote. Compound components read better; scoped slots are more honest about the data flow and do not require a context provider.

Two ways to give the caller the component's state
1// Compound: coordination is private. The caller sees flat markup;
2// TabsContext carries the selected id and the generated aria ids.
3<Tabs defaultTab="billing">
4 <TabList aria-label="Account settings">
5 <Tab id="billing">Billing</Tab>
6 <Tab id="team">Team</Tab>
7 </TabList>
8 <TabPanel id="billing"><BillingForm /></TabPanel>
9 <TabPanel id="team"><TeamList /></TabPanel>
10</Tabs>
11// Tabs owns roving tabindex, arrow keys, aria-controls and
12// aria-labelledby across pieces the caller never wired together.
13
14// Scoped: coordination is explicit. The component owns fetching,
15// selection and keyboard handling; the caller owns every element.
16<DataList items={invoices}>
17 {(item, { selected, toggle }) => (
18 <li>
19 <Checkbox checked={selected} onChange={toggle}
20 aria-label={`Select invoice ${item.ref}`} />
21 <InvoiceSummary invoice={item} />
22 </li>
23 )}
24</DataList>

Both keep the logic in one place and the markup at the call site. The difference is who can see the wiring: the compound version hides an exported-by-accident context and fails confusingly when a piece is used outside its parent; the scoped version cannot be misplaced, and is harder to read once nested.

The same idea in five dialects

FRAMEWORK-SPECIFICNames and syntax as of the current major versions of each framework. Svelte in particular changed this surface between versions — snippets replaced slot syntax — so the row is a pointer to the concept, not a syntax reference to copy from.

This is the part of component architecture that is genuinely not portable, and pretending otherwise is how a team writes React idioms in Vue and wonders why the ergonomics are bad. The table names the mechanism in each framework so that a pattern learned in one can be looked up rather than transplanted.

None of these is better. They differ in whether slots are a first-class concept, in whether the compiler can see them, and therefore in what the framework can optimise. Svelte and Angular can do more at compile time because the slot is a declared construct; React and Solid trade that for slots being ordinary values you can store, forward and wrap.

FrameworkDefault slotNamed slotsScoped / render-prop formWhere it bites
Reactprops.childrenNode-valued props, or Card.Header sub-componentsA function as children, or a render propNo slot concept means no compiler help: a forgotten sub-component is a runtime context error, not a build error
Vue<slot /><slot name="header" /> with v-slot:header<slot :item="item" /> consumed as v-slot="{ item }"Slot content is compiled into a function, so reasoning about when it evaluates differs from React's already-built elements
SvelteSnippet passed as content (formerly <slot />)Named snippets (formerly <slot name="...">)Snippet parameters (formerly let: on a slot)The API changed across major versions, so examples found online may not match the version you are on
Angular<ng-content /><ng-content select=".header" />ng-template with let- bindings via ngTemplateOutletProjection matches by CSS selector, so restructuring the caller's markup can silently stop matching
Solidprops.childrenNode-valued props or sub-componentsA function as childrenChildren are lazily evaluated getters; accessing props.children more than once has different cost implications than in React
Web Components<slot> in the shadow root<slot name="header">No built-in equivalent — pass callbacks or eventsSlotted nodes stay in the light DOM, so style scoping and ::slotted rules behave unlike any framework slot (Shadow DOM and the Composed Tree)

How to build it

Most important first.

  • Default to children. One unnamed slot handles the majority of components, and it is the cheapest possible API — nothing to learn, nothing to name.
  • Add named slots when there are genuinely several holes with different framing. Name them by position and purpose, not by content type: header, actions, aside rather than titleText, buttonList.
  • Reach for compound components when the pieces must agree about state, and keep the coordination private. The caller composes; the parent coordinates; the context between them is an implementation detail and should not be exported (Who Owns This State?).
  • Reach for a render prop or scoped slot only when the caller needs the component's internal state to build its markup — a list that exposes each item, a form that exposes validation state. It is the most powerful and least readable option, so it should be the last one you try.
  • Constrain slots where correctness demands it. A Tabs that accepts arbitrary children cannot guarantee the tablist/tab/tabpanel relationship; either validate the shape or provide the sub-components and document that they are required (The Rules of ARIA).
  • Let the caller own the heading level, either by accepting a level prop or by taking the whole heading as slot content. Baked-in heading levels break document outline on the first page that nests your component differently (Document Structure and Reading Order).
  • Keep configuration props for things that are genuinely component decisions — spacing scale, elevation, tone — and move everything that is content into slots.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Slots move responsibility for semantics to the caller. If the frame renders a section and the caller supplies the heading, the frame should require the heading rather than assume it, or the landmark ends up unlabelled (Semantics Are Behaviour).
  • Patterns with required ancestry cannot be freely composed. tab must be inside tablist, option inside listbox, row inside rowgroup. A slot that wraps children in a div severs those relationships even though the visual output is identical (The Rules of ARIA).
  • Compound components should wire the ARIA relationships themselves — generating ids, setting aria-controls and aria-labelledby across the pieces. That is the main reason they are worth their complexity (Accessible Component Patterns).
  • Focus order follows DOM order, and slots preserve DOM order — unless the component reorders visually with CSS, at which point tab order and reading order disagree with the visual order for everyone (Keyboard Operability).
  • A slot named actions implies a group of controls. If the component renders them in a div, keyboard users tab through each one; if the pattern calls for a toolbar with arrow-key navigation, the component owns that and the slot must be constrained accordingly.
  • Heading level is an accessibility concern, not a styling one. Screen-reader users navigate by heading; a card that always emits h3 produces an outline with gaps or repeats depending on where it lands (Document Structure and Reading Order).

What can go wrong

Failure modes
  • The slot that must contain a specific thing but says so nowhere. A caller passes a div where a Tab was required, the roles break, and nothing errors.
  • Named slots that overlap. header and title both exist, both render in the same region, and precedence is decided by whichever the implementation checks first.
  • Compound components used out of order or across a boundary they cannot see through: <Tab> rendered outside its <Tabs> throws on a missing context, or worse, silently reads a default.
  • Render props nested three deep, producing a pyramid of closures where each level shadows the one above and the actual markup is forty columns to the right.
  • The mitigation failing: you replaced props with slots, and callers now compose visually inconsistent cards because nothing constrains them. Composition trades enforcement for flexibility, and the enforcement was doing something.
  • Slot content created eagerly. Passing <ExpensiveChart /> as a prop to a collapsed accordion builds the element every render even while the panel is closed, unless the API takes a function instead (Lazy Loading).
What can arrive out of order
  • Slot content that fetches on mount races with the frame's own loading state, so a panel can show "ready" while its contents are still loading (Loading, Error, Empty — The States You Did Not Render).
  • Compound components that register themselves with the parent on mount register in DOM order, which is not necessarily the order the caller wrote if any of them render conditionally.
Security
  • Slots are the safe way to accept rich content. Passing nodes means the framework does the escaping; accepting an HTML string means the component becomes an injection sink for every caller (Cross-Site Scripting).
  • A component that renders slot content into a template or clones it into a portal should not also stringify it. Round-tripping through HTML is where escaping gets lost (Sanitization and Trusted HTML).
  • Composition does not sandbox. Content passed into a slot runs with the page's full authority — a slot is not a boundary an untrusted third party can be placed behind (Third-Party Scripts and the Supply Chain).
  • Compound components sharing state through context expose that state to anything rendered inside, including caller-supplied content. Do not put anything sensitive in a context whose provider wraps arbitrary children.
Misreads
  • "Slots are just props that happen to be JSX." They differ in evaluation scope, in what context they can read, and in whether the framework treats them as unchanged across a parent re-render. Those are not cosmetic differences.
  • "Compound components are the modern way to build everything." They are the answer to shared state between composed pieces. Used where a single component would do, they are five files and an exported context for one dropdown.
  • "Render props are obsolete." The pattern — the component hands you its state, you write the markup — is alive in every framework under a different name. Only the React-specific ergonomics changed (The React Mental Model).
  • "More flexible is better." A component that can render anything has no contract, and a design system with no contract is a folder of components (What a Component Owes Its Caller).
  • "If I pass children, nothing re-renders." Whether children are re-created depends on where they were created and which framework you are in. Do not assume it; measure it.

Measuring it, and what changes in the field

How you would see this
  • Count the props on your most-used components and classify each as content or configuration. A high content-prop ratio is the measurable form of "this wanted slots".
  • Count the boolean props. Every boolean doubles the theoretical state space, and a component with five is claiming thirty-two behaviours it has not tested.
  • Check what a component pulls into the bundle. A layout component that imports feature code is a component that should have taken it as children (Bundle Analysis).
  • Profile a component that takes children through a re-render of its parent. Whether children re-render depends on whether they were created in the caller's scope, and the profiler will tell you which happened (What a Component Costs to Render).
  • Read the accessibility tree of a composed instance. Slots are where required ancestry breaks, and the tree is where it shows (The Accessibility Tree).
Slow device, slow network, large data, old tab
  • In a design system consumed by other teams, slots reduce your support load and increase the variance in what ships. That trade is usually worth it and should be a conscious one (Design Systems).
  • In server rendering, slot content is evaluated on the server too, so an expensive child passed to a collapsed panel is server work as well as client work (Server-Side Rendering).
  • With server components, the boundary between what runs where cuts across composition: a server component can be passed as children into a client component, and that is the main reason the pattern exists (Server Components).
  • On a slow device, extra wrapper elements per slot become measurable inside lists. Elsewhere they are noise (List Virtualization).
  • Under translation, slots that build a sentence from several holes break, because word order is not universal. Take the whole string, with placeholders, and format it (Internationalization).
What this costs
  • Slots trade enforcement for flexibility. A variant prop guarantees one of three looks; an open slot guarantees nothing, and someone will put a table in your card.
  • Compound components are more code and more concepts than one component with props. They pay off when the pieces genuinely need to coordinate and are overhead when they do not.
  • Render props and scoped slots are the most flexible and the least readable, and they are the most framework-divergent part of this lesson — code written with one framework's version does not translate mechanically to another's.
  • Moving structure to the caller moves accessibility responsibility to the caller too, unless the component constrains the slot. Flexibility without constraint is how a design system ships inaccessible compositions with its own name on them.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe underlying idea — the caller supplies content, the component supplies the frame — exists in every component model including plain Web Components, where <slot> is part of the shadow DOM spec rather than a framework feature.
  • FRAMEWORK-SPECIFICThe spelling and the semantics diverge sharply. React and Solid use children plus node-valued props and function children, with no first-class slot concept. Vue has real named slots and scoped slots via v-slot. Svelte moved from <slot> elements to snippets, so version matters within the same framework. Angular projects with ng-content and a CSS selector, and exposes scoped data through ngTemplateOutlet with let- bindings. Code does not port between them mechanically.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Software Design — the open/closed principle in its most literal form: a component open to extension through content, closed to modification through props.
  • Programming Languages & Runtime Internals — whether slot content is an already-built value or a thunk the component invokes is a language-level distinction, and it is the reason evaluation timing differs across frameworks.