Markup Between the Tags: The Outlet Mechanism Behind <slot />
A BaseCard component owns the chrome — surface, padding, elevation — but every usage needs different contents: one screen puts a chart inside, another a table, a third a form. The prop channel is wrong for this job; Vue's answer is content projection:
<!-- BaseCard.vue -->
<template>
<div class="card">
<slot /> <!-- the parent decides what appears here -->
</div>
</template>
<!-- parent -->
<BaseCard>
<h2>Revenue this quarter</h2>
<RevenueChart :series="series" />
</BaseCard>
Everything written between the component's tags is compiled in the parent and passed to the child, which renders it wherever a <slot /> outlet appears. The child provides the frame and the position; the parent provides the content — the exact inversion that makes wrapper components (cards, modals, tabs, layouts) reusable without a prop per use case.
Named slots split the frame into regions:
<!-- BaseCard.vue -->
<template>
<div class="card">
<header class="card__header"><slot name="header" /></header>
<div class="card__body"><slot /></div> <!-- default region -->
<footer class="card__footer"><slot name="footer" /></footer>
</div>
</template>
<BaseCard>
<template #header>
<h2>{{ title }}</h2>
</template>
<p>Body content — unwrapped default slot.</p>
<template #footer>
<button @click="save">Save</button>
</template>
</BaseCard>
#header is shorthand for v-slot:header; the default slot accepts either <template #default> or bare content directly on the component tags. The compiler collects each region into an object of slot functions and hands it to the child as $slots, so <slot name="header" /> pulls the matching region. Two mechanics follow from that design and are worth knowing cold:
- Outlets are positional, content is by name. Move
<slot name="footer" />to the top of the card and the parent's#footercontent moves with it — the parent never rewrites its markup, which is how a responsive layout can relocate regions between breakpoints inside one component. - An outlet may appear twice or not at all. Rendering
<slot />in two places renders the content twice; rendering no outlet for a supplied region renders nothing — regions are opt-in per child, and the parent's markup is inert until the child calls the corresponding slot function.
This is the markup-side counterpart of v-model on Custom Components: that topic desugared a binding into a prop-and-emit pair, and slots desugar "the parent supplies content" into functions the child invokes. Under the hood the compiler collects the regions into a slot object where every entry is a function, marks that object with a stability flag, and passes it along with the component vnode. Static regions get the _: 1 /* STABLE */ marker, which lets the runtime skip re-collecting them on parent updates — a stable parent render does not rebuild the card's header and footer, it only re-invokes what actually changed. Regions whose name is computed (<template #[region]>) or whose content is v-if'd lose the stable marker and pay a small re-collection cost on each render. You never write these flags yourself; they explain why projecting markup is cheap enough to use everywhere — and why a hot-path slot should take its name from a constant, not from state.
A common first misstep: putting v-slot/# on a plain <div> inside the child (<div v-slot:header> is invalid — slots are consumed on the component tag or a <template> wrapping it, because the consumer is the parent writing <template #header> on <BaseCard>). The other is expecting the child's own template to appear between the tags — content between a component's tags replaces nothing inside the child; it is the child's slot input.
The mechanism also explains a debugging fact: Failed to resolve slot warnings mean the parent supplied a region the child never declares, and a <slot> outlet rendering nothing usually means the parent passed no content for it. The second chapter covers what the child should show when that happens.
Nothing Provided: Fallback Content and What 'Empty' Actually Means
An outlet with no supplied content renders nothing — an empty <div class="card__body"> with no height, a modal with a blank body, a page whose layout has a hole. The child can defend itself with fallback content: any content inside the <slot> tags is the default when the parent supplies nothing.
<div class="card__body">
<slot>
<p class="empty">No data yet.</p> <!-- shown when the parent passes nothing -->
</slot>
</div>
The fallback is a child-side responsibility — it lives in the child's template, uses the child's data, and is the natural home for loading and empty states:
<slot>
<Spinner v-if="loading" />
<EmptyState v-else />
</slot>
What counts as "nothing provided" is more precise than it looks, and getting it right prevents the classic fallback bugs:
- Truly absent — the parent writes
<BaseCard />or<BaseCard></BaseCard>with no content: fallback renders. - Empty or comment-only content — the parent binds a region whose content produces no real vnodes, such as every child guarded by a failing
v-if: the fallback renders. Vue normalizes slot output and discards empty text/comments before deciding, so a region that resolves to nothing behaves like an absent one. - Whitespace counts as content in the default setup —
<BaseCard> </BaseCard>(a space between the tags in an in-DOM template) can produce a text vnode, so the fallback does not appear and you get a card with invisible content. Thewhitespace: 'condense'compiler option (the default in SFCs) collapses this in most real cases, but when a fallback mysteriously won't render in an in-DOM template, whitespace is the first suspect.
The whitespace: 'condense' default actually applies two distinct rules, and knowing them ends most mystery-fallback reports: a whitespace-only text node between elements is removed entirely, while a whitespace-only node containing newlines is condensed to a single space. In-DOM templates skip that compilation step entirely, keeping raw text nodes — which is why <BaseCard> </BaseCard> written across two lines in an HTML file can still produce a text vnode and suppress the fallback. When fallback rendering must be bulletproof regardless of whitespace, arrange for the parent's slot to resolve to no vnodes rather than empty text: every branch of a v-if chain failing, or a v-for over an empty array, normalizes to comment nodes that count as nothing provided:
<BaseCard>
<p v-for="line in emptyLines" :key="line">{{ line }}</p> <!-- 0 vnodes → fallback shows -->
</BaseCard>
The distinction that confuses everyone once: fallback is about whether content was provided, not about the child's own state. v-if inside the parent's slot content — <BaseCard><p v-if="false">x</p></BaseCard> — leaves nothing to render, so the child's fallback appears even though the parent "did" write content. Conversely, if the parent passes content that renders but should have been an empty state (<DataTable :rows="[]" /> with the table's own markup as slot content), the fallback cannot help — the parent supplied a body, and the child must render what it was given.
That last point is the design guidance: put generic empty/loading states in the child's fallback (it knows when its own data is empty), and let the parent override with domain-specific content when it has better copy. The override path is one markup block:
<DataTable :rows="rows">
<template #default>
<p>Nothing matches your filters — <a href="/help">why?</a></p>
</template>
</DataTable>
The fallback also protects the child's layout contract — an outlet inside a flex row with no fallback collapses, but with fallback content it reserves its structure during loading. A discipline worth adopting in wrapper component libraries: every slot that sits in a visually meaningful position ships a fallback, because the component's layout, not the consuming page's convenience, is the thing being guaranteed.
One thing fallback content cannot do: see the child's state. A fallback is written in the child's template, so the child's data works there — but the parent's content is a different story entirely, governed by the scoping rule that defines how Vue compiles slots.
Compiled in the Parent: Why Projected Content Can't See the Child's State
The scoping rule for slots is the single most important semantic fact about them: slot content is compiled in the parent's scope. It reads the parent's setup state, the parent's imports, the parent's props — and none of the child's, no matter where it is rendered.
<!-- child: NotificationBanner.vue -->
<script setup>
const props = defineProps<{ message: string }>()
const tone = ref('info') // child state
</script>
<template>
<div class="banner" :class="tone">
<slot>{{ message }}</slot>
</div>
</template>
<!-- parent -->
<NotificationBanner :message="toast">
{{ toast.toUpperCase() }} <!-- parent state: fine -->
{{ tone }} <!-- child state: NOT available — undefined/compile warning -->
</NotificationBanner>
Why: the parent compiles the projected content into a function closed over the parent's setup bindings and passes it to the child as a slot prop. When the child's <slot /> executes that function, it runs with the closure's scope, not the child's render scope. The child calls the function; it does not transplant its variables into it.
The failure this prevents is also the design's selling point. The alternative — a prop that accepts raw markup — is a pattern Vue deliberately does not offer:
<!-- not a thing: <BaseCard content="<Chart :data='x' />" /> -->
HTML strings can't carry directives, can't bind reactive parent state, can't hold component references, and force you into v-html with its XSS surface and no scoping rules at all. Slots keep the content as live compiled template: v-if, v-for, event handlers, and child component imports all work inside projected markup because it was never reduced to text. That is also why the fallback of an outlet runs in child scope while the parent's content runs in parent scope — same outlet, two different compilers' output.
Three scoping consequences that show up in real bugs:
- The child cannot "reach down" into projected content. A wrapper that wants to style or conditionally render the parent's content can only render it (twice, via two outlets) or not — manipulating it requires the data-passing mechanism of scoped slots.
- Parent re-renders rebuild projected content. The slot function closes over parent state, so when that state changes, the child re-renders with the fresh closure — the content stays live, which is what you want, but it means an expensive parent render can re-render projected subtrees too.
- Name collisions are safe precisely because scopes are separate. A parent's
countand a child'scountnever interact inside<slot />— no shadowing, no merge, no implicit sharing.
The interview summary for basic slots: the parent supplies compiled content as functions; the child chooses where and whether to render it; fallback content lives child-side; and the content's data lives parent-side by construction. When a child must hand data back to the projected markup — a table that iterates rows and lets the parent render each one — that inversion is scoped slots, and it is the same function-passing mechanism viewed from the other end.
A runtime fact about $slots is this topic's one debugging trap: read $slots at render time, not in a watcher. Checking v-if="$slots.default" in the template works because the check re-runs whenever the child re-renders; subscribing to $slots in a watch or computed does not reliably fire when the parent changes its projected content, because slot functions are swapped during render, outside the reactive signals a watcher tracks. Components that decide behavior from slot presence therefore decide it in the template; if a complex case seems to demand a watcher, the clean answer is usually a prop that states intent instead.
The symmetric trap lives in the parent: content that is conditionally projected changes what the child sees on every toggle — <Card><Chart v-if="show" /></Card> makes the card's fallback appear and disappear with show. That is exactly right when the fallback is an empty state, and a layout jump when it is not. The behavior follows directly from the scoping rule: the parent's v-if evaluates in the parent, and the child reacts to whatever the slot function returned this time. When components are ready to be swapped wholesale rather than projected into, the roadmap turns to Dynamic Components with v-bind is.
Quiz
1. Where does the content written between a component's tags get its data from?
2. A <slot> has fallback content, and the parent writes <Card><p v-if="false">x</p></Card>. What renders?
3. How do you supply a region named 'footer' to a child that declares <slot name="footer" />?
4. Why does Vue pass slot content as template rather than offering a prop that accepts an HTML string?
Flashcards
Question
How does content projection work mechanically in Vue?
Click to reveal answer
Answer
Content between a component's tags is compiled in the parent into a slot function closed over parent bindings and passed to the child as $slots. The child renders it wherever a <slot /> (or <slot name="x" />) outlet appears — positional outlets, by-name regions, opt-in per child.
Question
What is fallback content, and exactly when does it render?
Click to reveal answer
Answer
Content inside the <slot> tags, rendered child-side when the parent supplies nothing usable for that outlet: no content, or content that resolves to no vnodes (all v-if false, comment/empty text discarded). Whitespace-only text in in-DOM templates can count as content and suppress it. It's decided on rendered output, not on markup presence.
Question
Which scope does slot content read, and why?
Click to reveal answer
Answer
The parent's — content is compiled where it's written, as a closure over the parent's setup bindings. The child's state/props are invisible inside projected markup; the child only invokes the function. Separate scopes also mean a parent's 'count' and a child's 'count' can't collide.
Question
Name the slot syntax rules.
Click to reveal answer
Answer
Named region: <slot name="header" /> in the child, <template #header> (v-slot:header) on the component tag in the parent. Default slot: <slot /> outlet; content can sit bare on the component tags or in <template #default>. v-slot is legal only on a component tag or a wrapping template — never on a plain element or the child's internal markup.
Question
Why are slots superior to an HTML-string prop for content injection?
Click to reveal answer
Answer
Strings can't hold directives, reactive bindings, events, or component references and require v-html (XSS surface, no scoping). Compiled slot content keeps the full template language, stays live with parent re-renders, and has an explicit scope rule — the child picks where to render, the parent keeps the data.
Revision Notes
Key Takeaways
- 1. Slot content is parent-compiled markup passed as functions and executed at the child's outlets — parent data stays live, child data stays invisible
- 2. Named slots split a component into regions; outlets are positional while content binds by name
- 3. Fallback content is the child's defense for unsupplied regions and triggers on rendered emptiness, not merely on absent markup
- 4. Slots exist because raw HTML-string props can't carry reactivity, directives, or events and would force v-html
- 5. Everything basic slots do flows from one mechanism — passed functions — which scoped slots later invert to hand data back
Interview Tips
- • "How do slots work under the hood?" — the parent compiles projected content into functions closed over its scope, passes them as $slots, and the child calls them at outlets; that closure fact explains both parent-data access and child-data invisibility
- • "Why not pass HTML as a prop?" — no directives/bindings/events/components in strings, v-html XSS surface; slots keep content compiled and reactive — mention type-checkable content as the bonus
- • "My fallback content isn't rendering — why?" — something resolved as provided: whitespace text in an in-DOM template, or a v-if branch that renders — check what the slot actually receives after normalization
- • "Can a child style the parent's slot content?" — not with basic slots (it can only render or skip it); reaching into projected markup needs scoped slots — use it as the segue
Cheat Sheet
Slots — Basics
Child:
<div class="card">
<slot name="header" /><slot /><slot name="footer">
<Spinner v-if="loading" /> <!-- fallback: child-side -->
</slot>
</div>
Parent:
<BaseCard>
<template #header><h2>{{ title }}</h2></template>
<p>{{ body }}</p> <!-- default slot -->
</BaseCard>
Mechanics: parent compiles projected markup into slot functions (closed over PARENT scope) → passed as $slots → child executes them at positional outlets. Child state is invisible in projected content; parent data is live and reactive there.
Fallback: renders when the region is absent or resolves to no vnodes (v-if all false, discarded comments); whitespace-only text in in-DOM templates can suppress it; put generic empty/loading states in the fallback, domain-specific overrides in the parent.
Rules: v-slot/# on component tag or wrapping template only · outlets positional, content by name · same slot may render twice or not at all · no HTML-string props — slots preserve directives, bindings, events, and XSS-free scoping.
Leads to: data flowing the other way (child → projected markup) = scoped slots.