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 Basics

This 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 Rule

If 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 Safety

Importing HTMLButtonAttributes (or any HTML*Attributes type) from svelte/elements and extending your Props interface 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 Usage

You don’t need to specify the type parameter at the call site — TypeScript infers T from the items prop 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

External Resources