Svelte slots, and how to migrate to snippets

Published
Updated
On this page

Svelte slots are how components accepted external markup before Svelte 5. They still work in older codebases, component libraries, and custom elements. But in Svelte 5, they're deprecated in favor of snippets.

This post covers two things: how slots work (useful when you hit them in an existing codebase), and how to migrate a slot-based component to snippets. If you're writing new Svelte 5 code, go straight to the snippets guide instead.

How slots work

A slot is a placeholder inside a child component where markup from the parent gets injected. Svelte 4 gave you four variations: the default slot, named slots, fallback content, and slot props. The sections below cover each one with the same slot syntax you'd see in an existing codebase.

Default slots

The simplest case: a single <slot /> somewhere in the child, and any content between the component tags in the parent fills it in.

<!-- modal.svelte -->
<div class="modal">
	<slot />
</div>
<!-- +page.svelte -->
<script>
	import Modal from './modal.svelte';
</script>

<Modal>
	<p>This is slotted content.</p>
</Modal>

The content between the component tags lands wherever <slot /> sits in the child. That's the whole default-slot story.

Named slots

When a component needs more than one content area, label each slot in the child with a name attribute and route content to it in the parent with slot="...".

<!-- modal.svelte -->
<div class="modal">
	<header>
		<slot name="header" />
	</header>
	<slot />
	<footer>
		<slot name="footer" />
	</footer>
</div>
<!-- +page.svelte -->
<Modal>
	<h2 slot="header">Delete file?</h2>
	<p>This action can't be undone.</p>
	<div slot="footer">
		<button>Cancel</button>
		<button>Delete</button>
	</div>
</Modal>

The <p> with no slot attribute lands in the default slot. Everything with a slot="..." attribute goes into the matching named slot.

Fallback content

Anything between the opening and closing <slot> tags renders when the parent provides no content:

<!-- modal.svelte -->
<div class="modal">
	<header>
		<slot name="header">
			<h2>Untitled</h2>
		</slot>
	</header>
	<slot />
</div>

If the parent passes a slot="header" element, it replaces the fallback. Otherwise <h2>Untitled</h2> shows up.

Slot props with let:

Slots can pass data from the child back up to the parent. The child supplies values as attributes on <slot>, and the parent picks them up with the let: directive.

<!-- user-list.svelte -->
<script lang="ts">
	type User = { name: string; age: number };

	export let users: User[];
</script>

<ul>
	{#each users as user}
		<li>
			<slot {user}>
				<p>{user.name}</p>
			</slot>
		</li>
	{/each}
</ul>
<!-- +page.svelte -->
<script lang="ts">
	import UserList from './user-list.svelte';

	const users = [
		{ name: 'Ada', age: 28 },
		{ name: 'Grace', age: 34 }
	];
</script>

<UserList {users} let:user>
	<div>
		<h3>{user.name}</h3>
		<p>Age: {user.age}</p>
	</div>
</UserList>

let:user on the component tag binds whatever the child passed as {user} on the <slot> to a variable named user inside the slotted content. The {user} shorthand in the child expands to user={user}.

Named slots with props

For named slots, let: goes on the element that carries the slot attribute, not on the component tag:

<!-- product-list.svelte -->
<script lang="ts">
	type Product = { name: string; price: number };

	export let products: Product[];
</script>

<ul>
	{#each products as product}
		<li>
			<slot name="product" {product} />
		</li>
	{/each}
</ul>
<!-- +page.svelte -->
<ProductList {products}>
	<div slot="product" let:product>
		<h3>{product.name}</h3>
		<p>${product.price}</p>
	</div>
</ProductList>

let:product is scoped to the element it's on and its children. It doesn't leak to other named slots on the same component. That's part of why slots were replaced.

Why slots were replaced

The Svelte team replaced slots for specific reasons, laid out in the Svelte 5 migration guide. Knowing them makes the snippets API make more sense.

let: was the wrong direction. Every other : directive in Svelte receives a value: bind:value, on:click. let:user declares one. That mismatch tripped up every new reader.

Scope was confusing. In a component with an items slot and an empty-state slot, the variable introduced by the items slot's let: wasn't usable inside the empty slot. There was no obvious mental model for where the scope started or ended.

Fragment-only injection needed a wrapper. To inject multiple siblings into a named slot without a containing tag, you had to reach for <svelte:fragment slot="...">. A snippet block is a fragment by default, so no special element is needed.

Named slots behaved inconsistently. Applied to a component vs. a regular element, slot="..." meant subtly different things. Snippets collapse that into a single mechanism: named props that hold functions.

Snippets address each of these head-on. They're functions with parameters, scoped by normal JavaScript rules, and first-class values you can assign to variables, pass through props, or export from a <script module> block.

Migrating slots to snippets

The rest of this post is the migration. Each section below shows the slot-based original next to its snippet equivalent so you can match up what you have against what you need.

Use the migration script first

If you're upgrading a Svelte 4 codebase, run the official migration first and let it handle the mechanical rewrites:

bunx sv migrate svelte-5

VS Code ships an equivalent Migrate Component to Svelte 5 Syntax command that does the same thing for a single file. The automated migration handles the common cases cleanly: slots, export let, $:, reactive statements. The manual mappings below are there for the edge cases and for understanding what the script produced.

Default slot to children prop

The default slot becomes a prop called children. Render it with {@render children()}.

Before (Svelte 4):

<!-- modal.svelte -->
<div class="modal">
	<slot />
</div>

After (Svelte 5):

<!-- modal.svelte -->
<script lang="ts">
	import type { Snippet } from 'svelte';

	let { children }: { children: Snippet } = $props();
</script>

<div class="modal">
	{@render children()}
</div>

The parent side doesn't change. Content between <Modal> and </Modal> still lands in the default area. Svelte 5 collects it into the children prop automatically.

Named slots to named snippet props

Each <slot name="x" /> becomes a prop on the child. Each slot="x" element in the parent becomes a {#snippet x()}...{/snippet} block.

Before:

<!-- modal.svelte -->
<header><slot name="header" /></header>
<slot />
<footer><slot name="footer" /></footer>
<!-- +page.svelte -->
<Modal>
	<h2 slot="header">Delete file?</h2>
	<p>This action can't be undone.</p>
	<div slot="footer">
		<button>Cancel</button>
		<button>Delete</button>
	</div>
</Modal>

After:

<!-- modal.svelte -->
<script lang="ts">
	import type { Snippet } from 'svelte';

	let {
		header,
		footer,
		children
	}: {
		header: Snippet;
		footer: Snippet;
		children: Snippet;
	} = $props();
</script>

<header>{@render header()}</header>
{@render children()}
<footer>{@render footer()}</footer>
<!-- +page.svelte -->
<Modal>
	{#snippet header()}
		<h2>Delete file?</h2>
	{/snippet}

	<p>This action can't be undone.</p>

	{#snippet footer()}
		<button>Cancel</button>
		<button>Delete</button>
	{/snippet}
</Modal>

The snippet block is the placement, so you only emit the markup you actually want. No wrapping <h2 slot="header">, no extra <div> around the footer buttons unless you actually want the div.

Slot props to snippet parameters

let:user on the consumer becomes a parameter on the snippet. The child goes from <slot {user} /> to {@render row(user)}.

Before:

<!-- user-list.svelte -->
<script lang="ts">
	type User = { name: string; age: number };

	export let users: User[];
</script>

<ul>
	{#each users as user}
		<li><slot {user} /></li>
	{/each}
</ul>
<!-- +page.svelte -->
<UserList {users} let:user>
	<h3>{user.name}</h3>
</UserList>

After:

<!-- user-list.svelte -->
<script lang="ts" generics="T">
	import type { Snippet } from 'svelte';

	let {
		users,
		row
	}: {
		users: T[];
		row: Snippet<[T]>;
	} = $props();
</script>

<ul>
	{#each users as user}
		<li>{@render row(user)}</li>
	{/each}
</ul>
<!-- +page.svelte -->
<UserList {users}>
	{#snippet row(user)}
		<h3>{user.name}</h3>
	{/snippet}
</UserList>

The snippet version is genuinely better typed. The generic T flows from the users array through to the parameter of row with no casts. Slot props never made that work cleanly.

Fallback content to if blocks

Slot fallback content — the stuff inside <slot>...</slot> — becomes an {#if} guard around {@render ...}.

Before:

<slot name="header">
	<h2>Untitled</h2>
</slot>

After:

<script lang="ts">
	import type { Snippet } from 'svelte';

	let { header }: { header?: Snippet } = $props();
</script>

{#if header}
	{@render header()}
{:else}
	<h2>Untitled</h2>
{/if}

If the prop is optional and you don't need a fallback, {@render header?.()} is the one-liner — it skips rendering when the prop is undefined.

svelte:fragment to snippet block

If you needed to inject multiple siblings into a named slot without a wrapper element, you reached for <svelte:fragment>. The snippet body is already fragment-like, no wrapper required.

Before:

<Modal>
	<svelte:fragment slot="footer">
		<button>Cancel</button>
		<button>Delete</button>
	</svelte:fragment>
</Modal>

After:

<Modal>
	{#snippet footer()}
		<button>Cancel</button>
		<button>Delete</button>
	{/snippet}
</Modal>

One less wrapper, one less special-case element to remember.

When slots still make sense

You don't need to rip out slots the moment you upgrade to Svelte 5. A few situations keep them in play:

  • Custom elements. When you compile a component with <svelte:options customElement>, keep using <slot />. Custom elements use the native Shadow DOM slot, not Svelte's. The migration guide calls this out explicitly.

  • Svelte 4 codebases in transit. Slots still compile in Svelte 5, so you can upgrade the framework before you migrate every component. Run bunx sv migrate svelte-5 file-by-file as you touch things.

  • Libraries that still expose slot-based APIs. If a third-party library hasn't migrated, you'll pass slotted content into it. One useful quirk from the migration guide: you can pass snippets into a component that uses slots, but the reverse doesn't work — slotted content can't be forwarded into a {@render ...} tag.

Everywhere else, reach for snippets.

Wrapping up

Slots were the right primitive for Svelte 4, and snippets are the right one for Svelte 5. Slots still work in Svelte 5, libraries lean on them, and custom elements require them. Everywhere else, use snippets. They type better, scope cleaner, and cover cases the slot API never reached.

For the full picture on the replacement API, head to Svelte snippets: the new way to reuse markup in Svelte 5. For the broader Svelte 5 model, start with Svelte 5 runes.

Happy coding!