Skip to content

Passing UI & Children

When you put markup between a component’s tags, Svelte hands it to the component as a snippet prop named children. The component renders it with {@render children()}.

Card.svelte
<script>
let { children } = $props();
</script>
<div class="card">
{@render children()} <!-- whatever the parent nested goes here -->
</div>
<!-- Parent -->
<Card>
<h2>Title</h2>
<p>Any markup here becomes the card's children.</p>
</Card>

This is the Svelte 5 equivalent of React’s children (or the Svelte 4 default <slot>): a reusable wrapper component that doesn’t need to know what it wraps.

flowchart LR
  parent["<Card> ...markup... </Card>"] --> prop["child receives children snippet"]
  prop --> render["{@render children()}"]
  render --> out["markup rendered inside the card"]
Nested markup arrives as the children snippet

For components with several insertion points (a header, a body, a footer), pass named snippets as props. Define them in the parent with {#snippet name()} inside the component tags, and the child receives each as a prop.

Modal.svelte
<script>
let { header, children, footer } = $props();
</script>
<div class="modal">
<div class="modal-header">{@render header()}</div>
<div class="modal-body">{@render children()}</div>
<div class="modal-footer">{@render footer()}</div>
</div>
<Modal>
{#snippet header()}<h2>Confirm</h2>{/snippet}
<p>Are you sure?</p> <!-- default children -->
{#snippet footer()}<button>OK</button>{/snippet}
</Modal>

Because snippets take parameters, a component can pass data out to the markup the parent provides — the pattern React calls render props. The child invokes the snippet with a value; the parent’s snippet receives it:

List.svelte
<script>
let { items, row } = $props();
</script>
{#each items as item (item.id)}
{@render row(item)} <!-- pass each item back to the parent's snippet -->
{/each}
<List items={products}>
{#snippet row(product)}
<li>{product.name} — ${product.price}</li> <!-- parent decides the markup -->
{/snippet}
</List>

The child owns the iteration; the parent owns the per-item markup. This inversion — child provides data, parent provides presentation — is the render-prop pattern, expressed cleanly with snippets.

With lang="ts", a snippet prop (children, or a render-prop snippet like row above) is typed with the Snippet interface imported from 'svelte'. Snippet (no arguments) means “takes no parameters”; Snippet<[T]> means “takes one parameter of type T” — the array is a tuple, since a snippet can take multiple parameters. Combined with a generic on the component itself, TypeScript ties the data’s item type to the snippet’s parameter type:

src/lib/TypedList.svelte
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
let { data, row }: { data: T[]; row: Snippet<[T]> } = $props();
</script>
<ul>
{#each data as item}
{@render row(item)}
{/each}
</ul>

Used correctly, T is inferred from data and row’s parameter must match:

src/lib/UseTypedListGood.svelte
<script lang="ts">
import TypedList from './TypedList.svelte';
let products = [{ name: 'Tea', price: 2 }];
</script>
<TypedList data={products}>
{#snippet row(item)}
<li data-testid="row">{item.name} — ${item.price}</li>
{/snippet}
</TypedList>
src/lib/typedlist.test.ts
import { test, expect } from 'vitest';
import { render } from '@testing-library/svelte';
import UseTypedListGood from './UseTypedListGood.svelte';
test('a correctly-typed row snippet renders each item', () => {
const { container } = render(UseTypedListGood);
expect(container.querySelector('[data-testid="row"]')?.textContent).toBe('Tea — $2');
});
✓ a correctly-typed row snippet renders each item

And a row snippet typed for the wrong shape is a real, caught type error — not a runtime surprise:

<!-- @expect-error Type '{ name: string; price: number; }[]' is not assignable to type '{ wrongShape: boolean; }[]'. Property 'wrongShape' is missing in type '{ name: string; price: number; }' but required in type '{ wrongShape: boolean; }'. -->
<script lang="ts">
import TypedList from './TypedList.svelte';
let products = [{ name: 'Tea', price: 2 }];
</script>
<TypedList data={products}>
{#snippet row(item: { wrongShape: boolean })}
<li>{item.wrongShape}</li>
{/snippet}
</TypedList>

svelte-check on that file reports exactly (verified against this course’s probe, Svelte 5.57.1 + TypeScript):

Type '{ name: string; price: number; }[]' is not assignable to type '{ wrongShape: boolean; }[]'.
Property 'wrongShape' is missing in type '{ name: string; price: number; }' but required in type '{ wrongShape: boolean; }'.

Without Snippet<[T]>, a mismatched row snippet would only surface as undefined values or a runtime crash inside the snippet body — typing it turns that into a compile-time catch.

$props() rest, renaming, defaults — and $props.id() for SSR-safe ids

Section titled “$props() rest, renaming, defaults — and $props.id() for SSR-safe ids”

Destructuring $props() for data props (defaults, renaming, rest with {...rest}) and typing them with a Props interface is covered in full in Props & $bindable — the same $props() call is what yields the snippet props (children, header, row) this lesson is about.

One more $props-family rune matters specifically for markup you pass down: $props.id() (added in 5.20) generates an id that’s unique to the current component instance and, critically, consistent between server and client during hydration — exactly what you need to link a <label for> to an <input id> without hardcoding a string that would collide if the component is used twice on the same page.

src/lib/LabeledField.svelte
<script lang="ts">
const uid = $props.id();
</script>
<label for="{uid}-name" data-testid="label">Name</label>
<input id="{uid}-name" data-testid="input" />
<span data-testid="uid">{uid}</span>
src/lib/propsid.test.ts
import { test, expect } from 'vitest';
import { render } from '@testing-library/svelte';
import LabeledField from './LabeledField.svelte';
test('$props.id() is unique per component instance and links label to input', () => {
const first = render(LabeledField);
const uid = first.container.querySelector('[data-testid="uid"]')!.textContent;
const label = first.container.querySelector('label')!;
const input = first.container.querySelector('input')!;
expect(label.getAttribute('for')).toBe(`${uid}-name`);
expect(input.id).toBe(`${uid}-name`);
// A second instance on the same page gets a DIFFERENT id — no collision.
const second = render(LabeledField);
const uid2 = second.container.querySelector('[data-testid="uid"]')!.textContent;
expect(uid2).not.toBe(uid);
});
✓ $props.id() is unique per component instance and links label to input

Two <LabeledField> instances on one page get two different uids (c1, c2 in this probe, proven above) — a hardcoded id="name" would have produced a duplicate DOM id and broken the label association for the second instance.

A snippet prop is just a normal prop whose value happens to be a “render this markup” function — children, header, row are not special syntax, they’re names you chose, typed like any other prop (Snippet / Snippet<[T]>). Passing UI down and passing data down are the same mechanism ($props()); what differs is only the type of the value being passed. Two-way binding ($bindable()) is a deliberately separate, opt-in mechanism layered on top of that — nothing about a plain snippet or data prop implies it.

What you getBenefitCost
children/named snippet propsReusable wrapper components that don’t know their contentsThe child must remember to {@render} every snippet prop it receives, or that markup silently never appears
Snippet<[T]> + component genericsMismatched row/data shapes are compile errors, proven aboveOnly as strong as the generic — an untyped/any snippet prop gets no protection
$props.id()Collision-free, hydration-safe ids with zero manual bookkeeping, proven aboveOnly available at the top level of a component (can’t be called conditionally or inside a function)
  • “A snippet param typed loosely (or left untyped) is just as safe as Snippet<[T]>.” It isn’t — proven above, only the properly generic-typed version turns a shape mismatch into a caught compile error instead of an undefined surprise at runtime.
  • “Any unique-looking string works fine for a label/input id, as long as I don’t reuse the component.” Hardcoded ids break the moment the component is reused on the same page — $props.id() is proven above to hand out a different id per instance automatically, including across server/client hydration.
  • “If the child doesn’t mark a prop $bindable(), a parent’s bind: on it throws immediately.” Not in this installed version — see the concrete, non-obvious behavior proven in Reading AI-generated code, next.

When reviewing generated components that pass UI down, check for:

  • Is children (or any named snippet prop) actually rendered with {@render} somewhere, or received and silently dropped?
  • Does a render-prop snippet (row, in the pattern above) have a type wide enough (any, or no type at all) that a genuine shape mismatch with the data it’s rendering would go unnoticed until runtime?
  • Is a form’s id/for pair hardcoded as a literal string instead of built from $props.id(), risking a collision the moment the component appears twice on one page?
  • Does a component bind: to a child’s prop assuming Svelte will loudly error if that prop isn’t $bindable()? Proven below: in this course’s verified Svelte 5.57.1, it does not — the sync just silently never happens.

🐛 Spot the bug

Field.svelte
<script>
let { value } = $props(); // NOT $bindable()
</script>
<input bind:value={value} />
Show the answer

This looks like a normal two-way-bindable input wrapper, and a parent doing <Field bind:value={name} /> doesn’t throw or warn — it renders fine. But value here was never marked $bindable(). Verified against this course’s probe (Svelte 5.57.1): typing into the input updates the input’s own local value (because bind:value inside Field is a real two-way binding to the local variable), but the parent’s name never changes — there is no error, no console warning, nothing. The two-way sync silently doesn’t cross the component boundary at all, which is worse than an error because nothing tells you it’s broken. The fix: let { value = $bindable() } = $props(); in the child, matching the pattern in Props & $bindable.

How does a component receive markup nested between its tags?
How do you provide multiple named insertion points to a component?
How does a Svelte component pass data out to parent-provided markup (render props)?
What does Snippet<[T]> mean here?
<script lang="ts" generics="T">
  import type { Snippet } from 'svelte';
  let { data, row }: { data: T[]; row: Snippet<[T]> } = $props();
</script>
What guarantee does $props.id() give you?
A parent does <Field bind:value={name} />. What actually happens (verified against Svelte 5.57.1)?
<!-- Field.svelte -->
<script>
  let { value } = $props(); // NOT $bindable()
</script>
<input bind:value={value} />