Passing UI & Children
The children snippet
Section titled “The children snippet”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()}.
<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"] Named snippet props for multiple slots
Section titled “Named snippet props for multiple slots”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.
<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>Passing data back with snippet parameters
Section titled “Passing data back with snippet parameters”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:
<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.
Typing snippet props with Snippet<[T]>
Section titled “Typing snippet props with Snippet<[T]>”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:
<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:
<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>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 itemAnd 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.
<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>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 inputTwo <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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
children/named snippet props | Reusable wrapper components that don’t know their contents | The child must remember to {@render} every snippet prop it receives, or that markup silently never appears |
Snippet<[T]> + component generics | Mismatched row/data shapes are compile errors, proven above | Only 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 above | Only available at the top level of a component (can’t be called conditionally or inside a function) |
Common Misconceptions
Section titled “Common Misconceptions”- “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 anundefinedsurprise 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’sbind:on it throws immediately.” Not in this installed version — see the concrete, non-obvious behavior proven in Reading AI-generated code, next.
Reading AI-generated code
Section titled “Reading AI-generated code”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/forpair 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
<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.