Once you’ve mastered the basics of $props, you’re ready to explore the patterns that power production component libraries and design systems.
Let’s dive in.
Start with the BasicsThis article covers advanced patterns. If you’re new to props in Svelte 5, start with the $props Rune article which covers fundamentals like destructuring, defaults, reactivity caveats, and basic TypeScript integration.
Rest Props and Prop Forwarding
As you build more sophisticated component libraries, you’ll frequently encounter the need to pass props through a component to an underlying element or child component. Rest props—the ability to capture “everything else” that wasn’t explicitly destructured—make this pattern elegant and maintainable.
Understanding the Rest Pattern
The spread operator (...) in JavaScript destructuring captures all remaining properties into a new object. In the context of $props, this means you can explicitly handle certain props while gathering everything else for forwarding:
<!-- Button.svelte -->
<script>
let {
variant = 'primary',
size = 'medium',
children,
...restProps // Captures everything else
} = $props()
// restProps now contains all props except variant, size, and children
// This might include: onclick, disabled, aria-label, type, form, etc.
const variantClasses = {
primary: 'bg-blue-500 text-white hover:bg-blue-600',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
danger: 'bg-red-500 text-white hover:bg-red-600',
ghost: 'bg-transparent text-gray-700 hover:bg-gray-100'
}
const sizeClasses = {
small: 'px-2 py-1 text-sm',
medium: 'px-4 py-2',
large: 'px-6 py-3 text-lg'
}
</script>
<button
class="rounded font-medium transition-colors {variantClasses[variant]} {sizeClasses[size]}"
{...restProps}
>
{@render children?.()}
</button> The magic here is in {...restProps}. This spreads all the captured properties onto the button element, making them actual HTML attributes. Any prop that wasn’t explicitly destructured flows through to the underlying element.
This means your Button component automatically accepts any valid button attribute:
<Button
variant="primary"
onclick={() => saveDocument()}
disabled={isLoading}
aria-busy={isLoading}
aria-label="Save the current document"
type="submit"
form="document-form"
data-testid="save-button"
>
Save Changes
</Button> Every attribute except variant passes through to the actual <button> element. The onclick becomes a click handler, disabled disables the button, ARIA attributes improve accessibility, type and form connect to form handling, and data-testid enables testing. Your Button component doesn’t need to know about any of these—it simply forwards them.
Why Rest Props Are Essential for Component Libraries
When building component libraries or design systems, rest props are not just convenient—they’re essential. Without them, you would need to explicitly declare every possible HTML attribute your wrapper might need:
<!-- AVOID: WITHOUT rest props - tedious, incomplete, and maintenance nightmare -->
<script>
let {
variant,
// Now we need to manually declare EVERY possible button attribute...
onclick,
ondblclick,
onmousedown,
onmouseup,
onmouseenter,
onmouseleave,
onfocus,
onblur,
onkeydown,
onkeyup,
onkeypress,
disabled,
type,
autofocus,
form,
formaction,
formenctype,
formmethod,
formnovalidate,
formtarget,
name,
value,
ariaLabel,
ariaDescribedby,
ariaBusy,
ariaExpanded,
ariaHaspopup,
ariaPressed,
ariaDisabled,
tabindex,
title,
id,
// ... dozens more attributes
// And we'd need to update this list for every new attribute!
children
} = $props()
</script>
<button
{onclick}
{disabled}
{type}
aria-label={ariaLabel}
<!--
...manually
pass
every
single
attribute!!!!
--
>
>
{@render children?.()}
</button> This approach is clearly unsustainable. You’d miss attributes, create inconsistencies, and spend enormous effort maintaining prop declarations instead of building features.
<!-- PREFERRED: WITH rest props - clean, complete, and future-proof -->
<script>
let { variant, children, ...props } = $props()
</script>
<button class="btn btn-{variant}" {...props}>
{@render children?.()}
</button> The rest props pattern is not just cleaner—it’s more correct. Your component automatically supports every valid attribute, including ones that might be added to HTML in the future.
Building a Complete Form Input Component
Let’s build a more substantial example that demonstrates how rest props enable powerful wrapper components. A form input component needs to handle labels, errors, help text, and more—while still supporting all native input attributes:
<!-- FormInput.svelte -->
<script>
let {
// Props specific to our wrapper component
id, // Required for accessibility
label,
error,
helpText,
required = false,
containerClass = '',
// Everything else passes through to the input
...inputProps
} = $props()
// Determine input state for styling
let hasError = $derived(!!error)
let showHelp = $derived(!!helpText && !error)
</script>
<div class="form-field {containerClass}">
{#if label}
<label for={id} class="block text-sm font-medium text-gray-700 mb-1">
{label}
{#if required}
<span class="text-red-500 ml-0.5" aria-hidden="true">*</span>
{/if}
</label>
{/if}
<input
{id}
class="
w-full px-3 py-2 border rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-offset-0
{hasError
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500 focus:border-blue-500'}
"
aria-invalid={hasError}
aria-describedby={hasError ? `${id}-error` : showHelp ? `${id}-help` : undefined}
{required}
{...inputProps}
/>
{#if hasError}
<p id="{id}-error" class="mt-1 text-sm text-red-600" role="alert">
{error}
</p>
{:else if showHelp}
<p id="{id}-help" class="mt-1 text-sm text-gray-500">
{helpText}
</p>
{/if}
</div> This component wraps a native <input> with label, error handling, and help text functionality. But notice that we never explicitly declare props like type, value, placeholder, maxlength, pattern, min, max, or any event handlers. All of these flow through via inputProps:
<!-- Usage examples showing the flexibility -->
<!-- Text input with validation -->
<FormInput
label="Email Address"
type="email"
placeholder="you@example.com"
required
bind:value={email}
error={emailError}
helpText="We'll never share your email with anyone"
autocomplete="email"
/>
<!-- Number input with constraints -->
<FormInput label="Age" type="number" min={0} max={120} step={1} bind:value={age} />
<!-- Date input with custom handling -->
<FormInput
label="Appointment Date"
type="date"
min={today}
max={nextMonth}
bind:value={appointmentDate}
onchange={(e) => validateDate(e.target.value)}
/>
<!-- Search with custom styling -->
<FormInput
type="search"
placeholder="Search products..."
bind:value={searchQuery}
oninput={debounce(handleSearch, 300)}
containerClass="max-w-md mx-auto"
/> The component handles the common concerns (label, error, help text, accessibility) while allowing full customization of the underlying input. This is the power of rest props—your wrapper adds value without limiting flexibility.
Selective Prop Forwarding to Multiple Elements
Sometimes a single component contains multiple elements that need different props. You can create multiple “buckets” for prop forwarding:
<!-- Card.svelte -->
<script>
let {
// Content props
title,
subtitle,
children,
// Props specifically for different parts of the card
headerProps = {},
bodyProps = {},
footerProps = {},
footer,
// Props for the outer container
...containerProps
} = $props()
</script>
<article class="card rounded-lg shadow-md overflow-hidden" {...containerProps}>
<header class="card-header px-6 py-4 bg-gray-50 border-b" {...headerProps}>
<h2 class="text-xl font-semibold text-gray-900">{title}</h2>
{#if subtitle}
<p class="mt-1 text-sm text-gray-600">{subtitle}</p>
{/if}
</header>
<div class="card-body px-6 py-4" {...bodyProps}>
{@render children?.()}
</div>
{#if footer}
<footer class="card-footer px-6 py-4 bg-gray-50 border-t" {...footerProps}>
{@render footer()}
</footer>
{/if}
</article> Now consumers can target specific parts of the card:
<Card
title="User Profile"
subtitle="Manage your account settings"
id="profile-card"
data-section="user"
headerProps={{
class: 'bg-blue-50 border-blue-200',
'data-testid': 'profile-header'
}}
bodyProps={{
class: 'min-h-[200px]'
}}
footerProps={{
class: 'flex justify-end gap-2'
}}
>
<p>Card body content here...</p>
{#snippet footer()}
<button class="btn-secondary">Cancel</button>
<button class="btn-primary">Save</button>
{/snippet}
</Card> This pattern gives consumers fine-grained control over every part of your component while keeping the API organized and intuitive.
Event Handler Forwarding
Rest props handle event handlers seamlessly. In Svelte 5, event handlers are simply props that start with on—they’re not special syntax like Svelte 4’s on: directive. This means they flow through rest props just like any other attribute:
<!-- IconButton.svelte -->
<script>
let {
icon,
label,
size = 'medium',
...props // Includes onclick, onmouseenter, onfocus, etc.
} = $props()
const sizeClasses = {
small: 'p-1',
medium: 'p-2',
large: 'p-3'
}
const iconSizes = {
small: 16,
medium: 20,
large: 24
}
</script>
<button
class="icon-button rounded-full hover:bg-gray-100 transition-colors {sizeClasses[size]}"
aria-label={label}
{...props}
>
<Icon name={icon} size={iconSizes[size]} />
</button> Any event handler passed to IconButton automatically works:
<IconButton
icon="trash"
label="Delete item"
onclick={() => deleteItem(id)}
onmouseenter={() => showTooltip('Delete this item')}
onmouseleave={() => hideTooltip()}
onfocus={() => announceForScreenReader('Delete button focused')}
/> The component doesn’t need to know about these events—it simply forwards them. This makes wrapper components that feel native to use.
Filtering and Transforming Rest Props
Sometimes you need to filter or transform props before forwarding them. Perhaps you want to prevent certain props from reaching the underlying element, or you need to modify some values:
<!-- SafeExternalLink.svelte -->
<script>
let { href, children, ...props } = $props()
// Filter out potentially dangerous props
const {
onclick, // Remove - we'll handle clicks ourselves
target, // Remove - we always use _blank
rel, // Remove - we always use noopener noreferrer
...safeProps
} = props
function handleClick(event) {
// Log external link clicks for analytics
trackExternalLink(href)
// Still call the user's onclick if provided
onclick?.(event)
}
</script>
<a {href} target="_blank" rel="noopener noreferrer" onclick={handleClick} {...safeProps}>
{@render children?.()}
<span class="external-icon" aria-hidden="true">↗</span>
</a> This component ensures external links always open safely (new tab, no referrer) while still allowing other props like class, id, or ARIA attributes to pass through.
Bidirectional Binding with $bindable
While Svelte’s default unidirectional data flow (parent to child) makes applications easier to reason about, there are legitimate cases where bidirectional communication simplifies code. Form inputs are the classic example—an input component needs to both receive and update a value. The $bindable rune provides a carefully designed mechanism for these scenarios.
The Problem $bindable Solves
To understand $bindable, let’s first see what happens without it. By default, props flow one way—from parent to child. A child component can locally modify a prop value, but that change doesn’t affect the parent.
Without binding, child modifications don’t affect the parent:
<script>
let { rating } = $props();
</script>
<div class="stars">
{#each [1, 2, 3, 4, 5] as star}
<button
class:filled={star <= rating}
onclick={() => rating = star}
>
★
</button>
{/each}
</div>
<!-- Parent.svelte -->
<script>
let userRating = $state(3);
</script>
<RatingInput rating={userRating} />
<p>Your rating: {userRating}</p>
<!-- Clicking stars updates the component display,
but userRating stays at 3! --> Adding $bindable
<!-- RatingInput.svelte -->
<script>
let { rating = $bindable(0) } = $props();
</script>
<div class="stars">
{#each [1, 2, 3, 4, 5] as star}
<button
class:filled={star <= rating}
onclick={() => rating = star}
>
★
</button>
{/each}
</div>
<!-- Parent.svelte -->
<script>
let userRating = $state(3);
</script>
<RatingInput bind:rating={userRating} />
<p>Your rating: {userRating}</p>
<!-- Now clicking stars updates both! --> The bind: directive creates the two-way link. The fallback value (0) only applies when the prop isn’t bound.
Anatomy and Behavior of $bindable()
The $bindable rune has a specific structure and behavior that’s important to understand:
<script>
// $bindable() can include a fallback value
let { value = $bindable('default') } = $props()
// Or be used without a fallback
let { required = $bindable() } = $props()
</script> Critical distinction about fallbacks: The fallback value only applies when the prop is not bound. This is a crucial behavioral detail:
<!-- Parent.svelte -->
<script>
import Input from './Input.svelte'
let text = $state('hello')
</script>
<!-- Scenario 1: Prop is bound -->
<Input bind:value={text} />
<!-- 'value' in Input equals 'hello' - the bound value from parent -->
<!-- Scenario 2: Static prop (not bound) -->
<Input value="static text" />
<!-- 'value' in Input equals 'static text' - the provided value -->
<!-- Scenario 3: No prop provided -->
<Input />
<!-- 'value' in Input equals 'default' - the $bindable fallback -->
<!-- Scenario 4: Bound to undefined - THIS THROWS AN ERROR! -->
<Input bind:value={undefined} />
<!-- Runtime error! When binding, parent must provide a defined value --> This behavior prevents a confusing situation where it’s unclear whether the fallback or the bound value should take precedence. If you’re binding, you must provide a value.
Building a Real Form Input with $bindable
Let’s build a complete, practical text input component that demonstrates $bindable in action:
<!-- TextInput.svelte -->
<script>
let {
// The main value - bindable for two-way sync
value = $bindable(''),
// Input configuration
type = 'text',
placeholder = '',
disabled = false,
readonly = false,
// Validation
minlength,
maxlength,
pattern,
required = false,
// Rest props for additional attributes
...restProps
} = $props()
// Computed states for UI feedback
let isEmpty = $derived(value.trim().length === 0)
let charCount = $derived(value.length)
let showCharCount = $derived(maxlength !== undefined)
// Clear the input
function clear() {
value = ''
}
</script>
<div class="text-input-wrapper">
<div class="input-container">
<input
{type}
{placeholder}
{disabled}
{readonly}
{minlength}
{maxlength}
{pattern}
{required}
bind:value
class="text-input"
class:empty={isEmpty}
class:has-clear={!isEmpty && !disabled && !readonly}
{...restProps}
/>
{#if !isEmpty && !disabled && !readonly}
<button
type="button"
class="clear-button"
onclick={clear}
aria-label="Clear input"
tabindex="-1"
>
×
</button>
{/if}
</div>
{#if showCharCount}
<div class="char-count" class:warning={charCount > maxlength * 0.9}>
{charCount}/{maxlength}
</div>
{/if}
</div>
<style>
.text-input-wrapper {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.input-container {
position: relative;
display: flex;
align-items: center;
}
.text-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.text-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.text-input.has-clear {
padding-right: 2rem;
}
.text-input.empty {
border-color: #e5e7eb;
}
.clear-button {
position: absolute;
right: 0.5rem;
background: none;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 0.25rem;
font-size: 1.25rem;
line-height: 1;
border-radius: 50%;
transition:
color 0.15s,
background-color 0.15s;
}
.clear-button:hover {
color: #4b5563;
background-color: #f3f4f6;
}
.char-count {
font-size: 0.75rem;
color: #6b7280;
text-align: right;
}
.char-count.warning {
color: #f59e0b;
}
</style> Usage is intuitive and powerful:
<!-- SearchForm.svelte -->
<script>
import TextInput from './TextInput.svelte'
let searchQuery = $state('')
let username = $state('')
function handleSearch() {
console.log('Searching for:', searchQuery)
}
</script>
<form onsubmit={handleSearch}>
<!-- Basic bound input -->
<TextInput bind:value={searchQuery} placeholder="Search..." type="search" />
<!-- With character limit -->
<TextInput
bind:value={username}
placeholder="Choose a username"
maxlength={20}
pattern="[a-z0-9_]+"
/>
<button type="submit">Search</button>
</form>
<!-- Values are always in sync -->
<p>Search query: "{searchQuery}"</p>
<p>Username: "{username}"</p> Every change in the input immediately reflects in the parent’s state, and any programmatic changes to the parent’s state immediately update the input.
Multiple Bindable Props for Complex Components
Components can have multiple bindable props when they manage several pieces of related state. A date range picker is a perfect example:
<!-- DateRangePicker.svelte -->
<script>
let {
// Both dates are bindable - the component manages a range
startDate = $bindable(null),
endDate = $bindable(null),
// Configuration (not bindable - just configuration)
minDate = null,
maxDate = null,
dateFormat = 'YYYY-MM-DD',
// Callbacks for additional control
onrangechange,
oninvalid
} = $props()
// Validation: ensure end is not before start
$effect(() => {
if (startDate && endDate && new Date(endDate) < new Date(startDate)) {
// Auto-correct invalid range
endDate = startDate
oninvalid?.({ reason: 'end_before_start', correctedTo: startDate })
}
})
// Calculate range duration
let rangeDays = $derived.by(() => {
if (!startDate || !endDate) return null
const start = new Date(startDate)
const end = new Date(endDate)
return Math.round((end - start) / (1000 * 60 * 60 * 24))
})
// Notify parent of range changes
$effect(() => {
if (startDate && endDate) {
onrangechange?.({ startDate, endDate, days: rangeDays })
}
})
</script>
<div class="date-range-picker">
<div class="date-field">
<label for="start-date">Start Date</label>
<input
id="start-date"
type="date"
bind:value={startDate}
min={minDate}
max={endDate || maxDate}
/>
</div>
<span class="separator">to</span>
<div class="date-field">
<label for="end-date">End Date</label>
<input
id="end-date"
type="date"
bind:value={endDate}
min={startDate || minDate}
max={maxDate}
/>
</div>
{#if rangeDays !== null}
<div class="range-info">
{rangeDays} day{rangeDays !== 1 ? 's' : ''} selected
</div>
{/if}
</div> The parent can bind to both values and have full control:
<!-- ReportFilters.svelte -->
<script>
import DateRangePicker from './DateRangePicker.svelte'
let reportStart = $state('2025-01-01')
let reportEnd = $state('2025-03-31')
function handleRangeChange({ startDate, endDate, days }) {
console.log(`Report covers ${days} days: ${startDate} to ${endDate}`)
}
</script>
<DateRangePicker
bind:startDate={reportStart}
bind:endDate={reportEnd}
minDate="2020-01-01"
maxDate="2025-12-31"
onrangechange={handleRangeChange}
/>
<p>Generating report from {reportStart} to {reportEnd}</p>
<button
onclick={() => {
// Programmatically set the range
reportStart = '2025-06-01'
reportEnd = '2025-06-30'
}}
>
Set to June 2025
</button> When to Use $bindable vs Callback Props
The Decision RuleIf you’re synchronising a single value that naturally flows both ways (a text field, a checkbox, a toggle), use
$bindable. If the parent needs to intercept, validate, or transform the change before applying it — or if multiple values change atomically — use a callback prop.
Both $bindable and callback props enable child-to-parent communication, but they serve different purposes and have different characteristics. Choosing the right pattern depends on your specific needs.
Use $bindable when:
- You’re creating form-like inputs that modify a single, simple value
- The parent and child should share and synchronize the same state
- You want the familiar, idiomatic
bind:syntax that Svelte developers expect - The prop represents mutable state that naturally flows in both directions
- Changes should immediately reflect in both directions without intermediate processing
<!-- PREFERRED: Good uses of $bindable - simple value synchronization -->
<TextInput bind:value={username} />
<Slider bind:value={volume} min={0} max={100} />
<Toggle bind:checked={darkMode} />
<ColorPicker bind:color={selectedColor} />
<Dropdown bind:selected={country} options={countries} /> Use callback props when:
- You’re communicating events or actions (not synchronizing state)
- The parent needs to decide how to handle changes (validation, transformation, conditional updates)
- Multiple values change together as part of a single operation
- You need to perform async operations before accepting changes
- The “change” is really an event with associated data, not a simple value update
<!-- PREFERRED: Good uses of callbacks - events and complex interactions -->
<FileUploader onupload={(files) => processAndStoreFiles(files)} />
<DataGrid
rows={data}
onrowselect={(row) => setSelectedRow(row)}
onrowdelete={(row) => confirmAndDelete(row)}
/>
<Form
onsubmit={async (data) => {
const errors = await validateOnServer(data)
if (errors) return showErrors(errors)
await saveDataToServer(data)
}}
onerror={(e) => logError(e)}
/>
<SearchAutocomplete
onsearch={(query) => fetchSuggestions(query)}
onselect={(suggestion) => navigateTo(suggestion.url)}
/> A hybrid approach sometimes makes sense—use $bindable for the primary value but callbacks for events:
<!-- ComboBox.svelte -->
<script>
let {
// The selected value - bindable for easy two-way sync
value = $bindable(''),
// Events that communicate actions, not value changes
onfocus,
onblur,
oninputchange, // When user types (before selection)
onselect // When user selects from dropdown
} = $props()
</script>
<!-- Parent.svelte -->
<ComboBox
bind:value={selectedCity}
oninputchange={(query) => fetchCitySuggestions(query)}
onselect={(city) => loadCityDetails(city)}
/> Bindable Props with Object State
When a bindable prop is an object, things get interesting. The child component can mutate properties of the object, and because it’s the same object reference, those mutations affect both parent and child state immediately.
<!-- UserEditor.svelte -->
<script>
let { user = $bindable({ name: '', email: '', bio: '', role: 'user' }) } = $props()
</script>
<div class="user-editor">
<label>
Name
<input bind:value={user.name} placeholder="Name" />
</label>
<label>
Email
<input type="email" bind:value={user.email} placeholder="Email" />
</label>
<label>
Bio
<textarea bind:value={user.bio} placeholder="Bio"></textarea>
</label>
<label>
Role
<select bind:value={user.role}>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="moderator">Moderator</option>
</select>
</label>
</div> <!-- Profile.svelte (parent) -->
<script>
import UserEditor from './UserEditor.svelte'
let currentUser = $state({
name: 'Stan',
email: 'stan@example.com',
role: 'admin'
})
</script>
<UserEditor bind:user={currentUser} />
<!-- Changes in UserEditor immediately reflect here! -->
<pre>{JSON.stringify(currentUser, null, 2)}</pre> This is powerful but requires careful consideration. The parent and child share the same object—there’s no isolation. Every keystroke in the child immediately mutates the parent’s state.
For “edit and save” UIs, you might want to work with a copy and only propagate changes on explicit save:
<!-- SafeUserEditor.svelte -->
<script>
let {
user, // The "source of truth" from parent
onsave, // Called when user clicks save
oncancel // Called when user cancels
} = $props()
// Create a local draft copy for editing
// Changes to 'draft' don't affect 'user'
let draft = $state({ ...user })
// Track if there are unsaved changes
let hasChanges = $derived(
draft.name !== user.name ||
draft.email !== user.email ||
draft.role !== user.role ||
draft.bio !== user.bio
)
function save() {
onsave(draft)
}
function cancel() {
// Reset draft to original values
draft = { ...user }
oncancel?.()
}
function reset() {
draft = { ...user }
}
</script>
<div class="user-editor">
<label>
Name
<input bind:value={draft.name} />
</label>
<label>
Email
<input type="email" bind:value={draft.email} />
</label>
<label>
Role
<select bind:value={draft.role}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</label>
<div class="actions">
<button
onclick={() => {
draft = { ...user }
oncancel?.()
}}
disabled={!hasChanges}
>
Cancel
</button>
<button onclick={reset} disabled={!hasChanges}>Reset</button>
<button onclick={save} disabled={!hasChanges}>Save Changes</button>
</div>
</div> This pattern gives users the ability to make changes, preview them, and either commit or discard them—without affecting the parent’s state until explicitly saved.
Advanced TypeScript Patterns
TypeScript integration goes beyond basic prop typing. Let’s explore patterns for complex components.
Typing Rest Props
Extend HTMLButtonAttributes for Full Type SafetyImporting
HTMLButtonAttributes(or anyHTML*Attributestype) fromsvelte/elementsand extending yourPropsinterface gives TypeScript complete knowledge of every valid attribute for the underlying element — including event handlers, ARIA attributes, and data attributes.
For wrapper components, extend HTML element types from svelte/elements:
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements'
interface Props extends HTMLButtonAttributes {
variant?: 'primary' | 'secondary' | 'danger'
loading?: boolean
}
let { variant = 'primary', loading = false, children, disabled, ...rest }: Props = $props()
</script>
<button class="btn btn-{variant}" disabled={loading || disabled} {...rest}>
{#if loading}<span class="spinner"></span>{/if}
{@render children?.()}
</button> Now TypeScript validates all button attributes:
<!-- CORRECT-->
<Button type="submit" form="checkout">Submit</Button>
<!-- ERROR -->
<Button potato="yes">Click</Button> Typing Snippets
The Snippet type from svelte enables typed content slots:
<script lang="ts">
import type { Snippet } from 'svelte'
interface DataItem {
id: string
name: string
price: number
}
interface Props {
items: DataItem[]
children?: Snippet
header?: Snippet<[{ total: number }]>
row?: Snippet<[item: DataItem, index: number]>
}
let { items, children, header, row }: Props = $props()
let total = $derived(items.reduce((sum, item) => sum + item.price, 0))
</script>
{#if header}
{@render header({ total })}
{/if}
{#each items as item, i}
{#if row}
{@render row(item, i)}
{:else}
<div>{item.name}: ${item.price}</div>
{/if}
{/each}
{@render children?.()} Consumers get full type inference:
<ItemList {items}>
{#snippet header(info)}
<h2>Total: ${info.total}</h2>
<!-- info.total is typed as number -->
{/snippet}
{#snippet row(item, index)}
<div>#{index + 1}: {item.name}</div>
<!-- item is typed as DataItem -->
{/snippet}
</ItemList> Event Handler Typing
Type handlers precisely for better autocomplete:
<script lang="ts">
interface Props {
onclose?: () => void
onclick?: (event: MouseEvent) => void
onselect?: (item: { id: string; label: string }) => void
onsubmit?: (data: FormData) => Promise<void>
validate?: (value: string) => boolean
}
let { onclose, onclick, onselect, onsubmit, validate }: Props = $props()
</script> Generic Components
Generics enable components that work with any data type while maintaining full type safety.
Basic Generic Component
Generics Are Inferred from UsageYou don’t need to specify the type parameter at the call site — TypeScript infers
Tfrom theitemsprop you pass in. This means your generic component is fully type-safe without any extra annotation from the consumer.
<script lang="ts" generics="T">
interface Props {
items: T[]
selected?: T | null
onselect?: (item: T) => void
renderItem?: (item: T) => string
}
let {
items,
selected = $bindable(null),
onselect,
renderItem = (item) => String(item)
}: Props = $props()
</script>
<ul class="select-list">
{#each items as item}
<li>
<button
class:selected={item === selected}
onclick={() => {
selected = item
onselect?.(item)
}}
>
{renderItem(item)}
</button>
</li>
{/each}
</ul> TypeScript infers T from usage:
<script lang="ts">
const fruits = ['apple', 'banana', 'cherry']
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
]
let selectedFruit = $state<string | null>(null)
let selectedUser = $state<(typeof users)[0] | null>(null)
</script>
<!-- T inferred as string -->
<SelectList items={fruits} bind:selected={selectedFruit} />
<!-- T inferred as { id: number; name: string } -->
<SelectList items={users} bind:selected={selectedUser} renderItem={(u) => u.name} /> Constrained Generics
Require items to have specific properties:
<script lang="ts" generics="T extends { id: string; label: string }">
interface Props {
items: T[]
selected?: T | null
onselect?: (item: T) => void
}
let { items, selected = $bindable(null), onselect }: Props = $props()
</script>
<ul>
{#each items as item (item.id)}
<li>
<button
class:selected={selected?.id === item.id}
onclick={() => {
selected = item
onselect?.(item)
}}
>
{item.label}
</button>
</li>
{/each}
</ul> Now TypeScript enforces the constraint:
<!-- PREFERRED:Valid - has id and label -->
<SelectList
items={[
{ id: '1', label: 'Option A', extra: 'data' },
{ id: '2', label: 'Option B', extra: 'more' }
]}
/>
<!-- AVOID: Error - missing required properties -->
<SelectList items={[{ name: 'Alice' }]} /> Multiple Generic Parameters
<script lang="ts" generics="TKey, TValue">
import type { Snippet } from 'svelte'
interface Props {
entries: Map<TKey, TValue> | [TKey, TValue][]
keyRenderer?: Snippet<[key: TKey]>
valueRenderer?: Snippet<[value: TValue]>
onselect?: (key: TKey, value: TValue) => void
}
let { entries, keyRenderer, valueRenderer, onselect }: Props = $props()
let entriesArray = $derived(entries instanceof Map ? [...entries] : entries)
</script>
<table>
{#each entriesArray as [key, value]}
<tr onclick={() => onselect?.(key, value)}>
<td
>{#if keyRenderer}{@render keyRenderer(key)}{:else}{String(key)}{/if}</td
>
<td
>{#if valueRenderer}{@render valueRenderer(value)}{:else}{String(value)}{/if}</td
>
</tr>
{/each}
</table> Architectural Patterns
These patterns solve complex component design challenges.
Discriminated Union Props
When a component has multiple “modes” with different required props:
<script lang="ts">
import type { Snippet } from 'svelte'
type Props =
| { mode: 'static'; content: string; html?: boolean }
| { mode: 'async'; url: string; loading?: Snippet; error?: Snippet<[Error]> }
| { mode: 'custom'; render: Snippet }
let props: Props = $props()
let asyncContent = $state<string | null>(null)
let asyncError = $state<Error | null>(null)
let isLoading = $state(false)
$effect(() => {
if (props.mode === 'async') {
isLoading = true
fetch(props.url)
.then((r) => r.text())
.then((text) => {
asyncContent = text
isLoading = false
})
.catch((err) => {
asyncError = err
isLoading = false
})
}
})
</script>
{#if props.mode === 'static'}
{#if props.html}{@html props.content}{:else}{props.content}{/if}
{:else if props.mode === 'async'}
{#if isLoading}
{#if props.loading}{@render props.loading()}{:else}Loading...{/if}
{:else if asyncError}
{#if props.error}{@render props.error(asyncError)}{:else}Error: {asyncError.message}{/if}
{:else}
{asyncContent}
{/if}
{:else if props.mode === 'custom'}
{@render props.render()}
{/if} TypeScript ensures you only access properties valid for each mode.
Compound Components with Context
Related components that share state:
<!-- Tabs.svelte -->
<script lang="ts" module>
import { setContext, getContext } from 'svelte';
const TABS_KEY = Symbol('tabs');
interface TabsContext {
activeTab: string;
setActiveTab: (id: string) => void;
}
export function getTabsContext(): TabsContext {
const ctx = getContext<TabsContext>(TABS_KEY);
if (!ctx) error('Tab components must be inside <Tabs>');
return ctx;
}
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
defaultTab?: string;
activeTab?: string;
onchange?: (tabId: string) => void;
children: Snippet;
}
let {
defaultTab = '',
activeTab = $bindable(defaultTab),
onchange,
children
}: Props = $props();
setContext<TabsContext>(TABS_KEY, {
get activeTab() { return activeTab; },
setActiveTab(id: string) {
activeTab = id;
onchange?.(id);
}
});
</script>
<div class="tabs">
{@render children()}
</div>
<!-- TabButton.svelte -->
<script lang="ts">
import { getTabsContext } from './Tabs.svelte';
import type { Snippet } from 'svelte';
interface Props {
id: string;
children: Snippet;
}
let { id, children }: Props = $props();
const tabs = getTabsContext();
let isActive = $derived(tabs.activeTab === id);
</script>
<button
class="tab-button"
class:active={isActive}
onclick={() => tabs.setActiveTab(id)}
aria-selected={isActive}
>
{@render children()}
</button>
<!-- TabPanel.svelte -->
<script lang="ts">
import { getTabsContext } from './Tabs.svelte';
import type { Snippet } from 'svelte';
interface Props {
id: string;
children: Snippet;
}
let { id, children }: Props = $props();
const tabs = getTabsContext();
let isActive = $derived(tabs.activeTab === id);
</script>
{#if isActive}
<div class="tab-panel" role="tabpanel">
{@render children()}
</div>
{/if} Usage:
<Tabs defaultTab="profile">
<div class="tab-list">
<TabButton id="profile">Profile</TabButton>
<TabButton id="settings">Settings</TabButton>
<TabButton id="billing">Billing</TabButton>
</div>
<TabPanel id="profile">
<h2>Profile Settings</h2>
<p>Manage your profile...</p>
</TabPanel>
<TabPanel id="settings">
<h2>App Settings</h2>
<p>Configure your preferences...</p>
</TabPanel>
<TabPanel id="billing">
<h2>Billing</h2>
<p>Payment methods...</p>
</TabPanel>
</Tabs> Runtime Validation with Effects
TypeScript catches compile-time errors. For runtime validation:
<script lang="ts">
interface Props {
min: number
max: number
value?: number
}
let { min, max, value = $bindable(min) }: Props = $props()
$effect(() => {
if (min > max) {
console.warn(`RangeSlider: min (${min}) > max (${max})`)
}
if (value < min) {
console.warn(`RangeSlider: clamping value ${value} to min ${min}`)
value = min
}
if (value > max) {
console.warn(`RangeSlider: clamping value ${value} to max ${max}`)
value = max
}
})
</script>
<input type="range" {min} {max} bind:value /> Use $effect for this kind of reactive validation and clamping — it runs after each state change, which is exactly when you need to inspect and correct values. Reserve $effect.pre for cases where you need to read the DOM before Svelte applies its pending updates, such as capturing scroll position before a list re-renders.
Key Takeaways
These advanced patterns unlock the full power of Svelte 5’s prop system:
Rest Props & Forwarding enable transparent attribute forwarding, essential for wrapper components and design systems.
$bindable creates controlled two-way bindings for form-like components while maintaining clear data ownership.
Advanced TypeScript with HTML element types, Snippet generics, and interface extensions provides compile-time safety for complex components.
Generic Components enable type-safe reusability across different data types.
Architectural Patterns like discriminated unions and compound components solve real-world design challenges.
Master these patterns, and you’ll build Svelte 5 components that are flexible, type-safe, and production-ready.
See Also
Official Documentation
- Svelte 5: $props - Official reference
- Svelte 5: $bindable - Two-way binding
- Svelte 5: TypeScript - TypeScript guide
- Svelte REPL - Try code online
External Resources
- TypeScript Handbook: Generics - TypeScript generics
- Svelte Society - Community resources
- Svelte Discord - Get help