In Svelte 4 any prop could be bound with bind:; Svelte 5 makes binding opt-in with $bindable(), so input components migrated from Svelte 4 suddenly throw “Cannot bind to property … as it is not declared with $bindable”, and components written fresh often forget it and silently become one-way.
This page builds a reusable text field component for Svelte 5 runes: a bindable value, error and hint props wired to ARIA, attribute forwarding with rest props, and the rules for binding objects and numbers. It complements the store patterns in Svelte store integration for forms and the migration notes in Svelte 5 runes migration for form stores.
Context and prerequisites
In Svelte 5, component props come from let { … } = $props(). A prop is one-way by default: the child can read it, and may reassign its local copy, but the parent does not see the change. Declaring value = $bindable() in the destructuring makes it two-way when the parent uses bind:value, and one-way when the parent passes value={x}.
$bindable(fallback) provides a fallback used when the parent passes nothing, which lets the component work uncontrolled. Rest props (...rest) collect everything else the parent passes — autocomplete, inputmode, name, event handlers — for spreading onto the native input.
The core pattern: a bindable text field
<!-- TextField.svelte -->
<script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements";
type Props = Omit<HTMLInputAttributes, "value"> & {
label: string;
value?: string;
error?: string;
hint?: string;
};
// value is two-way when the parent uses bind:value, and falls back to ""
// when the parent passes nothing (uncontrolled use).
let { label, value = $bindable(""), error, hint, id, ...rest }: Props = $props();
// $props.id() (Svelte 5.20+) is stable across SSR and hydration.
const uid = $props.id();
const inputId = $derived(id ?? `field-${uid}`);
const errorId = $derived(error ? `${inputId}-error` : undefined);
const hintId = $derived(hint ? `${inputId}-hint` : undefined);
const describedBy = $derived([errorId, hintId].filter(Boolean).join(" ") || undefined);
</script>
<div class="field">
<label for={inputId}>{label}</label>
{#if hint}<p id={hintId} class="hint">{hint}</p>{/if}
<input
id={inputId}
bind:value
{...rest}
aria-invalid={error ? "true" : undefined}
aria-describedby={describedBy}
/>
{#if error}<p id={errorId} class="error">{error}</p>{/if}
</div>
<!-- Parent -->
<script lang="ts">
import TextField from "./TextField.svelte";
let form = $state({ email: "", name: "" });
let touched = $state({ email: false, name: false });
const emailError = $derived(touched.email && !/^\S+@\S+\.\S+$/.test(form.email) ? "Enter an email like [email protected]." : undefined);
</script>
<TextField label="Email" type="email" autocomplete="email" bind:value={form.email}
error={emailError} onblur={() => (touched.email = true)} />
Step-by-step walkthrough
- Declare
value = $bindable(fallback). Only$bindableprops acceptbind:; the fallback keeps uncontrolled use working. - Type the props with
HTMLInputAttributes.Omit<HTMLInputAttributes, "value">plus your own props gives full typing for every native attribute callers might pass. - Spread rest props onto the input, before your ARIA attributes. Callers’
autocomplete,inputmode,name,maxlengthand event handlers reach the element; youraria-invalidandaria-describedbycome after so they are not overridden accidentally. - Generate stable ids.
$props.id()avoids hydration mismatches onfor,idandaria-describedbyin SvelteKit. - Receive errors as props. The parent (or a form store) decides when an error is visible — for example after blur, as in touched vs dirty vs visited.
- Bind to properties of
$stateobjects.bind:value={form.email}works because$stateobjects are deeply reactive proxies.
Why binding is now explicit
Svelte 4’s “every prop is bindable” made data flow hard to follow: any child could write back to any parent value, and a component’s public interface did not say which props it might change. Svelte 5 makes that part of the contract. A component that declares $bindable is announcing that it owns editing of that value; one that does not is purely presentational for it. For form inputs this is exactly the right boundary — the input edits its value, and nothing else — and it lets readers of a parent component see at a glance, from bind: versus plain attributes, which children can change which state.
Composing inputs into larger fields
The same rules scale up to composite fields. A date-of-birth field made of day, month and year inputs can expose one bindable value as an ISO string, parse it into three local $state values, and write the joined string back whenever any part changes and all three are complete. The parent binds one value and receives one error; the component handles the parts internally. Put the three inputs in a fieldset with a legend (“Date of birth”), label each part, and attach the error to the fieldset so screen-reader users hear it once when entering the group rather than three times.
Avoid exposing three separate bindable props for such a field. It pushes the parsing and the “is it complete yet?” logic into every parent, and cross-part validation — 31 February — ends up duplicated wherever the field is used.
Failure modes and edge cases
1. Binding a prop that is not $bindable
<TextField bind:value={x} /> against a component with let { value } = $props() throws at runtime in development. Add $bindable(); this is the most common Svelte 4 → 5 migration error for input components.
2. Mutating a non-bound object prop
If the parent passes address={form.address} without bind:, and the child writes address.city = "x", Svelte warns about mutating state it does not own (the ownership_invalid_mutation warning) because the parent never agreed to two-way data. Either bind it, or have the child emit changes through a callback prop.
3. Numbers
bind:value on type="number" inputs gives numbers, and null for empty or invalid input. For money and quantities where intermediate text matters, bind a string and parse in the form, per controlled number inputs and intermediate values.
4. Event handler props
Svelte 5 uses onblur, oninput props rather than on:blur directives; they arrive in rest and are spread onto the input automatically. If the component also needs its own onblur, call the caller’s handler from yours rather than letting one overwrite the other.
5. Exposing focus
An error summary needs to focus the inner input. Bind the element (bind:this={inputEl}) and export a function (export function focus() { inputEl.focus(); } in the instance script), which parents call through a component reference.
Verification checklist
Frequently Asked Questions
Should every prop on an input component be bindable?
No. Make only the value the component edits bindable. Labels, errors and hints are inputs to the component; allowing them to be bound invites children to write state they should only display.
Can a bindable prop have validation inside the component?
The component can format or constrain what it writes (for example stripping non-digits), but validation that depends on other fields or on timing belongs in the form. Keep the component predictable: what the user typed, optionally normalised, goes up; the error comes down.
How do I bind a checkbox group?
Use bind:group on native checkboxes inside the component, with a bindable array prop: let { selected = $bindable([]) } = $props() and <input type="checkbox" bind:group={selected} value={option}>. Wrap the group in a fieldset with a legend, and put group errors on the fieldset.