Two-Way Data Flow Made Simple
In most frameworks, data flows in one direction: from component state to the DOM. When a user types in an input field, you must manually listen to events and update state. This pattern — while explicit — creates boilerplate that obscures intent and makes simple interactions feel laborious.
Consider this common scenario: you have a search input, and you want to filter a list as the user types. Without binding, you need event handlers, state updates, and careful coordination:
<script>
let searchTerm = $state('')
function handleInput(event) {
searchTerm = event.target.value
}
</script>
<input value={searchTerm} oninput={handleInput} /> Svelte’s bind: directive eliminates this friction by establishing two-way data binding: when state changes, the UI updates automatically, and when the user interacts with bound elements, state updates automatically. This bidirectional synchronization transforms common patterns like form handling from multi-step choreography into declarative simplicity:
<script>
let searchTerm = $state('')
</script>
<input bind:value={searchTerm} /> One line. No event handlers. No ceremony. The state and input stay perfectly synchronized.
Why this matters: Two-way binding isn’t about writing less code—it’s about expressing intent clearly. When you write bind:value={searchTerm}, you’re declaring a relationship: “this input and this variable are two views of the same data.” Svelte handles all the synchronization mechanics for you.
This tutorial explores the bind: directive comprehensively: from basic input bindings to advanced component prop bindings, from readonly media properties to dimension measurements, from form reset behaviors to performance optimization. You’ll understand not just the syntax, but the mental model that makes bindings powerful and the patterns that keep them maintainable.
Understanding Data Flow
The One-Way Data Flow Default
By default, data in Svelte (and most reactive frameworks) flows downward:
<script>
let name = $state('Alice')
</script>
<p>Hello, {name}!</p> When name changes, the paragraph updates. Simple, predictable, and unidirectional. This works perfectly for display but what about user input?
Without binding, capturing user input requires explicit event handling:
<script>
let name = $state('Alice')
function handleInput(event) {
name = event.target.value
}
</script>
<input value={name} oninput={handleInput} /><p>Hello, {name}!</p> You’re manually closing the loop: state → DOM (via value), and DOM → state (via oninput). This pattern is verbose but transparent, you can see exactly what’s happening.
The Two-Way Binding Shortcut
The bind: directive automates this loop:
<script>
let name = $state('Alice')
</script>
<input bind:value={name} /><p>Hello, {name}!</p> Behind the scenes, Svelte creates the event listener for you. When the input fires an input event, Svelte updates name. When your code updates name, Svelte updates the input’s value property. The two stay synchronized automatically.
Mental model: Think of bind: as creating a “live connection” between a variable and a DOM property. Any change on either end propagates to the other instantly.
When to Use Each Approach
Use bind: when:
- The relationship is straightforward: “this input controls this variable”
- You don’t need to transform, validate, or intercept the value during updates
- You’re building forms, settings panels, or interactive controls
Use explicit event handlers when:
- You need to transform input (e.g., trim, lowercase, sanitize)
- You need validation before accepting changes
- You need to trigger side effects (API calls, analytics)
- The logic is complex enough that explicitness aids understanding
Both approaches are valid. Use bind: for simplicity, event handlers for control.
Basic Input Bindings
Text Inputs: bind:value
The most common binding synchronizes an <input> element’s value with a variable:
<script>
let username = $state('')
let email = $state('')
let bio = $state('')
</script>
<form>
<label>
Username:
<input type="text" bind:value={username} />
</label>
<label>
Email:
<input type="email" bind:value={email} />
</label>
<label>
Bio:
<textarea bind:value={bio}></textarea>
</label>
</form>
<div class="preview">
<p><strong>Username:</strong> {username || '(empty)'}</p>
<p><strong>Email:</strong> {email || '(empty)'}</p>
<p><strong>Bio:</strong> {bio || '(empty)'}</p>
</div> How it works: Svelte listens to the input event on the element. Every time the user types, the event fires, and Svelte reads event.target.value and assigns it to your variable. If you change the variable programmatically (e.g., username = 'Bob'), Svelte updates the input’s value property, and the DOM reflects the change.
Shorthand syntax: When the variable name matches the property name, you can omit the value:
<input bind:value />
<!-- Same as: -->
<input bind:value /> This only works when the variable is literally named value. It’s a small convenience for common cases.
Number Inputs: Automatic Type Coercion
For <input type="number"> and <input type="range">, Svelte automatically converts the string value to a number:
<script>
let quantity = $state(1)
let price = $state(9.99)
let discount = $state(0)
let total = $derived((quantity * price * (1 - discount / 100)).toFixed(2))
</script>
<div class="calculator">
<label>
Quantity:
<input type="number" bind:value={quantity} min="1" />
</label>
<label>
Price per item: $
<input type="number" bind:value={price} min="0" step="0.01" />
</label>
<label>
Discount: {discount}%
<input type="range" bind:value={discount} min="0" max="50" />
</label>
<p class="total">Total: ${total}</p>
</div> Why automatic coercion matters: In HTML, all form values are strings. Without coercion, you’d need to manually call parseFloat() or parseInt() on every value. Svelte handles this for you, so quantity and price are actual numbers, not strings.
Edge case - empty or invalid inputs: If a number input is empty or contains invalid characters, the bound value becomes undefined (not NaN or "" as in some frameworks). Plan for this:
<script>
let age = $state(undefined)
let isValid = $derived(age !== undefined && age > 0 && age < 150)
</script>
<input type="number" bind:value={age} placeholder="Your age" />
{#if age === undefined}
<p class="hint">Please enter your age</p>
{:else if !isValid}
<p class="error">Please enter a valid age (1-150)</p>
{:else}
<p class="success">Age: {age}</p>
{/if} Why undefined and not NaN? Svelte treats empty/invalid number inputs as “no value provided” rather than “a numeric parse error.” This makes it easier to distinguish between “user hasn’t entered anything yet” and “user entered something, and it’s zero.”
Default Values and Form Reset
Since Svelte 5.6, inputs can have a defaultValue attribute. When the input is part of a form and the form is reset, the input reverts to this default value instead of becoming empty:
<script>
let name = $state('') // Starts empty
let email = $state('user@example.com') // Starts with value
</script>
<form>
<label>
Name:
<input bind:value={name} defaultValue="Guest" />
</label>
<label>
Email:
<input bind:value={email} defaultValue="user@example.com" />
</label>
<button type="reset">Reset Form</button>
</form>
<p>Name: {name || '(empty)'}</p>
<p>Email: {email}</p> How it works: When you click “Reset”, the browser’s built-in form reset mechanism fires. Svelte intercepts this and sets bound values to their defaultValue instead of clearing them. This is especially useful for forms that should revert to a “known good state” rather than blanking out entirely.
Initial render priority: On the first render, the binding value takes precedence over defaultValue unless the binding is null or undefined. This means:
<script>
let value1 = $state('initial') // Shows "initial"
let value2 = $state(null) // Shows "default"
</script>
<input bind:value={value1} defaultValue="default" />
<input bind:value={value2} defaultValue="default" /> Checkbox Bindings
Single Checkbox: bind:checked
For a single checkbox, bind to a boolean:
<script>
let accepted = $state(false)
let newsletter = $state(false)
let notifications = $state(true)
let canSubmit = $derived(accepted) // Terms must be accepted
</script>
<form>
<label>
<input type="checkbox" bind:checked={accepted} />
I accept the terms and conditions (required)
</label>
<label>
<input type="checkbox" bind:checked={newsletter} />
Subscribe to newsletter (optional)
</label>
<label>
<input type="checkbox" bind:checked={notifications} />
Enable push notifications (optional)
</label>
<button type="submit" disabled={!canSubmit}>Submit</button>
</form>
<div class="status">
<p>Terms accepted: {accepted ? '✓' : '✗'}</p>
<p>Newsletter: {newsletter ? '✓' : '✗'}</p>
<p>Notifications: {notifications ? '✓' : '✗'}</p>
</div> Why bind:checked and not bind:value? For checkboxes, the meaningful property is checked (a boolean), not value (which is typically a static string like “on”). Svelte binds to what matters.
Form reset with defaultChecked: Similar to defaultValue, checkboxes can have a defaultChecked attribute:
<script>
let enabled = $state(true)
</script>
<form>
<label>
<input type="checkbox" bind:checked={enabled} defaultChecked={true} />
Feature enabled
</label>
<button type="reset">Reset to default (checked)</button>
</form> When the form resets, enabled becomes true (the default), not false.
Indeterminate State: bind:indeterminate
Checkboxes can be in an indeterminate state—visually distinct from checked or unchecked. This is useful for “select all” checkboxes that control a group:
<script>
let items = $state([
{ id: 1, name: 'Item 1', selected: true },
{ id: 2, name: 'Item 2', selected: false },
{ id: 3, name: 'Item 3', selected: true }
])
let allSelected = $derived(items.every((item) => item.selected))
let noneSelected = $derived(items.every((item) => !item.selected))
let someSelected = $derived(!allSelected && !noneSelected)
function toggleAll() {
const newState = !allSelected
items = items.map((item) => ({ ...item, selected: newState }))
}
</script>
<label>
<input type="checkbox" checked={allSelected} indeterminate={someSelected} onclick={toggleAll} />
Select All
</label>
<hr />
{#each items as item}
<label>
<input
type="checkbox"
checked={item.selected}
onchange={(e) => {
items = items.map((i) => (i.id === item.id ? { ...i, selected: e.target.checked } : i))
}}
/>
{item.name}
</label>
{/each}
<p>Selected: {items.filter((i) => i.selected).length} of {items.length}</p> Why indeterminate matters: When you have a parent checkbox controlling child checkboxes, the parent should show three states:
- Checked: All children are selected
- Unchecked: No children are selected
- Indeterminate: Some but not all children are selected
This gives users clear visual feedback about the selection state without ambiguity.
Technical note: The indeterminate state is purely visual—it doesn’t affect the checked property. A checkbox can be both checked={true} and indeterminate={true} simultaneously. The indeterminate styling (usually a dash) overrides the checkmark visually.
Checkbox Groups: bind:group
When multiple checkboxes should populate the same array, use bind:group:
<script>
let selectedFeatures = $state([])
let features = [
{ id: 'wifi', label: 'WiFi', price: 0 },
{ id: 'bluetooth', label: 'Bluetooth', price: 5 },
{ id: 'gps', label: 'GPS', price: 10 },
{ id: '4g', label: '4G LTE', price: 15 }
]
let totalPrice = $derived(
selectedFeatures.reduce((sum, id) => {
const feature = features.find((f) => f.id === id)
return sum + (feature?.price || 0)
}, 0)
)
</script>
<fieldset>
<legend>Select Features</legend>
{#each features as feature}
<label>
<input type="checkbox" bind:group={selectedFeatures} value={feature.id} />
{feature.label}
{#if feature.price > 0}
(+${feature.price})
{/if}
</label>
{/each}
</fieldset>
<p>Selected: {selectedFeatures.join(', ') || 'None'}</p>
<p>Additional cost: ${totalPrice}</p> How bind:group works: All checkboxes with bind:group={array} share the same array. When a checkbox is checked, its value attribute is added to the array. When unchecked, it’s removed. Svelte handles the array manipulation for you.
Why not use individual booleans? You could create a separate boolean for each checkbox, but then you’d need to manually keep track of which are checked and build the array yourself. bind:group eliminates that bookkeeping.
Important: All checkboxes in a group must be in the same Svelte component. Groups don’t work across component boundaries (though you can pass the array as a prop to child components).
Radio Button Groups
Radio buttons are mutually exclusive—only one can be selected at a time. Use bind:group with the same variable for all radios in the group:
<script>
let shippingMethod = $state('standard')
let shippingOptions = [
{ id: 'standard', label: 'Standard (5-7 days)', price: 5.99 },
{ id: 'express', label: 'Express (2-3 days)', price: 12.99 },
{ id: 'overnight', label: 'Overnight', price: 24.99 }
]
let shippingCost = $derived.by(() => {
const option = shippingOptions.find((o) => o.id === shippingMethod)
return option?.price || 0
})
</script>
<fieldset>
<legend>Shipping Method</legend>
{#each shippingOptions as option}
<label>
<input type="radio" bind:group={shippingMethod} value={option.id} />
{option.label} - ${option.price.toFixed(2)}
</label>
{/each}
</fieldset>
<p>Selected: {shippingMethod}</p>
<p>Shipping cost: ${shippingCost.toFixed(2)}</p> Why bind:group for radios? Unlike checkboxes (where bind:group populates an array), radio buttons with bind:group set the variable to the selected radio’s value. When you select a different radio, the variable updates to that radio’s value.
Mental model: Think of radio buttons as a “pick one from these options” control. The group variable holds the currently selected option’s value. When the user clicks a different radio, Svelte updates the variable. When you programmatically change the variable, Svelte checks the matching radio.
Default selection: If no radio is initially checked, the variable’s initial value determines which radio is selected:
<script>
let method = $state('express') // 'Express' radio will be checked on load
</script> If the variable’s value doesn’t match any radio’s value, no radio is checked initially (though users can then select one).
Select Dropdowns
Single Select: bind:value
A <select> element’s binding corresponds to the value property of the selected <option>:
<script>
let selectedCountry = $state('us')
let countries = [
{ code: 'us', name: 'United States' },
{ code: 'ca', name: 'Canada' },
{ code: 'uk', name: 'United Kingdom' },
{ code: 'au', name: 'Australia' },
{ code: 'de', name: 'Germany' }
]
</script>
<label>
Country:
<select bind:value={selectedCountry}>
{#each countries as country}
<option value={country.code}>
{country.name}
</option>
{/each}
</select>
</label>
<p>You selected: {selectedCountry}</p> Option values can be any type: Unlike plain HTML (where values are always strings), Svelte lets you use objects or numbers as option values:
<script>
let selectedUser = $state(null)
let users = [
{ id: 1, name: 'Alice', role: 'Admin' },
{ id: 2, name: 'Bob', role: 'Editor' },
{ id: 3, name: 'Charlie', role: 'Viewer' }
]
</script>
<select bind:value={selectedUser}>
<option value={null}>-- Select a user --</option>
{#each users as user}
<option value={user}>
{user.name} ({user.role})
</option>
{/each}
</select>
{#if selectedUser}
<p>Selected: {selectedUser.name}, role: {selectedUser.role}</p>
{/if} When the user selects an option, selectedUser becomes the full user object, not just a string. This eliminates the need for lookups.
Default selection with selected attribute: You can mark an option as initially selected using the selected attribute:
<select bind:value={color}>
<option value="red">Red</option>
<option value="green" selected>Green</option>
<option value="blue">Blue</option>
</select> When a form containing this select is reset, it will revert to “Green” (the marked option), not the first option.
Initial render priority: As with inputs, the binding value takes precedence over the selected attribute unless the binding is undefined.
Multiple Select: bind:value with Array
A <select multiple> element behaves like a checkbox group—the bound variable is an array:
<script>
let selectedTags = $state(['javascript', 'svelte'])
let availableTags = [
'javascript',
'typescript',
'svelte',
'react',
'vue',
'angular',
'node',
'deno'
]
</script>
<label>
Select tags (hold Ctrl/Cmd to select multiple):
<select bind:value={selectedTags} multiple size="8">
{#each availableTags as tag}
<option value={tag}>{tag}</option>
{/each}
</select>
</label>
<p>Selected: {selectedTags.join(', ') || 'None'}</p> How it works: When an option is selected, its value is added to the array. When deselected, it’s removed. Svelte keeps the array synchronized with the user’s selections.
Usability note: Multi-select dropdowns are notoriously difficult to use. Consider using checkboxes or custom multi-select components for better UX.
File Inputs: bind:files
File inputs use bind:files to access the FileList of selected files:
<script>
let files = $state()
let previewUrls = $state([])
$effect(() => {
if (files && files.length > 0) {
previewUrls = Array.from(files).map((file) => URL.createObjectURL(file))
} else {
previewUrls = []
}
// Cleanup URLs when files change
return () => {
previewUrls.forEach((url) => URL.revokeObjectURL(url))
}
})
function clearFiles() {
// To clear files, you must create a new FileList via DataTransfer
files = new DataTransfer().files
}
</script>
<label for="images">Upload images:</label>
<input id="images" type="file" bind:files accept="image/*" multiple />
<button onclick={clearFiles}>Clear</button>
{#if files && files.length > 0}
<p>Selected {files.length} file(s):</p>
<ul>
{#each Array.from(files) as file}
<li>{file.name} ({(file.size / 1024).toFixed(2)} KB)</li>
{/each}
</ul>
<div class="previews">
{#each previewUrls as url}
<img src={url} alt="Preview" style="max-width: 200px; margin: 8px;" />
{/each}
</div>
{/if} Why bind:files is special: Unlike other bindings, file inputs require a FileList object, which cannot be directly constructed. To programmatically set files, you must create a DataTransfer object and get its files property.
Why files start as undefined: In server-side rendering environments, DataTransfer may not be available. Leaving files uninitialized prevents errors during SSR.
Modifying the file list: FileList is immutable—you can’t remove individual files directly. To remove a file, create a new DataTransfer, add the files you want to keep, and assign the new FileList:
<script>
function removeFile(indexToRemove) {
const dt = new DataTransfer()
Array.from(files).forEach((file, index) => {
if (index !== indexToRemove) {
dt.items.add(file)
}
})
files = dt.files
}
</script> Media Element Bindings
Media elements (<audio> and <video>) have several bindable properties. Understanding which are two-way and which are readonly is critical.
Two-Way Media Bindings
These properties can be both read and set:
<script>
let currentTime = $state(0)
let playbackRate = $state(1)
let paused = $state(true)
let volume = $state(1)
let muted = $state(false)
</script>
<video
src="/sample-video.mp4"
bind:currentTime
bind:playbackRate
bind:paused
bind:volume
bind:muted
controls
></video>
<div class="controls">
<button onclick={() => (paused = !paused)}>
{paused ? '▶️ Play' : '⏸️ Pause'}
</button>
<button onclick={() => (currentTime = 0)}>⏮️ Restart</button>
<label>
Volume: {Math.round(volume * 100)}%
<input type="range" bind:value={volume} min="0" max="1" step="0.01" />
</label>
<label>
<input type="checkbox" bind:checked={muted} />
Muted
</label>
<label>
Speed:
<select bind:value={playbackRate}>
<option value={0.5}>0.5x</option>
<option value={1}>1x (Normal)</option>
<option value={1.25}>1.25x</option>
<option value={1.5}>1.5x</option>
<option value={2}>2x</option>
</select>
</label>
</div> Why these are two-way:
currentTime: You can seek to a specific time by setting this valueplaybackRate: You can speed up or slow down playback programmaticallypaused: Settingpaused = falseplays the video;paused = truepauses itvolume: You can adjust volume programmatically (0 to 1)muted: You can mute/unmute programmatically
These bindings let you build custom media controls that are fully synchronized with the media element’s state.
Readonly Media Bindings
These properties reflect the media’s state but cannot be set:
<script>
let duration = $state(0)
let buffered = $state(null)
let seekable = $state(null)
let seeking = $state(false)
let ended = $state(false)
let readyState = $state(0)
let played = $state(null)
let progress = $derived(duration > 0 ? (currentTime / duration) * 100 : 0)
</script>
<audio
src="/podcast.mp3"
bind:duration
bind:buffered
bind:seekable
bind:seeking
bind:ended
bind:readyState
bind:played
controls
></audio>
<div class="info">
<p>Duration: {Math.floor(duration)}s</p>
<p>Progress: {progress.toFixed(1)}%</p>
<p>Ready state: {readyState} (0-4)</p>
<p>Seeking: {seeking ? 'Yes' : 'No'}</p>
<p>Ended: {ended ? 'Yes' : 'No'}</p>
</div> Why these are readonly:
duration: The length of the media is determined by the file itselfbuffered: Buffering is handled by the browser, not your codeseekable: Which ranges are seekable depends on the media formatseeking: This reflects whether a seek operation is in progressended: True when playback has reached the endreadyState: Tracks the loading state (0 = nothing, 4 = enough loaded)played: TimeRanges representing which parts have been played
These bindings are useful for displaying information or reacting to state changes (via $effect), but you can’t change the media by setting them.
Video-Specific Bindings
Video elements have two additional readonly bindings for dimensions:
<script>
let videoWidth = $state(0)
let videoHeight = $state(0)
</script>
<video src="/movie.mp4" bind:videoWidth bind:videoHeight controls></video>
<p>Video dimensions: {videoWidth} × {videoHeight}</p> Why readonly? The video’s intrinsic dimensions come from the video file itself. You can style the <video> element with CSS to display at any size, but the actual video resolution is fixed.
Image Element Bindings
Images have similar readonly bindings for their natural (intrinsic) dimensions:
<script>
let naturalWidth = $state(0)
let naturalHeight = $state(0)
</script>
<img src="/photo.jpg" bind:naturalWidth bind:naturalHeight alt="Sample" style="max-width: 400px;" />
<p>Original image size: {naturalWidth} × {naturalHeight}</p>
<p>Display size: {naturalWidth > 400 ? '400px (scaled)' : naturalWidth + 'px'}</p> Use case: These bindings are useful for responsive image handling, calculating aspect ratios, or validating image dimensions before upload.
Details Element: bind:open
The <details> element has a two-way open binding:
<script>
let detailsOpen = $state(false)
let faqOpen = $state(false)
</script>
<details bind:open={detailsOpen}>
<summary>What is Svelte?</summary>
<p>Svelte is a radical new approach to building user interfaces...</p>
</details>
<details bind:open={faqOpen}>
<summary>How does binding work?</summary>
<p>Bindings create a two-way connection between state and DOM properties...</p>
</details>
<button onclick={() => (detailsOpen = !detailsOpen)}>
{detailsOpen ? 'Collapse' : 'Expand'} first section
</button>
<button
onclick={() => {
detailsOpen = true
faqOpen = true
}}
>
Expand all
</button>
<button
onclick={() => {
detailsOpen = false
faqOpen = false
}}
>
Collapse all
</button> Why this is useful: You can programmatically control disclosure widgets, build “expand all” functionality, or persist open/closed state to localStorage.
Contenteditable Bindings
Elements with contenteditable="true" can bind to three different text properties:
<script>
let htmlContent = $state('<p><strong>Bold</strong> and <em>italic</em> text</p>')
let innerTextContent = $state('Plain text only')
let textContentValue = $state('Raw text content')
</script>
<div class="editor-section">
<h3>innerHTML (preserves HTML tags)</h3>
<div contenteditable="true" bind:innerHTML={htmlContent} class="editor"></div>
<pre>{htmlContent}</pre>
</div>
<div class="editor-section">
<h3>innerText (respects text rendering)</h3>
<div contenteditable="true" bind:innerText={innerTextContent} class="editor"></div>
<pre>{innerTextContent}</pre>
</div>
<div class="editor-section">
<h3>textContent (raw text nodes)</h3>
<div contenteditable="true" bind:textContent={textContentValue} class="editor"></div>
<pre>{textContentValue}</pre>
</div>
<style>
.editor {
border: 1px solid #ccc;
padding: 12px;
min-height: 100px;
border-radius: 4px;
margin-bottom: 8px;
}
.editor:focus {
outline: 2px solid #0066cc;
}
</style> Differences explained:
innerHTML: Includes all HTML tags. Useful for rich text editors but dangerous with user input (XSS risk)innerText: Respects CSS styling and visibility. Hidden text is excluded,<br>becomes newlinetextContent: Raw text content of all nodes. Includes hidden text, ignores styling
Security warning: Using bind:innerHTML with user-generated content opens you to XSS attacks. Always sanitize HTML before rendering:
<script>
import DOMPurify from 'dompurify'
let userHtml = $state('')
let sanitizedHtml = $derived(DOMPurify.sanitize(userHtml))
</script>
<div contenteditable bind:innerHTML={userHtml}></div><div>{@html sanitizedHtml}</div> Dimension Bindings (Readonly)
All visible elements can bind to their dimensions using a ResizeObserver. These bindings are readonly—you can’t resize an element by setting these values:
<script>
let clientWidth = $state(0)
let clientHeight = $state(0)
let offsetWidth = $state(0)
let offsetHeight = $state(0)
let contentRect = $state(null)
let area = $derived(clientWidth * clientHeight)
</script>
<div
class="measured-box"
bind:clientWidth
bind:clientHeight
bind:offsetWidth
bind:offsetHeight
bind:contentRect
>
<p>Resize your browser window to see measurements update</p>
<p>clientWidth: {clientWidth}px (content + padding)</p>
<p>clientHeight: {clientHeight}px</p>
<p>offsetWidth: {offsetWidth}px (content + padding + border + scrollbar)</p>
<p>offsetHeight: {offsetHeight}px</p>
<p>Area: {area.toLocaleString()}px²</p>
</div>
<style>
.measured-box {
border: 5px solid #333;
padding: 20px;
margin: 20px;
background: #f0f0f0;
resize: both;
overflow: auto;
min-width: 200px;
min-height: 150px;
}
</style> Understanding the dimensions:
clientWidth/Height: Content area + padding (excludes border and scrollbar)offsetWidth/Height: Content area + padding + border + scrollbarcontentRect: A DOMRectReadOnly with more detailed measurementscontentBoxSize: Array of ResizeObserverSize objectsborderBoxSize: Similar, but includes borderdevicePixelContentBoxSize: Physical pixel dimensions (useful for high-DPI displays)
Why readonly? These bindings observe the element’s size as determined by layout, CSS, and content. You can’t force an element to be a specific size by setting these—you must use CSS or the style attribute.
Use cases:
- Responsive canvas sizing
- Dynamic chart dimensions
- Conditional rendering based on container size
- Measuring text or content before layout decisions
Performance note: Dimension bindings use ResizeObserver, which is highly efficient. However, avoid binding dimensions on hundreds of elements simultaneously—consider using a virtualized list or measuring a container instead.
Important caveat: display: inline elements don’t have width/height (except for intrinsic-sized elements like <img> and <canvas>). Change the element’s display to inline-block or block to measure it.
Element References: bind:this
The bind:this directive captures a reference to a DOM element or component instance:
<script>
let inputRef = $state(null)
let canvasRef = $state(null)
function focusInput() {
inputRef?.focus()
}
$effect(() => {
if (canvasRef) {
const ctx = canvasRef.getContext('2d')
ctx.fillStyle = '#ff0000'
ctx.fillRect(10, 10, 100, 100)
}
})
</script>
<input bind:this={inputRef} placeholder="Click button to focus me" />
<button onclick={focusInput}>Focus Input</button>
<canvas bind:this={canvasRef} width="200" height="200"></canvas> When to use bind:this:
- Calling imperative DOM APIs (focus, play, pause, select)
- Measuring elements that don’t have bindable dimensions
- Integrating third-party libraries that need a DOM reference
- Accessing form elements for validation
Lifecycle timing: The reference is undefined until the element is mounted. Always use optional chaining (?.) or check for existence:
<script>
let videoRef = $state(null)
function playVideo() {
if (videoRef) {
videoRef.play()
}
}
// AVOID: This runs during initialization, before mount
// videoRef.play() // Error: videoRef is undefined
// PREFERRED: Use $effect or event handlers
$effect(() => {
videoRef?.play()
})
</script> Component Instance References
You can also bind to component instances to call their exported functions. While Svelte 5 encourages declarative patterns, imperative APIs are still useful for certain scenarios like modals and animations.
Imperative Approach (using bind:this):
<!-- Modal.svelte -->
<script>
let { children } = $props()
let isOpen = $state(false)
// Functions accessible via bind:this
function open() {
isOpen = true
}
function close() {
isOpen = false
}
function toggle() {
isOpen = !isOpen
}
// Export functions so parent can call them via bind:this
export { open, close, toggle }
</script>
{#if isOpen}
<div class="modal-backdrop" onclick={close}>
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
{@render children()}
<button onclick={close}>Close</button>
</div>
</div>
{/if} <!-- Parent.svelte -->
<script>
import Modal from './Modal.svelte'
let modalRef = $state(null)
</script>
<button onclick={() => modalRef?.open()}>Open Modal</button>
<button onclick={() => modalRef?.toggle()}>Toggle Modal</button>
<Modal bind:this={modalRef}>
<h2>Modal Content</h2>
<p>This modal was opened programmatically!</p>
</Modal> While this works, it mixes imperative control with declarative rendering, which can lead to harder-to-maintain code. In Svelte 5, prefer using $bindable props for better clarity and testability.
Declarative Approach (Svelte 5 preferred):
<!-- Modal.svelte -->
<script>
let { open = $bindable(false), children } = $props()
function close() {
open = false
}
</script>
{#if open}
<div class="modal-backdrop" onclick={close}>
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
{@render children()}
<button onclick={close}>Close</button>
</div>
</div>
{/if} <!-- Parent.svelte -->
<script>
import Modal from './Modal.svelte'
let isModalOpen = $state(false)
</script>
<button onclick={() => (isModalOpen = true)}>Open Modal</button>
<button onclick={() => (isModalOpen = !isModalOpen)}>Toggle Modal</button>
<Modal bind:open={isModalOpen}>
<h2>Modal Content</h2>
<p>This modal is controlled declaratively!</p>
</Modal> With use of $bindable directive for the open prop, the parent component manages the modal’s open state reactively. This leads to clearer data flow and easier testing.
When to use each approach?
Each approach has its merits, and understanding the tradeoffs helps you choose the right pattern for your use case. The decision often comes down to control flow: who should own the state, and how complex is the component’s behavior?
Use imperative (bind:this):
- Complex animations or transitions - When you need fine-grained control over animation timing, sequencing, or coordination across multiple elements. For example, a carousel that needs to pause, resume, or jump to specific slides programmatically.
- Third-party library integration - When wrapping components from libraries that expose imperative APIs (like chart libraries with
.update()or.destroy()methods). - Multiple unrelated actions - When a component exposes several independent operations that don’t map to a single state value. For instance, a video player with
play(),pause(),seek(),setPlaybackRate(), andcaptureFrame(). - Testing component internals - When you need to access internal state or trigger specific behaviors during unit tests, though this should be used sparingly.
- Legacy or third-party components - When working with existing components that were designed with imperative APIs and refactoring them isn’t practical.
Use declarative ($bindable props):
- Simple state management - When the component’s behavior maps cleanly to a boolean or simple value (open/closed, visible/hidden, selected value).
- Parent-driven state - When the parent component needs to be the source of truth for the state, making it easier to coordinate with other parts of your application.
- Predictable data flow - When you want explicit, traceable state changes that are easier to debug and reason about. Every state change flows through the parent’s reactive system.
- Better testability - When you want to test the component by simply passing different prop values rather than calling methods and checking side effects.
- Svelte 5 philosophy - The declarative approach aligns with Svelte 5’s move toward more reactive, less imperative patterns. It’s easier to understand and maintain over time.
Hybrid approach
For complex components, you can combine both patterns:
<!-- VideoPlayer.svelte -->
<script>
let { playing = $bindable(false), volume = $bindable(0.8), currentTime = $bindable(0) } = $props()
let videoElement = $state(null)
// Imperative methods for complex operations
function captureFrame() {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = videoElement.videoWidth
canvas.height = videoElement.videoHeight
ctx.drawImage(videoElement, 0, 0)
return canvas.toDataURL('image/png')
}
function skipToChapter(chapterIndex) {
currentTime = chapters[chapterIndex].startTime
}
export { captureFrame, skipToChapter }
</script>
<video bind:this={videoElement} bind:paused={!playing} bind:volume bind:currentTime /> Best practice: Start with the declarative approach using $bindable props. Only add imperative methods via bind:this when you encounter operations that genuinely don’t map to reactive state. Most modal, dropdown, and toggle components should be fully declarative.
Component Prop Bindings with $bindable
Svelte 5 introduces the $bindable rune for two-way binding between parent and child components.
Making Props Bindable
In the child component, mark props as $bindable:
<!-- Counter.svelte -->
<script>
let { count = $bindable(0), step = 1 } = $props()
</script>
<div class="counter">
<button onclick={() => (count -= step)}>−</button>
<span>{count}</span>
<button onclick={() => (count += step)}>+</button>
</div> In the parent, use bind: to establish two-way binding:
<!-- Parent.svelte -->
<script>
import Counter from './Counter.svelte'
let value = $state(10)
</script>
<Counter bind:count={value} step={5} />
<p>Value in parent: {value}</p>
<button onclick={() => (value = 0)}>Reset from parent</button> How it works: When the child component updates count, the parent’s value updates automatically. When the parent updates value, the child’s display updates. This is true two-way binding across component boundaries.
Fallback values: Bindable props can have default values:
<script>
let { count = $bindable(0) } = $props()
</script> The fallback value (0) applies when the parent doesn’t bind the prop. If the parent binds the prop but passes undefined, you’ll get a runtime error:
<!-- AVOID: This throws an error -->
<Counter bind:count={undefinedValue} />
<!-- PREFERRED: This works -->
<Counter bind:count={zeroValue} />
<Counter />
<!-- Uses fallback: 0 --> Why the error? Svelte enforces that if you opt into binding, you must provide a defined value. This prevents confusing situations where the fallback value fights with an undefined binding.
Multiple Bindable Props
Components can have multiple $bindable props:
<!-- RangeSlider.svelte -->
<script>
let { min = $bindable(0), max = $bindable(100), step = 1 } = $props()
// Ensure min < max
$effect(() => {
if (min > max) {
max = min
}
})
</script>
<div class="range-slider">
<label>
Min: {min}
<input type="range" bind:value={min} min="0" max="100" {step} />
</label>
<label>
Max: {max}
<input type="range" bind:value={max} {min} max="100" {step} />
</label>
</div> Usage:
<script>
import RangeSlider from './RangeSlider.svelte'
let minPrice = $state(20)
let maxPrice = $state(80)
</script>
<RangeSlider bind:min={minPrice} bind:max={maxPrice} step={5} />
<p>Price range: ${minPrice} - ${maxPrice}</p> Custom Input Components
Build reusable form components with validation and styling:
<!-- TextField.svelte -->
<script>
let {
value = $bindable(''),
label,
type = 'text',
placeholder = '',
error = '',
required = false
} = $props()
let touched = $state(false)
let showError = $derived(touched && error)
</script>
<div class="text-field">
<label>
{label}
{#if required}<span class="required">*</span>{/if}
<input
{type}
bind:value
{placeholder}
class:error={showError}
onblur={() => (touched = true)}
/>
</label>
{#if showError}
<p class="error-message">{error}</p>
{/if}
</div>
<style>
.text-field {
margin-bottom: 16px;
}
label {
display: block;
font-weight: 500;
margin-bottom: 4px;
}
.required {
color: #dc2626;
}
input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
}
input:focus {
outline: none;
border-color: #0066cc;
}
input.error {
border-color: #dc2626;
}
.error-message {
color: #dc2626;
font-size: 14px;
margin: 4px 0 0;
}
</style> Usage with validation:
<script>
import TextField from './TextField.svelte'
let formData = $state({
email: '',
password: '',
confirmPassword: ''
})
let errors = $derived.by(() => {
let errs = {}
if (formData.email && !/\S+@\S+\.\S+/.test(formData.email)) {
errs.email = 'Please enter a valid email address'
}
if (formData.password && formData.password.length < 8) {
errs.password = 'Password must be at least 8 characters'
}
if (formData.confirmPassword && formData.password !== formData.confirmPassword) {
errs.confirmPassword = 'Passwords do not match'
}
return errs
})
</script>
<form>
<TextField
bind:value={formData.email}
label="Email"
type="email"
placeholder="you@example.com"
error={errors.email}
required
/>
<TextField
bind:value={formData.password}
label="Password"
type="password"
error={errors.password}
required
/>
<TextField
bind:value={formData.confirmPassword}
label="Confirm Password"
type="password"
error={errors.confirmPassword}
required
/>
<button type="submit" disabled={Object.keys(errors).length > 0}> Create Account </button>
</form> Function Bindings (Advanced)
Since Svelte 5.9, you can use function bindings with bind:property={get, set} syntax. This allows you to intercept and transform values:
<script>
let value = $state('HELLO')
</script>
<!-- Two-way binding with transformation -->
<input bind:value={() => value, (v) => (value = v.toUpperCase())} />
<p>Value (always uppercase): {value}</p> How it works: The first function (getter) returns the current value to display. The second function (setter) receives the new value and can transform it before assigning.
Use cases:
- Input normalization (trimming, uppercase, formatting)
- Validation before accepting values
- Computed bindings with side effects
For readonly bindings: Use null as the getter:
<script>
let width = $state(0)
function handleResize(newWidth) {
console.log('Width changed to:', newWidth)
width = newWidth
}
</script>
<div bind:clientWidth={null, handleResize}>Resizable element</div> This is useful when you want to react to dimension changes but don’t need to display the value directly.
Common Struggles and Solutions
1: Binding to Derived Values
Problem: You cannot directly bind to $derived values because they’re readonly computed values.
<script>
let firstName = $state('John')
let lastName = $state('Doe')
let fullName = $derived(`${firstName} ${lastName}`)
</script>
<!-- AVOID: Cannot bind to derived value -->
<input bind:value={fullName} /> Solution 1: Bind to the underlying state variables:
<input bind:value={firstName} placeholder="First name" />
<input bind:value={lastName} placeholder="Last name" />
<p>Full name: {fullName}</p> Solution 2: Use Svelte 5.9’s function binding syntax to bind a derived read with an explicit setter:
<script>
let celsius = $state(0)
// Derived for display
let fahrenheit = $derived((celsius * 9) / 5 + 32)
</script>
<label>
Celsius: <input type="number" bind:value={celsius} />
</label>
<label>
Fahrenheit:
<!-- getter returns derived, setter writes back through celsius -->
<input type="number" bind:value={() => fahrenheit, (f) => (celsius = ((f - 32) * 5) / 9)} />
</label> 2: Number Inputs Becoming undefined
Problem: Empty or invalid number inputs become undefined, which can break calculations.
<script>
let quantity = $state(0)
let price = $state(0)
// AVOID: If either is undefined, total is NaN
let total = $derived(quantity * price)
</script> Solution: Use nullish coalescing or explicit checks:
<script>
let quantity = $state(0)
let price = $state(0)
let total = $derived((quantity ?? 0) * (price ?? 0))
// Or: isNaN(quantity) || isNaN(price) ? 0 : quantity * price
</script> 3: Binding to Array/Object Properties
Problem: Direct mutation doesn’t trigger reactivity in Svelte 5.
<script>
let items = $state([
{ id: 1, name: 'Item 1', checked: false },
{ id: 2, name: 'Item 2', checked: false }
])
</script>
<!-- AVOID: Direct mutation -->
<input
type="checkbox"
checked={items[0].checked}
onchange={(e) => (items[0].checked = e.target.checked)}
/> Solution: Create a new array to trigger reactivity:
<input
type="checkbox"
checked={items[0].checked}
onchange={(e) => {
items = items.map((item, i) => (i === 0 ? { ...item, checked: e.target.checked } : item))
}}
/> Better approach: Use bind:checked with a handler that properly updates state:
{#each items as item, i}
<label>
<input
type="checkbox"
checked={item.checked}
onchange={(e) => {
// $state Proxy tracks property mutations automatically
items[i].checked = e.target.checked
}}
/>
{item.name}
</label>
{/each} 4: Binding Without $bindable
Problem: Attempting to bind to a component prop that isn’t marked as $bindable.
<!-- AVOID: Child component without $bindable -->
<script>
let { value } = $props() // Not bindable!
</script>
<!-- AVOID: Parent tries to bind - THIS FAILS -->
<Child bind:value={parentValue} /> Solution: Mark the prop as $bindable in the child:
<!-- PREFERRED: Child component with $bindable -->
<script>
let { value = $bindable(0) } = $props()
</script>
<!-- PREFERRED: Now this works -->
<Child bind:value={parentValue} /> 5: Checkbox Group vs. Individual Booleans
Problem: Using the same boolean for multiple checkboxes makes them behave identically.
<script>
let agreed = $state(false)
</script>
<!-- AVOID: Both checkboxes control the same boolean -->
<label><input type="checkbox" bind:checked={agreed} /> Terms</label>
<label><input type="checkbox" bind:checked={agreed} /> Privacy</label>
<!-- Checking one checks both! --> Solution: Use bind:group for arrays or separate booleans:
<script>
// Option 1: bind:group with array
let agreements = $state([])
</script>
<label><input type="checkbox" bind:group={agreements} value="terms" /> Terms</label>
<label><input type="checkbox" bind:group={agreements} value="privacy" /> Privacy</label>
<!-- Option 2: Separate booleans -->
<script>
let agreedTerms = $state(false)
let agreedPrivacy = $state(false)
</script>
<label><input type="checkbox" bind:checked={agreedTerms} /> Terms</label>
<label><input type="checkbox" bind:checked={agreedPrivacy} /> Privacy</label> 6: Select Not Showing Initial Value
Problem: The select dropdown doesn’t show the bound value on initial render.
<script>
let selectedColor = $state('purple') // No matching option!
</script>
<select bind:value={selectedColor}>
<option value="red">Red</option>
<option value="green">Green</option>
<option value="blue">Blue</option>
</select>
<!-- Shows blank because "purple" doesn't exist --> Solution: Ensure the initial value matches an option, or provide a placeholder:
<script>
let selectedColor = $state('red') // Matches first option
</script>
<select bind:value={selectedColor}>
<option value="">-- Select a color --</option>
<option value="red">Red</option>
<option value="green">Green</option>
<option value="blue">Blue</option>
</select> Real-World Patterns
1. Debounced Search Input
The Problem: When users type in a search box connected to an API, making a request on every keystroke overwhelms your server. A user typing “svelte” triggers 6 API calls—one for each letter. This causes unnecessary server load, wasted bandwidth, and potential race conditions.
The Solution: Debouncing delays the API call until the user pauses typing, reducing requests from 6 to 1 while maintaining responsive UX.
<script>
let searchQuery = $state('')
let debouncedQuery = $state('')
let results = $state([])
let isSearching = $state(false)
let debounceTimer
// Debounce the search query
$effect(() => {
clearTimeout(debounceTimer)
isSearching = true
debounceTimer = setTimeout(() => {
debouncedQuery = searchQuery
}, 300)
return () => clearTimeout(debounceTimer)
})
// Perform search when debounced query changes
$effect(() => {
if (debouncedQuery.length < 2) {
results = []
isSearching = false
return
}
isSearching = true
fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`)
.then((r) => r.json())
.then((data) => {
results = data
isSearching = false
})
.catch(() => {
results = []
isSearching = false
})
})
</script>
<div class="search-container">
<input
type="search"
bind:value={searchQuery}
placeholder="Search products..."
class="search-input"
/>
{#if isSearching}
<div class="loading">Searching...</div>
{:else if searchQuery.length > 0}
{#if results.length > 0}
<ul class="results-list">
{#each results as result}
<li><a href="/product/{result.id}">{result.name}</a></li>
{/each}
</ul>
{:else if searchQuery.length >= 2}
<div class="no-results">No results found for "{searchQuery}"</div>
{/if}
{/if}
</div>
<style>
.search-container {
position: relative;
max-width: 500px;
}
.search-input {
width: 100%;
padding: 12px;
font-size: 16px;
border: 2px solid #ccc;
border-radius: 8px;
}
.results-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: white;
border: 1px solid #ccc;
border-radius: 8px;
max-height: 300px;
overflow-y: auto;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
list-style: none;
padding: 0;
}
.results-list li {
padding: 12px;
border-bottom: 1px solid #eee;
}
.results-list li:hover {
background: #f5f5f5;
}
.loading,
.no-results {
padding: 12px;
text-align: center;
color: #666;
}
</style> How it works step-by-step:
searchQuerybinds directly to input for instant visual feedback$effectwatchessearchQueryand sets 300ms timeout to updatedebouncedQuery- If user types again, we clear old timeout and start new one
- Cleanup function prevents memory leaks on unmount
- Separate
$effectwatchesdebouncedQueryand triggers API call isSearchingstate provides loading feedback
Key techniques: bind:value for immediate feedback, $effect cleanup for timer management, separated search state from debounced state, minimum query length validation.
Variations: Adaptive delays based on query length, AbortController for request cancellation, result caching, autocomplete from recent searches.
2. Settings Panel with localStorage Sync
The Problem: Users customize settings (theme, font size, notifications) but everything resets on page refresh, forcing reconfiguration every session.
The Solution: Persist to localStorage, load on mount, save on every change, and apply settings to DOM immediately.
<script>
import { browser } from '$app/environment'
let settings = $state({
theme: 'light',
fontSize: 16,
notifications: true,
autoSave: true,
language: 'en'
})
// Load from localStorage on mount
if (browser) {
const saved = localStorage.getItem('app-settings')
if (saved) {
try {
settings = { ...settings, ...JSON.parse(saved) }
} catch (e) {
console.error('Failed to parse saved settings')
}
}
}
// Save to localStorage whenever settings change
$effect(() => {
if (browser) {
localStorage.setItem('app-settings', JSON.stringify(settings))
// Apply theme
document.documentElement.setAttribute('data-theme', settings.theme)
document.documentElement.style.fontSize = `${settings.fontSize}px`
}
})
</script>
<div class="settings-panel">
<h2>Settings</h2>
<div class="setting">
<label>
Theme
<select bind:value={settings.theme}>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="auto">Auto</option>
</select>
</label>
</div>
<div class="setting">
<label>
Font Size: {settings.fontSize}px
<input type="range" bind:value={settings.fontSize} min="12" max="24" />
</label>
</div>
<div class="setting">
<label>
<input type="checkbox" bind:checked={settings.notifications} />
Enable notifications
</label>
</div>
<div class="setting">
<label>
<input type="checkbox" bind:checked={settings.autoSave} />
Auto-save changes
</label>
</div>
<div class="setting">
<label>
Language
<select bind:value={settings.language}>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
</select>
</label>
</div>
</div>
<div class="preview">
<p>This text respects your font size setting</p>
</div>
<style>
.settings-panel {
max-width: 500px;
padding: 24px;
background: var(--bg-secondary);
border-radius: 8px;
}
.setting {
margin-bottom: 20px;
}
label {
display: block;
font-weight: 500;
}
select,
input[type='range'] {
width: 100%;
margin-top: 8px;
}
.preview {
margin-top: 24px;
padding: 20px;
background: var(--bg-primary);
border-radius: 8px;
}
</style> How it works step-by-step:
- Initialize settings with defaults in
$state - Load from localStorage on mount, merge with defaults
$effectwatches settings object and saves to localStorage- Same
$effectapplies settings to DOM (theme, font size) - Check
browsercontext for SSR compatibility - Try-catch around JSON parsing handles corruption
Key techniques: Mixed bind:value/bind:checked for different inputs, $effect for dual persistence + application, browser checks for SSR, object spreading for partial data merging.
Production considerations: Migration strategy for new settings, validation against schema, storage quota monitoring, cross-tab sync via storage event, GDPR compliance.
Variations: Backend sync for cross-device, import/export as JSON, setting presets, real-time preview, categorized tabs.
3. Multi-Step Form with Validation
The Problem: Long forms overwhelm users. They need progress indication, ability to review previous steps, validation feedback, and confidence their data won’t be lost.
The Solution: Wizard pattern with logical steps, independent validation per step, visual progress, forward navigation only when valid, unrestricted backward navigation.
<script>
let currentStep = $state(1)
let formData = $state({
// Step 1: Personal
firstName: '',
lastName: '',
email: '',
// Step 2: Address
street: '',
city: '',
zipCode: '',
country: 'us',
// Step 3: Preferences
newsletter: false,
notifications: true,
theme: 'light'
})
let step1Valid = $derived(
formData.firstName.length > 0 &&
formData.lastName.length > 0 &&
/\S+@\S+\.\S+/.test(formData.email)
)
let step2Valid = $derived(
formData.street.length > 0 && formData.city.length > 0 && /^\d{5}$/.test(formData.zipCode)
)
let canProceed = $derived(currentStep === 1 ? step1Valid : currentStep === 2 ? step2Valid : true)
function nextStep() {
if (currentStep < 3) currentStep++
}
function prevStep() {
if (currentStep > 1) currentStep--
}
function handleSubmit(e) {
e.preventDefault()
console.log('Form submitted:', formData)
}
</script>
<div class="wizard">
<div class="progress">
<div class="step" class:active={currentStep === 1} class:complete={currentStep > 1}>
<span class="step-number">1</span>
<span class="step-label">Personal</span>
</div>
<div class="step" class:active={currentStep === 2} class:complete={currentStep > 2}>
<span class="step-number">2</span>
<span class="step-label">Address</span>
</div>
<div class="step" class:active={currentStep === 3}>
<span class="step-number">3</span>
<span class="step-label">Preferences</span>
</div>
</div>
<form onsubmit={handleSubmit}>
{#if currentStep === 1}
<div class="form-section">
<h2>Personal Information</h2>
<input bind:value={formData.firstName} placeholder="First Name" required />
<input bind:value={formData.lastName} placeholder="Last Name" required />
<input bind:value={formData.email} type="email" placeholder="Email" required />
</div>
{:else if currentStep === 2}
<div class="form-section">
<h2>Address</h2>
<input bind:value={formData.street} placeholder="Street Address" required />
<input bind:value={formData.city} placeholder="City" required />
<input bind:value={formData.zipCode} placeholder="ZIP Code" pattern="\d{5}" required />
<select bind:value={formData.country}>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="uk">United Kingdom</option>
</select>
</div>
{:else}
<div class="form-section">
<h2>Preferences</h2>
<label>
<input type="checkbox" bind:checked={formData.newsletter} />
Subscribe to newsletter
</label>
<label>
<input type="checkbox" bind:checked={formData.notifications} />
Enable notifications
</label>
<label>
Theme:
<select bind:value={formData.theme}>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="auto">Auto</option>
</select>
</label>
</div>
{/if}
<div class="actions">
{#if currentStep > 1}
<button type="button" onclick={prevStep}>← Back</button>
{/if}
{#if currentStep < 3}
<button type="button" onclick={nextStep} disabled={!canProceed}> Next → </button>
{:else}
<button type="submit">Complete</button>
{/if}
</div>
</form>
</div>
<style>
.wizard {
max-width: 600px;
margin: 0 auto;
}
.progress {
display: flex;
justify-content: space-between;
margin-bottom: 32px;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
position: relative;
}
.step:not(:last-child)::after {
content: '';
position: absolute;
top: 20px;
left: 50%;
width: 100%;
height: 2px;
background: #e5e5e5;
z-index: -1;
}
.step.complete::after {
background: #16a34a;
}
.step-number {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: #e5e5e5;
font-weight: bold;
margin-bottom: 8px;
}
.step.active .step-number {
background: #0066cc;
color: white;
}
.step.complete .step-number {
background: #16a34a;
color: white;
}
.form-section {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
input,
select {
padding: 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
}
.actions {
display: flex;
gap: 12px;
justify-content: space-between;
}
button {
padding: 12px 24px;
background: #0066cc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
button[type='button'] {
background: #666;
}
</style> How it works step-by-step:
- Single
formDataobject holds all fields across steps currentStepcontrols visibility via conditionals- Each step has
$derivedvalidator for relevant fields only - “Next” disabled until current step valid; “Back” always enabled
- CSS classes show active/complete/pending visual state
- Submit button only on final step ensures all validation passed
Key techniques: Single source of truth for form data, granular per-step validation, progressive disclosure via {#if}, reactive CSS classes, conditional button states.
UX best practices: Always allow backward navigation, strict forward validation, clear progress tracking, data persistence across steps, inline field errors.
Accessibility: ARIA progressbar landmarks, keyboard navigation, screen reader announcements via aria-live, focus management on step changes, error summary for screen readers.
Production enhancements: Auto-save to localStorage, URL-based routing for steps, live field validation (debounced), step skipping for power users, progress percentage display, beforeunload warning.
Variations: Non-linear navigation, conditional steps based on answers, review step before submit, save-and-resume with unique URLs, branch logic for different paths.
4. Dynamic Form Builder with Flexible Field Management
The Problem: Applications need forms where users can add/remove fields dynamically, configure properties, and reorder elements. Static forms can’t handle survey builders, custom registration, or admin-configured data collection.
The Solution: Meta-form system separating field configuration from form data. Users dynamically add/remove/configure fields while all values remain bound and reactive.
<script>
let fields = $state([
{ id: 1, type: 'text', label: 'Name', value: '', required: true },
{ id: 2, type: 'email', label: 'Email', value: '', required: true }
])
let nextId = $state(3)
function addField(type) {
fields = [
...fields,
{
id: nextId++,
type,
label: `New ${type} field`,
value: type === 'checkbox' ? false : '',
required: false
}
]
}
function removeField(id) {
fields = fields.filter((f) => f.id !== id)
}
function updateField(id, updates) {
fields = fields.map((f) => (f.id === id ? { ...f, ...updates } : f))
}
let formValues = $derived(
fields.reduce((acc, field) => {
acc[field.label] = field.value
return acc
}, {})
)
let isValid = $derived(
fields.every((field) => !field.required || (field.value && field.value !== ''))
)
</script>
<div class="form-builder">
<h2>Form Builder</h2>
<div class="toolbar">
<button onclick={() => addField('text')}>+ Text</button>
<button onclick={() => addField('email')}>+ Email</button>
<button onclick={() => addField('number')}>+ Number</button>
<button onclick={() => addField('checkbox')}>+ Checkbox</button>
<button onclick={() => addField('textarea')}>+ Text Area</button>
</div>
<form>
{#each fields as field (field.id)}
<div class="field-row">
<input
type="text"
value={field.label}
oninput={(e) => updateField(field.id, { label: e.target.value })}
placeholder="Label"
/>
<label>
<input
type="checkbox"
checked={field.required}
onchange={(e) => updateField(field.id, { required: e.target.checked })}
/>
Required
</label>
{#if field.type === 'checkbox'}
<label>
<input type="checkbox" bind:checked={field.value} />
{field.label}
</label>
{:else if field.type === 'textarea'}
<textarea bind:value={field.value} placeholder={field.label}></textarea>
{:else}
<input type={field.type} bind:value={field.value} placeholder={field.label} />
{/if}
<button onclick={() => removeField(field.id)}>×</button>
</div>
{/each}
</form>
<div class="output">
<h3>Form Data:</h3>
<pre>{JSON.stringify(formValues, null, 2)}</pre>
<p>Valid: {isValid ? '✓' : '✗'}</p>
</div>
</div>
<style>
.field-row {
display: flex;
gap: 8px;
margin-bottom: 12px;
padding: 12px;
background: #f9f9f9;
border-radius: 4px;
}
button {
padding: 8px 12px;
cursor: pointer;
}
</style> How it works: Field metadata in array, dynamic add/remove via array operations, editable labels, type-specific rendering, bound values, derived submission payload.
Key techniques: Heterogeneous field array, binding to array items, $derived for transformation, keyed #each with IDs, conditional rendering by type.
Use cases: Survey builders, CRM customization, event registration, data collection tools, admin form configuration.
Production enhancements: Field templates, drag-and-drop, conditional logic, advanced validation, field grouping, save as template, real-time collaboration.
Performance Considerations
While Svelte’s bindings are highly optimized, understanding their performance characteristics helps you build faster applications. The key insight: bindings are just syntactic sugar over event listeners and reactive assignments. When used wisely, they’re extremely efficient. When misused with complex computations or large lists, they can become bottlenecks.
This section covers common performance pitfalls and their solutions, helping you write bindings that scale from prototypes to production.
1. Debouncing Expensive Operations
The Problem: Every character typed into a bound input triggers an input event, which updates your state, which triggers any $derived expressions that depend on it. If that derived computation is expensive (filtering thousands of items, complex calculations, API calls), you’re doing expensive work on every keystroke.
Example scenario: A user types “svelte” into a search box. That’s 6 keystrokes, triggering 6 full list filters, 6 regex operations, 6 DOM updates. If your list has 10,000 items, you just performed 60,000 filter operations unnecessarily.
The Solution: Debounce expensive operations to wait until the user stops typing before doing the heavy work.
<!-- AVOID: Expensive operation on every keystroke -->
<script>
let searchTerm = $state('')
let products = $state([/* 10,000 products */])
// This runs on EVERY keystroke - very expensive!
let filtered = $derived(
products.filter(product => {
// Complex filtering logic
const matchesName = product.name.toLowerCase().includes(searchTerm.toLowerCase())
const matchesTags = product.tags.some(tag => tag.includes(searchTerm))
const matchesDescription = product.description.toLowerCase().includes(searchTerm.toLowerCase())
return matchesName || matchesTags || matchesDescription
})
)
</script>
<input bind:value={searchTerm} placeholder="Search products..." />
<p>{filtered.length} results</p>
<!-- PREFERRED: Debounce the expensive computation -->
<script>
let searchTerm = $state('') // Updates instantly for UI feedback
let debouncedSearch = $state('') // Updates after user stops typing
let debounceTimer
// Debounce the search term
$effect(() => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debouncedSearch = searchTerm
}, 300) // Wait 300ms after last keystroke
// Cleanup function prevents memory leaks
return () => clearTimeout(debounceTimer)
})
// Expensive operation only runs when debouncedSearch changes
let filtered = $derived(
products.filter(product => {
const matchesName = product.name.toLowerCase().includes(debouncedSearch.toLowerCase())
const matchesTags = product.tags.some(tag => tag.includes(debouncedSearch))
const matchesDescription = product.description.toLowerCase().includes(debouncedSearch.toLowerCase())
return matchesName || matchesTags || matchesDescription
})
)
</script>
<input bind:value={searchTerm} placeholder="Search products..." />
<p>{filtered.length} results</p> Why this works:
searchTermupdates immediately → user sees their typing without lagdebouncedSearchupdates 300ms after typing stops → expensive work runs once, not 6 times- Cleanup function ensures timers are cleared properly on component unmount
Performance impact: For a 10,000-item list with a 6-character search, this reduces operations from 60,000 to 10,000—a 6x improvement. Users don’t notice the 300ms delay because they’re still typing.
Tuning the debounce delay:
- 100-200ms: Good for fast typers, feels very responsive
- 300-400ms: Standard, works for most use cases
- 500ms+: Feels sluggish, only use for very expensive operations
Alternative: Throttling
While debouncing waits until typing stops, throttling limits updates to a maximum frequency:
<script>
let searchTerm = $state('')
let debouncedSearch = $state('')
let lastUpdate = 0
$effect(() => {
const now = Date.now()
if (now - lastUpdate > 300) {
debouncedSearch = searchTerm
lastUpdate = now
}
})
</script> Throttling provides periodic updates during typing, which can feel more responsive for live search.
2. Avoiding Bindings in Large Lists
The Problem: When you bind to properties inside an #each loop with hundreds or thousands of items, Svelte creates that many two-way bindings. Each binding adds an event listener, and when items update, all those listeners fire. This can cause performance issues during rapid updates.
Example scenario: A todo list with 1,000 items, each with a checkbox. Clicking “Select All” triggers 1,000 individual binding updates, each potentially triggering reactivity separately.
<!-- AVOID: Bindings in large lists can be slow -->
<script>
let todos = $state(Array.from({ length: 1000 }, (_, i) => ({
id: i,
text: `Todo ${i}`,
completed: false
})))
function toggleAll() {
const allCompleted = todos.every(todo => todo.completed)
todos = todos.map(todo => ({ ...todo, completed: !allCompleted }))
}
</script>
<!-- This creates 1,000 two-way bindings -->
{#each todos as todo (todo.id)}
<label>
<input type="checkbox" bind:checked={todo.completed} />
{todo.text}
</label>
{/each}
<!-- PREFERRED: Use event handlers for batch updates -->
<script>
let todos = $state(Array.from({ length: 1000 }, (_, i) => ({
id: i,
text: `Todo ${i}`,
completed: false
})))
function toggleTodo(id, completed) {
// Create new array to trigger reactivity
todos = todos.map(todo =>
todo.id === id ? { ...todo, completed } : todo
)
}
function toggleAll() {
const allCompleted = todos.every(todo => todo.completed)
todos = todos.map(todo => ({ ...todo, completed: !allCompleted }))
}
</script>
{#each todos as todo (todo.id)}
<label>
<input
type="checkbox"
checked={todo.completed}
onchange={(e) => toggleTodo(todo.id, e.target.checked)}
/>
{todo.text}
</label>
{/each} Why this works:
- No two-way bindings means fewer event listeners
- Batch updates can be optimized (update multiple items in one pass)
- Easier to add logging, analytics, or validation
When bindings are still fine:
- Lists under ~100 items (bindings are very efficient at this scale)
- Items update infrequently (user manually checking boxes)
- Simplicity matters more than micro-optimizations
Advanced: Virtual Lists
For truly large lists (10,000+ items), consider virtualizing:
<script>
import VirtualList from 'svelte-virtual-list'
let items = $state(
Array.from({ length: 100000 }, (_, i) => ({
id: i,
text: `Item ${i}`,
selected: false
}))
)
</script>
<!-- Only renders visible items + buffer -->
<VirtualList {items} let:item height="400px">
<label>
<input
type="checkbox"
checked={item.selected}
onchange={(e) => {
item.selected = e.target.checked
items = items
}}
/>
{item.text}
</label>
</VirtualList> 3. Binding vs. Event Handlers: When to Use Each
The Tradeoff: Bindings are concise and declarative, but event handlers give you more control. Understanding when to use each improves both code clarity and performance.
Use bind: when:
- Simple synchronization with no transformation
- The relationship is purely declarative: “this input controls this variable”
- No validation or side effects needed
- Rapid prototyping where simplicity matters
<!-- PREFERRED: Perfect use of bind: -->
<script>
let username = $state('')
let age = $state(0)
let newsletter = $state(false)
</script>
<input type="text" bind:value={username} />
<input type="number" bind:value={age} />
<input type="checkbox" bind:checked={newsletter} /> Use event handlers when:
- You need to transform or validate input
- Side effects are required (API calls, analytics)
- Conditional updates (only update if validation passes)
- Complex business logic surrounds the update
<!-- PREFERRED: Event handler for complex logic -->
<script>
let username = $state('')
let usernameError = $state('')
function handleUsernameInput(e) {
let value = e.target.value
// Transform: trim and lowercase
value = value.trim().toLowerCase()
// Validate
if (value.length < 3) {
usernameError = 'Username must be at least 3 characters'
} else if (!/^[a-z0-9_]+$/.test(value)) {
usernameError = 'Only letters, numbers, and underscores allowed'
} else if (value.length > 20) {
usernameError = 'Username too long'
return // Don't update if too long
} else {
usernameError = ''
}
// Side effect: check availability
checkUsernameAvailability(value)
// Only update if valid
username = value
}
</script>
<input type="text" value={username} oninput={handleUsernameInput} aria-invalid={!!usernameError} />
{#if usernameError}
<p class="error">{usernameError}</p>
{/if} Performance comparison:
<!-- bind: is equivalent to this: -->
<input
value={variable}
oninput={(e) => {
variable = e.target.value
}}
/> There’s no performance difference between bind: and a simple event handler assignment. The performance considerations come from what you do after the assignment (expensive derivations, API calls, etc.).
4. Memoization for Expensive Derived Values
The Problem: $derived expressions recalculate whenever their dependencies change. If a derived value depends on a bound input AND that calculation is expensive, you’re doing expensive work on every keystroke.
<!-- AVOID: Expensive calculation on every keystroke -->
<script>
let csvData = $state('')
// Parses CSV on every character typed!
let parsedData = $derived.by(() => {
const rows = csvData.split('\n')
return rows.map((row) => row.split(','))
})
</script>
<textarea bind:value={csvData}></textarea> Solution: we can use debouncing and memoization
<!-- PREFERRED: Debounce + memoization -->
<script>
let csvData = $state('')
let debouncedCsv = $state('')
let debounceTimer
$effect(() => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debouncedCsv = csvData
}, 500)
return () => clearTimeout(debounceTimer)
})
// Only parses when user stops typing
let parsedData = $derived.by(() => {
if (!debouncedCsv) return []
const rows = debouncedCsv.split('\n')
return rows.map((row) => row.split(','))
})
</script>
<textarea bind:value={csvData}></textarea><p>Rows: {parsedData.length}</p> 5. Batch Updates for Multiple Bindings
The Problem: When multiple bound values change simultaneously (e.g., resetting a form), each binding triggers reactivity independently. This can cause unnecessary re-renders.
<!-- AVOID: Multiple separate updates -->
<script>
let firstName = $state('')
let lastName = $state('')
let email = $state('')
function resetForm() {
firstName = '' // Triggers reactivity
lastName = '' // Triggers reactivity
email = '' // Triggers reactivity
// 3 separate updates!
}
</script> solution: Use batch update
<!-- PREFERRED: Batch update with object state -->
<script>
let formData = $state({
firstName: '',
lastName: '',
email: ''
})
function resetForm() {
// Single object update triggers reactivity once
formData = { firstName: '', lastName: '', email: '' }
}
</script>
<input bind:value={formData.firstName} />
<input bind:value={formData.lastName} />
<input bind:value={formData.email} /> Why this works: Svelte’s reactivity is smart enough to batch updates to the same object, reducing re-render overhead.
6. Avoiding Unnecessary Dimension Bindings
The Problem: Dimension bindings (clientWidth, offsetHeight, etc.) use ResizeObserver, which fires callbacks on every size change. Binding to dimensions on many elements can impact performance.
<!-- PREFERRED: Bind only to the container -->
<script>
let containerWidth = $state(0)
let containerHeight = $state(0)
</script>
<!-- AVOID: Binding dimensions on every item -->
{#each items as item}
<div bind:clientWidth={item.width} bind:clientHeight={item.height}>
{item.content}
</div>
{/each}
<div bind:clientWidth={containerWidth} bind:clientHeight={containerHeight}>
{#each items as item}
<div>
{item.content}
<!-- Calculate item size based on container -->
</div>
{/each}
</div> Key principle: Measure once at the container level, calculate child sizes with CSS or JavaScript.
Performance Summary
DO:
- Debounce expensive operations triggered by bindings
- Use event handlers for complex logic instead of bindings
- Batch updates by grouping related state in objects
- Measure performance with real data (don’t optimize prematurely)
- Consider virtual lists for 1,000+ items
DON’T:
- Bind to expensive
$derivedcalculations that run on every keystroke - Use bindings in large lists without measuring performance first
- Bind dimensions on every item in a list
- Optimize bindings before you have a performance problem
Remember: Svelte’s bindings are highly optimized. Most performance issues come from expensive computations or rendering large lists—not from bindings themselves. Profile your application before optimizing.
Accessibility Best Practices
Bindings don’t automatically make forms accessible. Always follow these guidelines:
1. Use Proper Labels
Every input must have an associated label:
<!-- AVOID: No label -->
<input bind:value={email} />
<!-- PREFERRED: Explicit label -->
<label for="email">Email</label>
<input id="email" type="email" bind:value={email} />
<!-- PREFERRED: Implicit label -->
<label>
Email
<input type="email" bind:value={email} />
</label> 2. Provide Error Feedback
Use ARIA attributes to announce errors to screen readers:
<script>
let email = $state('')
let touched = $state(false)
let isValid = $derived(/\S+@\S+\.\S+/.test(email))
let showError = $derived(touched && !isValid && email.length > 0)
</script>
<label for="email">Email</label>
<input
id="email"
type="email"
bind:value={email}
onblur={() => (touched = true)}
aria-invalid={showError}
aria-describedby={showError ? 'email-error' : undefined}
/>
{#if showError}
<p id="email-error" role="alert">Please enter a valid email address</p>
{/if} 3. Use Fieldsets for Groups
Group related inputs with <fieldset> and <legend>:
<fieldset>
<legend>Shipping Method</legend>
<label>
<input type="radio" bind:group={shipping} value="standard" />
Standard (5-7 days)
</label>
<label>
<input type="radio" bind:group={shipping} value="express" />
Express (2-3 days)
</label>
</fieldset> Best Practices Summary
- Use
bind:for simple two-way sync: Forms and inputs benefit from binding’s brevity - Prefer event handlers for complex logic: Validation, formatting, and side effects need explicit control
- Mark component props as
$bindable: Enable parent-child two-way binding when appropriate - Guard against
undefinedin number inputs: Empty number inputs becomeundefined—handle this explicitly - Use
bind:groupfor checkbox/radio groups: Manage arrays of selections cleanly - Debounce expensive operations: Don’t recalculate on every keystroke
- Always provide labels: Accessibility is non-negotiable
- Validate initial select values: Ensure bound values match option values
- Use
bind:thisfor DOM access: Capture references for imperative APIs - Combine bindings with validation: Show clear feedback to users
Quick Reference
<!-- Text inputs -->
<input type="text" bind:value={text} />
<textarea bind:value={text}></textarea>
<!-- Number inputs (auto-convert to number) -->
<input type="number" bind:value={number} />
<input type="range" bind:value={number} min="0" max="100" />
<!-- Single checkbox (boolean) -->
<input type="checkbox" bind:checked={boolean} />
<input type="checkbox" bind:indeterminate />
<!-- Checkbox group (array) -->
<input type="checkbox" bind:group={array} value="option1" />
<input type="checkbox" bind:group={array} value="option2" />
<!-- Radio buttons (string/number/object) -->
<input type="radio" bind:group={selected} value="a" />
<input type="radio" bind:group={selected} value="b" />
<!-- Select dropdown -->
<select bind:value={selected}>
<option value="a">A</option>
<option value="b" selected>B</option>
</select>
<!-- Select multiple (array) -->
<select bind:value={array} multiple>
<option value="a">A</option>
<option value="b">B</option>
</select>
<!-- File input -->
<input type="file" bind:files accept="image/*" multiple />
<!-- Details element -->
<details bind:open={isOpen}>
<summary>Click to toggle</summary>
<p>Content</p>
</details>
<!-- Contenteditable -->
<div contenteditable bind:innerHTML={html}></div>
<div contenteditable bind:innerText={text}></div>
<div contenteditable bind:textContent={text}></div>
<!-- Media (two-way) -->
<video bind:currentTime bind:playbackRate bind:paused bind:volume bind:muted></video>
<!-- Media (readonly) -->
<video
bind:duration
bind:buffered
bind:seeking
bind:ended
bind:readyState
bind:videoWidth
bind:videoHeight
></video>
<!-- Image (readonly) -->
<img bind:naturalWidth bind:naturalHeight />
<!-- Dimensions (readonly) -->
<div bind:clientWidth bind:clientHeight bind:offsetWidth bind:offsetHeight bind:contentRect></div>
<!-- Element reference -->
<input bind:this={inputElement} />
<!-- Component binding -->
<Child bind:value={parentValue} />
<!-- Function binding (Svelte 5.9+) -->
<input bind:value={() => value, (v) => (value = v.toUpperCase())} /> Conclusion
The bind: directive represents one of Svelte’s most powerful features for building interactive UIs: bidirectional data flow that automatically synchronizes DOM state with component state. By abstracting away the manual event listener wiring, value extraction, and state updates required in vanilla JavaScript, bind: transforms what would be dozens of lines of imperative code into a single declarative attribute. From simple form inputs to complex component compositions, two-way binding provides the foundation for reactive, interactive interfaces.
Mastering bind: requires understanding its various forms and appropriate use cases. Form element bindings (bind:value, bind:checked) handle user input. Dimension bindings (bind:clientWidth) enable responsive layouts. Component bindings with $bindable() create powerful parent-child communication patterns. The key is recognizing when two-way binding simplifies your code versus when unidirectional data flow with explicit event handlers provides better architecture. By combining bind: with $derived for computed transformations and $effect for side effects on bound value changes, you can build sophisticated, maintainable reactive systems.
Key Takeaways
bind:creates two-way data flow automatically syncing DOM values to component state and vice versa, eliminating manual event listener and state update boilerplate- Form element bindings handle user input with
bind:valuefor text/number inputs,bind:checkedfor checkboxes,bind:groupfor radio buttons, andbind:filesfor file inputs - Dimension bindings provide responsive layout data -
bind:clientWidth,bind:clientHeight,bind:offsetWidth,bind:offsetHeighttrack element dimensions reactively - Component bindings require
$bindable()props on the child component to opt-in to two-way binding:let { value = $bindable() } = $props() bind:thiscaptures element references for programmatic DOM access:let element; bind:this={element}stores the DOM node in a variable- Type coercion happens automatically with
type="number"converting string inputs to numbers,<input bind:value={numericState} type="number">populates state with numeric types - Group bindings enable radio/checkbox sets - multiple elements with
bind:group={selectedValue}synchronize to a single state variable, storing the checked value(s) - Function bindings transform values (Svelte 5.9+):
bind:value={(() => getter, (v) => setter)}applies custom logic during get/set operations
See Also
- Official Svelte 5 Documentation -
bind: $state- Reactive state that bindings update$bindable()- Making component props bindable$derived- Computed values from bound state$effect- Side effects triggered by bound value changes- Form Validation - Client-side form validation patterns
- MDN - Input Types - Understanding HTML input element types