Forms

Reusable Svelte form field suite.

Live demo

01

A three-step wizard built from the field primitives. The Stepper at the top mirrors the active step; click a completed step to jump back. Each β€œNext” runs validation before letting you advance.

  1. 2 Preferences
  2. 3 Review
  3. 4 Done

Step 1 Β· Basics

TextField, NumberField, and RangeField β€” the core scalar inputs.

First and last name.

Whole years, please.

Slide to set.

0 30
3

Implementation

02
FormField.svelte
<script lang="ts">
  import TextField from '$lib/components/forms/TextField.svelte';
  import SelectField from '$lib/components/forms/SelectField.svelte';
  import CheckboxField from '$lib/components/forms/CheckboxField.svelte';
​
  let email = $state('');
  let role = $state('engineer');
  let optIn = $state(false);
​
  const roles = [
    { value: 'engineer',  label: 'Engineer' },
    { value: 'designer',  label: 'Designer' },
    { value: 'pm',        label: 'Product manager' }
  ];
</script>
​
<form>
  <TextField label="Email" bind:value={email} type="email" required />
  <SelectField label="Role" bind:value={role} options={roles} />
  <CheckboxField label="Email me product updates" bind:checked={optIn} />
</form>

The Forms suite is a set of small field components that share a FormField wrapper for label, error, and help-text rendering. Each field owns its own input element so native validation, keyboard handling, and screen-reader semantics come for free; you compose the ones you need rather than configuring one mega component. Here we hook three steps to a Stepper so a single currentStep index drives both navigation and progress.

Logic explainer

03

What Does It Do? (Plain English)

A suite of 13 typed form-field components built around one shared wrapper called FormField. Each field β€” text, textarea, number, select, radio group, checkbox, checkbox group, range, date, time, switch, colour, plus the bare FormField itself for custom inputs β€” exposes a consistent prop surface (name, label, value, error, touched, helpText, required, disabled, readonly) so swapping one input for another is a one-line change. Every field uses the same "show errors only after touched" UX pattern, the same ARIA wiring for label / help / error association, and the same focus / disabled / responsive styling.

Think of it as a typewriter with interchangeable typeballs β€” the carriage, ribbon, and paper feed (the FormField wrapper) stay constant, and you swap the typeball (the input element) for the type of character you need to enter. You learn the carriage once and every typeball Just Works.

How It Works (Pseudo-Code)

SHARED WRAPPER (FormField):
  state:
    fieldId  = `field-${name}`
    helpId   = `${name}-help`
    errorId  = `${name}-error`

  derive visibleError:
    touched && error    // only show error after user interaction

  render:
    <div class:has-error={visibleError}>
      <label for={fieldId}>
        {label}
        {#if required} <span aria-label="required">*</span> {/if}
      </label>
      {#if helpText} <p id={helpId}>{helpText}</p> {/if}
      <div>
        {@render children()}    ← the actual input element goes here
      </div>
      {#if visibleError}
        <span id={errorId} role="alert">{error}</span>
      {/if}
    </div>

INDIVIDUAL FIELD (e.g. TextField):
  takes the same base props plus type-specific ones (type, maxlength, pattern…)
  passes name/label/required/error/touched/helpText through to FormField
  renders the actual <input> with bind:value, aria-required, aria-invalid,
    aria-describedby={helpId}, aria-errormessage={errorId}

CONSUMER:
  let value = $state('')
  let touched = $state(false)
  let error = $derived(/* ... */)

  <TextField
    name="email"
    label="Email"
    type="email"
    bind:value
    {error}
    {touched}
    onblur={() => touched = true}
  />

The reactive contract is uniform: pass value (or checked for boolean fields, or values[] for CheckboxGroup) and bind: it for two-way sync. Pass error and touched separately so the parent owns the validation logic β€” the field never invents an error.

The Core Concept: One Wrapper, 13 Inputs

The naΓ―ve approach is to give each input type its own labelling, ID-generation, and error-display code. That produces 13 nearly-identical implementations with subtly different bugs β€” one forgets aria-describedby, another forgets role="alert", a third generates IDs differently and crashes server-side rendering with hydration mismatches.

The wrapper pattern centralises that into one place:

TextField    β†’  <FormField {props}> <input type="text" /> </FormField>
NumberField  β†’  <FormField {props}> <input type="number" /> </FormField>
DateField    β†’  <FormField {props}> <input type="date" /> </FormField>
SelectField  β†’  <FormField {props}> <select>{options}</select> </FormField>
RadioGroup   β†’  <FormField {props}> {options.map(o => <input type="radio">)} </FormField>
... etc.

Every wrapper produces the exact same DOM scaffold β€” same label structure, same error position, same ID scheme, same focus ring on :focus-visible, same red border + pink background when has-error. The only thing that varies is what's inside the field-input slot.

This is composition over inheritance for components: rather than have 13 classes inheriting from a base class with override hooks, you have 13 components that wrap one shared component and pass the right innards through a snippet.

The "Touched" Pattern

A central UX rule across the suite: errors only appear after the user has interacted with the field. The wrapper computes:

let visibleError = $derived(touched && error);

So even if error is non-empty (because validation says "name is required" the moment the form mounts), nothing shows until touched is true. The parent flips touched in two situations:

  1. On blur: the user has tried, failed, and moved on β€” the right time to show the error.
  2. On submit: bulk-flip every field's touched flag, so all errors appear at once when the user attempts to submit an invalid form.

This avoids the "yelling at me before I've typed" anti-pattern that plagues server-side-validation-style forms. The pattern is documented in the code with a literal comment: // This is the "don't yell at me before I've even tried" logic!

Field Shape Pattern

Every field component follows the same prop shape (with type-specific extensions):

interface BaseFieldProps {
  name: string;            // required β€” drives ID generation
  label: string;            // required β€” visible label + ARIA name
  value?: T;                // bindable β€” the field's value
  placeholder?: string;
  helpText?: string;        // explanatory copy under the label
  required?: boolean;       // shows * indicator + aria-required
  disabled?: boolean;       // greys out, blocks interaction
  readonly?: boolean;       // looks like disabled but semantic difference
  error?: string;           // current validation message
  touched?: boolean;        // gate for error visibility
  onblur?: () => void;
  oninput?: (value: T) => void;
}

The T for value varies by field type:

  • TextField, TextareaField, DateField, TimeField, ColorField, SelectField β€” string
  • NumberField, RangeField β€” number
  • CheckboxField, SwitchField β€” boolean (uses checked instead of value)
  • RadioGroup β€” string (the value of the chosen radio)
  • CheckboxGroup β€” string[] (uses values instead of value)

Type-specific fields add their own props: TextField adds type | maxlength | pattern | autocomplete, NumberField adds min | max | step, SelectField adds options[], RangeField adds showValue | showMinMax, CheckboxGroup adds minSelected | maxSelected, etc. The base contract stays uniform; the extensions are additive.

Composition: Multi-Step Forms, Conditional Fields, Dynamic Generation

Because every field exposes the same base props, three common patterns are trivially expressible:

Multi-step forms. Render different field sets per step; track validity per step:

{#if step === 1}
  <TextField name="firstName" bind:value={form.firstName} />
  <TextField name="email" type="email" bind:value={form.email} />
{:else if step === 2}
  <CheckboxGroup name="interests" bind:values={form.interests} options={…} />
{/if}

Conditional fields. Show/hide based on prior answers:

<CheckboxField name="hasCompany" label="I have a company" bind:checked={hasCompany} />
{#if hasCompany}
  <TextField name="companyName" required={hasCompany} bind:value={companyName} />
{/if}

Dynamic generation. Drive the form from a config array β€” useful for survey tools or admin builders:

{#each fields as field}
  {#if field.type === 'text' || field.type === 'email'}
    <TextField name={field.name} type={field.type} bind:value={data[field.name]} />
  {:else if field.type === 'number'}
    <NumberField name={field.name} bind:value={data[field.name]} />
  {/if}
{/each}

The shared base prop shape makes the dispatch table small.

State Flow Diagram

   PARENT FORM:
     formData      = $state({ name: '', email: '', age: null })
     touched       = $state({ name: false, email: false, age: false })
     errors        = $derived({ name: ..., email: ..., age: ... })
     isValid       = $derived(every error empty)

     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚  user types into a field            β”‚
     β”‚  β†’ bind:value updates formData[k]   β”‚
     β”‚  β†’ errors derives, possibly changes β”‚
     β”‚  β†’ field re-renders, but touched    β”‚
     β”‚     is still false β€” no visible     β”‚
     β”‚     error yet                       β”‚
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
                  β”‚ user blurs the field
                  β–Ό
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚  onblur β†’ touched[k] = true         β”‚
     β”‚  β†’ FormField recomputes visibleErrorβ”‚
     β”‚  β†’ if error: render the alert regionβ”‚
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
                  β”‚ user submits
                  β–Ό
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚  bulk-flip every touched flag β†’ trueβ”‚
     β”‚  β†’ all errors now visible           β”‚
     β”‚  β†’ if !isValid: bail (focus the     β”‚
     β”‚     first invalid field)            β”‚
     β”‚  β†’ else: submit the data            β”‚
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

   PER FIELD (FormField wrapper):
     [render label] β†’ [render help if helpText]
                     β†’ [render the input via children snippet]
                     β†’ if (touched && error): [render alert]

Props Reference

The base contract β€” present on every field component:

Prop Type Default Description
name string required Field identifier; drives ID generation for ARIA wiring.
label string required Visible label + ARIA name.
value string | number | boolean | string[] varies Bindable value. CheckboxField/SwitchField use checked; CheckboxGroup uses values.
placeholder string β€” Hint text in empty inputs (where supported).
helpText string β€” Explanatory copy under the label, wired via aria-describedby.
required boolean false Shows * indicator and sets aria-required.
disabled boolean false Sets the real disabled attribute.
readonly boolean false Sets readonly (not disabled) β€” value is final, not unavailable.
error string '' Current validation message. Hidden until touched.
touched boolean false Gate for error visibility.
onblur () => void β€” Standard blur handler, typically used to flip touched.
oninput (value: T) => void β€” Fires on every value change.

Field-specific extensions:

  • TextField β€” type: 'text'|'email'|'url'|'tel'|'password'|'search', maxlength, pattern, autocomplete.
  • TextareaField β€” rows, maxlength, showCharCount.
  • NumberField β€” min, max, step.
  • SelectField β€” options: { value, label, disabled? }[].
  • RadioGroup β€” options[], orientation: 'horizontal'|'vertical'.
  • CheckboxField β€” checked instead of value.
  • CheckboxGroup β€” values[] instead of value, options[], orientation, minSelected, maxSelected.
  • RangeField β€” min (required), max (required), step, showValue, showMinMax.
  • DateField / TimeField β€” min, max as ISO strings (YYYY-MM-DD or HH:MM).
  • SwitchField β€” checked, size: 'sm'|'md'|'lg'.
  • ColorField β€” value is a hex string.

Edge Cases

Situation Behaviour
touched=false but error is non-empty Error stays hidden. The form looks valid even though it isn't β€” by design, until the user interacts.
touched=true but error is empty No alert renders. has-error class is not applied. The label stays the default colour.
disabled=true and readonly=true together Both disabled and readonly attributes apply. Browsers prefer disabled β€” the field is greyed out and not submitted with the form.
required + empty value at submit The required attribute triggers the browser's native invalid-form state on submit. Pair with the touched-pattern for immediate inline feedback.
aria-describedby references both helpId and errorId When helpText and a visible error are both present, both IDs are referenced; AT reads help text and the error in sequence.
Two forms on one page sharing field names (name='email') Both fields would generate id="field-email" β€” duplicate IDs, broken label-for. Scope names per form (signup-email vs login-email).
User submits without ever touching a field Parent should bulk-flip touched before validating; otherwise the form's invalid state is hidden. The pattern is documented in the suite.
Rendering a custom input via <FormField> directly Pass children as a Snippet; the wrapper's structure stays the same. Useful for date-range pickers, tag inputs, etc.
error text contains HTML Rendered as text, not HTML. No injection risk; the text shows literally.

Dependencies

  • Svelte 5.x β€” $state, $derived, $bindable, $props, snippets. The whole suite is runes-only.
  • Zero external dependencies. Native form elements, scoped CSS, inline SVG for custom icons (checkmarks, dropdown arrows).

File Structure

src/lib/components/forms/FormField.svelte         # base wrapper (label + help + error scaffold)
src/lib/components/forms/TextField.svelte         # text / email / url / tel / password / search
src/lib/components/forms/TextareaField.svelte     # multi-line text + char counter
src/lib/components/forms/NumberField.svelte       # number + min/max/step
src/lib/components/forms/SelectField.svelte       # select + custom arrow
src/lib/components/forms/RadioGroup.svelte        # radio button group
src/lib/components/forms/CheckboxField.svelte     # single checkbox
src/lib/components/forms/CheckboxGroup.svelte     # multi-select checkbox group
src/lib/components/forms/RangeField.svelte        # range slider with optional value display
src/lib/components/forms/DateField.svelte         # native date picker
src/lib/components/forms/TimeField.svelte         # native time picker
src/lib/components/forms/SwitchField.svelte       # toggle switch wrapper
src/lib/components/forms/ColorField.svelte        # colour picker with preview swatch
src/lib/components/forms/Forms.md                 # this file
src/lib/components/forms/Forms.test.ts            # vitest unit tests for all 13 fields
src/routes/forms/+page.svelte                     # demo page

API

04
PropTypeDefaultDescription
namestringβ€”Required. Used for the input id, error id, and form submission key.
labelstringβ€”Visible label rendered above the input.
value / checkedstring | number | boolean''Bindable two-way value. Use checked for boolean fields.
requiredbooleanfalseAdds the asterisk and the native required attribute.
errorstring''Error message shown when the field is touched.
touchedbooleanfalseHas the user interacted with the field β€” gates error display.
helpTextstring''Subtle helper text below the input.
oninput(value) => voidβ€”Called on every change with the latest value.
onblur() => voidβ€”Called when focus leaves the input.
options (Select / Radio){ value, label }[]β€”List of choices for selection-type fields.
min / max / stepnumberβ€”Number and Range fields use the native HTML constraints.