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.

Props in Svelte 4 and Svelte 5 In Svelte 4 props were declared with export let, any prop could be bound, fallbacks were default values on export let, and attributes were forwarded with $$restProps. In Svelte 5 props are declared with $props, only props declared with $bindable can be bound, fallbacks are passed to $bindable or given as destructuring defaults, and attributes are forwarded with rest props from the $props destructuring. Concern Svelte 4 Svelte 5 declare export let value let { value } = $props() two-way binding any prop only value = $bindable() fallback export let value = "" value = $bindable("") forward attributes $$restProps ...rest from $props()

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

  1. Declare value = $bindable(fallback). Only $bindable props accept bind:; the fallback keeps uncontrolled use working.
  2. Type the props with HTMLInputAttributes. Omit<HTMLInputAttributes, "value"> plus your own props gives full typing for every native attribute callers might pass.
  3. Spread rest props onto the input, before your ARIA attributes. Callers’ autocomplete, inputmode, name, maxlength and event handlers reach the element; your aria-invalid and aria-describedby come after so they are not overridden accidentally.
  4. Generate stable ids. $props.id() avoids hydration mismatches on for, id and aria-describedby in SvelteKit.
  5. 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.
  6. Bind to properties of $state objects. bind:value={form.email} works because $state objects 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.

bind:value through a component boundary The user types into the native input. The input's bind:value updates the TextField's value prop. Because value is declared with $bindable and the parent used bind:value, the parent's form.email state updates. The parent's derived emailError recomputes and flows back down to TextField as the error prop, which updates aria-invalid and the error paragraph. User native input TextField Parent state types bind:value → value $bindable → form.email error prop (derived)

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.

One-way, two-way, and callback props A one-way prop suits values the component only displays, such as label, hint and error. A bindable prop suits the value the component edits, such as an input's text. A callback prop such as onchange suits cases where the parent must decide whether to accept a change, such as formatting or rejecting input. One-way prop label, hint, error. The component only displays it. $bindable prop value. The component edits it. Callback prop onchange(v). The parent decides whether to accept it.

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.


Related

← Svelte Store Integration for Forms