Building Accessible Svelte Forms with AgnosticUI: Validation, Inputs, and ARIA Done Right






Building Accessible Svelte Forms with AgnosticUI Validation







Building Accessible Svelte Forms with AgnosticUI: Validation, Inputs, and ARIA Done Right

Published: June 2025  |  Topic: Svelte · AgnosticUI · Accessibility · Form Validation

Why Accessible Forms Still Feel Like a Chore (And How to Fix That)

Let’s be honest: building forms is the part of frontend development nobody brags about at conferences.
And yet, forms are where users interact with your product most directly — signing up, checking out,
submitting support tickets. Get them wrong and you lose conversions. Get the accessibility wrong and
you actively exclude a significant portion of your users. According to the
W3C WAI,
over 70% of common WCAG failures involve form-related elements. That’s not a niche concern.

This is where AgnosticUI earns its keep.
It’s a framework-agnostic component library — meaning the same components work in React, Vue, Angular, and
Svelte — with accessibility baked in at the design level, not bolted on as an afterthought.
When you use AgnosticUI Svelte forms,
you’re not fighting the library to add ARIA labels; it’s already doing that for you.

This guide walks you through building a fully functional, WAI-ARIA compliant
form in Svelte using AgnosticUI components — covering text inputs, ChoiceInput (checkboxes and radio buttons),
real-time validation, and error handling that actually communicates to screen readers. No padding,
no boilerplate theory. Just the pattern that works.

Setting Up AgnosticUI in a Svelte Project

Before writing a single form field, you need AgnosticUI’s Svelte package installed and configured.
The setup is straightforward, but the order of operations matters — especially if you’re using SvelteKit,
which introduces some SSR nuances worth knowing about.

Install the package and its CSS dependency. AgnosticUI ships component styles separately from
framework logic, which is what makes it framework-agnostic in the first place. You import the
global CSS once at your app’s root, and each component handles the rest.

# Install AgnosticUI for Svelte
npm install agnostic-svelte

# AgnosticUI uses its own CSS file — import it globally
# In your app's root layout or main entry file:

In your src/app.html (SvelteKit) or your root +layout.svelte, import the
AgnosticUI CSS. Then in any component, import individual pieces — Input, ChoiceInput,
Button — only what you need. Tree-shaking keeps your bundle clean.

<!-- src/routes/+layout.svelte -->
<script>
  import 'agnostic-svelte/css/common.min.css';
</script>

<slot />

Once that’s done, you have access to the full AgnosticUI component suite in any Svelte component
without further configuration. No theming setup required for the defaults, though AgnosticUI supports
CSS custom properties for customization if your design system needs it.

AgnosticUI Input Components: More Than a Styled Text Field

The Input component in AgnosticUI is the workhorse of
AgnosticUI input components.
It handles labels, helper text, error states, and ARIA attribute wiring automatically — which means
you don’t need to manually connect aria-describedby to your error message or remember to
add aria-invalid when validation fails. The component does it internally when you pass
an error string.

The API is intentionally minimal. You pass a label, a reactive value,
an optional helpText, and an error string that appears only when non-empty.
That last prop is the key: when error has content, AgnosticUI applies the error styling
and sets aria-invalid="true" and connects the error message via aria-describedby
— all without you writing a line of ARIA manually.

<!-- EmailField.svelte -->
<script>
  import { Input } from 'agnostic-svelte';

  let email = '';
  let emailError = '';

  function validateEmail() {
    if (!email) {
      emailError = 'Email address is required.';
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      emailError = 'Please enter a valid email address.';
    } else {
      emailError = '';
    }
  }
</script>

<Input
  label="Email Address"
  type="email"
  bind:value={email}
  on:blur={validateEmail}
  error={emailError}
  helpText="We'll never share your email with anyone."
  isRounded
/>

Notice the on:blur trigger — validating on blur rather than on every keystroke is a
deliberate UX decision. Showing errors while the user is still typing creates anxiety without providing
actionable feedback. Validate on blur for initial interaction, then switch to real-time validation
once the error is already visible. This two-phase approach is both more humane and more aligned with
WCAG 3.3.1 (Error Identification) guidelines.

Svelte Form State Management: Keeping It Reactive and Clean

Svelte’s reactivity model is genuinely one of its strongest features for forms.
Unlike React, where you need useState for every field and a reducer for any
reasonable complexity, Svelte lets you declare reactive variables and derived state with minimal
ceremony. For Svelte form state management,
this means you can manage an entire form in a single <script> block cleanly.

For simple forms, plain reactive variables are sufficient. For multi-step forms or forms
shared across components, Svelte stores — particularly writable — give you the
cross-component reactivity without pulling in a form library. The key principle is keeping
your form state close to your validation logic: one object for values, one object for errors.

<script>
  import { Input, Button } from 'agnostic-svelte';

  // Form state — single source of truth
  let formValues = {
    name: '',
    email: '',
    message: ''
  };

  let formErrors = {
    name: '',
    email: '',
    message: ''
  };

  let isSubmitting = false;
  let submitSuccess = false;

  // Derived: is the form valid?
  $: isFormValid = Object.values(formErrors).every(e => e === '')
    && Object.values(formValues).every(v => v.trim() !== '');

  function validateField(field, value) {
    switch (field) {
      case 'name':
        return value.trim() ? '' : 'Full name is required.';
      case 'email':
        return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
          ? '' : 'Enter a valid email address.';
      case 'message':
        return value.trim().length >= 20
          ? '' : 'Message must be at least 20 characters.';
      default:
        return '';
    }
  }

  function handleBlur(field) {
    formErrors[field] = validateField(field, formValues[field]);
  }

  async function handleSubmit(e) {
    e.preventDefault();

    // Validate all fields on submit
    Object.keys(formValues).forEach(field => {
      formErrors[field] = validateField(field, formValues[field]);
    });

    if (!isFormValid) return;

    isSubmitting = true;
    // Simulate API call
    await new Promise(r => setTimeout(r, 1200));
    isSubmitting = false;
    submitSuccess = true;
  }
</script>

The $: isFormValid reactive declaration is doing real work here: it recalculates
automatically whenever formErrors or formValues change, giving you a
live “is this form ready?” signal that you can use to disable the submit button. No manual
re-computation, no event listeners. This is Svelte’s compile-time reactivity working exactly
as intended — and it pairs with AgnosticUI’s Button isDisabled prop
without any additional wiring.

AgnosticUI ChoiceInput: Checkboxes and Radio Buttons, Unified

One of the more elegant decisions in AgnosticUI’s API design is the
AgnosticUI ChoiceInput
component. Instead of maintaining separate checkbox and radio components with divergent APIs,
AgnosticUI unifies them under a single ChoiceInput with a type prop.
This isn’t just API tidiness — it means your validation logic, your ARIA handling, and your
styling are consistent across both input types.

You pass an array of option objects to ChoiceInput, each containing a name,
value, and label. For radio buttons, you bind to a single reactive string.
For checkboxes, you bind to an array. The component manages the checked state internally and exposes
a change event you can use to update your form state and trigger validation.

<script>
  import { ChoiceInput } from 'agnostic-svelte';

  // Radio button example — single selection
  let preferredContact = '';
  let contactError = '';

  const contactOptions = [
    { name: 'contact', value: 'email', label: 'Email' },
    { name: 'contact', value: 'phone', label: 'Phone' },
    { name: 'contact', value: 'sms', label: 'SMS' }
  ];

  function handleContactChange(e) {
    preferredContact = e.detail.value;
    contactError = preferredContact ? '' : 'Please select a contact preference.';
  }

  // Checkbox example — multiple selection
  let selectedInterests = [];
  let interestsError = '';

  const interestOptions = [
    { name: 'interests', value: 'ux', label: 'UX Design' },
    { name: 'interests', value: 'a11y', label: 'Accessibility' },
    { name: 'interests', value: 'svelte', label: 'Svelte' },
    { name: 'interests', value: 'agnosticui', label: 'AgnosticUI' }
  ];

  function handleInterestsChange(e) {
    selectedInterests = e.detail.values;
    interestsError = selectedInterests.length
      ? '' : 'Please select at least one interest.';
  }
</script>

<!-- Radio Buttons -->
<ChoiceInput
  type="radio"
  legendLabel="Preferred Contact Method"
  options={contactOptions}
  checkedOptions={[preferredContact]}
  on:change={handleContactChange}
/>
{#if contactError}
  <p class="error" role="alert" aria-live="assertive">{contactError}</p>
{/if}

<!-- Checkboxes -->
<ChoiceInput
  type="checkbox"
  legendLabel="Topics of Interest"
  options={interestOptions}
  checkedOptions={selectedInterests}
  on:change={handleInterestsChange}
/>
{#if interestsError}
  <p class="error" role="alert" aria-live="assertive">{interestsError}</p>
{/if}

One detail worth flagging: error display for ChoiceInput requires a bit more manual
ARIA work than Input, because the error is associated with a group of controls rather
than a single field. Using role="alert" with aria-live="assertive" on
your error paragraph ensures screen readers announce the error immediately when it appears.
For non-critical validation hints, use aria-live="polite" instead — it waits for
the user to finish their current action before announcing.

AgnosticUI Error Handling: Making Failures Communicative

Error handling in forms has two audiences: sighted users who see red borders and error text,
and users relying on assistive technology who need the same information conveyed through
semantic markup. AgnosticUI error handling
handles the first automatically; you handle the second by knowing when to use
aria-live regions and when to let the component’s built-in ARIA do the work.

The Input component’s error prop is connected to an
aria-describedby relationship internally. This means when a screen reader
focuses on the input, it reads the label, then the error message — in that order. You don’t
need to add anything extra. Where you do need to add aria-live is
for dynamically injected error summaries (e.g., a list of all errors at the top of the form
shown on submit), or for errors on ChoiceInput groups as shown above.

<!-- Form-level error summary on submit -->
{#if showErrorSummary}
  <div
    role="alert"
    aria-live="assertive"
    aria-atomic="true"
    class="error-summary"
  >
    <h3>Please fix the following errors:</h3>
    <ul>
      {#each Object.entries(formErrors) as [field, error]}
        {#if error}
          <li>
            <a href="#{field}-input">{error}</a>
          </li>
        {/if}
      {/each}
    </ul>
  </div>
{/if}

The error summary pattern — a list of all form errors at the top of the form, with anchor links
to the offending fields — is a WCAG best practice and an underused usability improvement for
everyone. Sighted users on mobile especially benefit from not having to hunt for which field
is wrong. When this summary appears after submit, the aria-live="assertive"
combined with aria-atomic="true" ensures the entire block is read as a unit,
not piecemeal.

WAI-ARIA Compliant Forms in Svelte: The Patterns That Actually Matter

ARIA compliance for forms isn’t about sprinkling attributes everywhere until a validator
stops complaining. It’s about three core relationships: label-to-input,
error-to-input, and group-to-controls. Get those right
and your form works for screen reader users. Get them wrong — or over-engineer them with
redundant ARIA — and you actively make things worse.

AgnosticUI handles label-to-input and error-to-input automatically through the Input
component’s internal markup. Group-to-controls is handled via fieldset and
legend inside ChoiceInput, which is the correct semantic pattern
for grouped radio buttons and checkboxes. The legendLabel prop maps to the
<legend> element — so users of assistive technology hear “Preferred Contact Method:
Email, Phone, SMS” as a complete announcement when they enter the group.

The patterns that remain your responsibility are: ensuring required fields are
marked with aria-required="true" (pass the isRequired prop in AgnosticUI),
that your submit button has a descriptive accessible name, and that focus is managed correctly
after dynamic content changes — such as moving focus to the error summary after a failed submit.
Svelte’s tick() function, combined with a direct DOM reference, handles focus
management cleanly after reactive updates.

<script>
  import { tick } from 'svelte';

  let errorSummaryEl;
  let showErrorSummary = false;

  async function handleSubmit(e) {
    e.preventDefault();

    // Run all validations
    validateAll();

    const hasErrors = Object.values(formErrors).some(e => e !== '');

    if (hasErrors) {
      showErrorSummary = true;
      // Wait for DOM update, then move focus to error summary
      await tick();
      errorSummaryEl?.focus();
      return;
    }

    // Proceed with submission
    await submitForm();
  }
</script>

<div
  bind:this={errorSummaryEl}
  tabindex="-1"
  role="alert"
  aria-live="assertive"
>
  <!-- Error summary content -->
</div>

Putting It All Together: A Complete Accessible Form

Below is a complete, production-ready example combining everything covered so far —
AgnosticUI input components,
ChoiceInput, reactive validation, error handling, ARIA error summaries, and focus management.
This is the kind of form you’d actually ship: no placeholder validation, no console.log debugging
artifacts, no “TODO: add accessibility later.”

<!-- ContactForm.svelte -->
<script>
  import { tick } from 'svelte';
  import { Input, Button } from 'agnostic-svelte';
  import { ChoiceInput } from 'agnostic-svelte';
  import 'agnostic-svelte/css/common.min.css';

  let formValues = { name: '', email: '', message: '' };
  let formErrors = { name: '', email: '', message: '' };
  let preferredContact = '';
  let contactError = '';
  let showErrorSummary = false;
  let isSubmitting = false;
  let submitSuccess = false;
  let errorSummaryEl;

  const contactOptions = [
    { name: 'contact', value: 'email', label: 'Email' },
    { name: 'contact', value: 'phone', label: 'Phone' }
  ];

  $: isFormValid =
    !Object.values(formErrors).some(e => e) &&
    !Object.values(formValues).some(v => !v.trim()) &&
    !!preferredContact;

  function validators(field, value) {
    const rules = {
      name: v => v.trim() ? '' : 'Full name is required.',
      email: v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)
        ? '' : 'Enter a valid email address.',
      message: v => v.trim().length >= 20
        ? '' : 'Message must be at least 20 characters.'
    };
    return rules[field]?.(value) ?? '';
  }

  function handleBlur(field) {
    formErrors[field] = validators(field, formValues[field]);
  }

  function handleContactChange(e) {
    preferredContact = e.detail.value;
    contactError = '';
  }

  async function handleSubmit(e) {
    e.preventDefault();

    // Validate all
    Object.keys(formValues).forEach(f => {
      formErrors[f] = validators(f, formValues[f]);
    });
    if (!preferredContact) contactError = 'Please select a contact method.';

    const hasErrors = Object.values(formErrors).some(Boolean) || contactError;

    if (hasErrors) {
      showErrorSummary = true;
      await tick();
      errorSummaryEl?.focus();
      return;
    }

    isSubmitting = true;
    await new Promise(r => setTimeout(r, 1000));
    isSubmitting = false;
    submitSuccess = true;
  }
</script>

{#if submitSuccess}
  <div role="status" aria-live="polite">
    <p>✅ Your message was sent successfully!</p>
  </div>
{:else}
  <form on:submit={handleSubmit} novalidate>

    <!-- Error Summary -->
    {#if showErrorSummary}
      <div
        bind:this={errorSummaryEl}
        tabindex="-1"
        role="alert"
        aria-live="assertive"
        aria-atomic="true"
        class="error-summary"
      >
        <strong>Please fix the following:</strong>
        <ul>
          {#each Object.entries(formErrors) as [field, err]}
            {#if err}<li><a href="#{field}">{err}</a></li>{/if}
          {/each}
          {#if contactError}<li>{contactError}</li>{/if}
        </ul>
      </div>
    {/if}

    <!-- Name Field -->
    <Input
      id="name"
      label="Full Name"
      bind:value={formValues.name}
      on:blur={() => handleBlur('name')}
      error={formErrors.name}
      isRequired
    />

    <!-- Email Field -->
    <Input
      id="email"
      label="Email Address"
      type="email"
      bind:value={formValues.email}
      on:blur={() => handleBlur('email')}
      error={formErrors.email}
      helpText="Used only for our response."
      isRequired
    />

    <!-- Message Field -->
    <Input
      id="message"
      label="Your Message"
      bind:value={formValues.message}
      on:blur={() => handleBlur('message')}
      error={formErrors.message}
      isRequired
    />

    <!-- Contact Preference -->
    <ChoiceInput
      type="radio"
      legendLabel="Preferred Contact Method"
      options={contactOptions}
      checkedOptions={[preferredContact]}
      on:change={handleContactChange}
    />
    {#if contactError}
      <p role="alert" aria-live="assertive" class="error">{contactError}</p>
    {/if}

    <!-- Submit -->
    <Button
      type="submit"
      mode="primary"
      isDisabled={isSubmitting}
    >
      {isSubmitting ? 'Sending…' : 'Send Message'}
    </Button>

  </form>
{/if}

A few deliberate choices worth noting in this implementation. The form uses novalidate
to disable native browser validation — not because native validation is bad, but because AgnosticUI
provides richer, more customizable error presentation that integrates properly with your ARIA setup.
Native browser validation and custom validation coexist awkwardly; pick one lane and commit.

The submitSuccess state replaces the form with a success message using
role="status" and aria-live="polite". This is the correct pattern:
success is non-urgent information, so polite waits for silence before announcing.
Errors, by contrast, use assertive because they require immediate user attention.
Matching aria-live urgency to actual message urgency is one of those details
that separates forms that pass an accessibility audit from forms that actually work for
people using screen readers day-to-day.

AgnosticUI Form Best Practices: The Short List That Covers Most Bases

After working through the implementation details, it’s worth stepping back and naming the
principles that unify all of the above. These aren’t abstract guidelines — they’re the
specific decisions that affect whether your form is usable, accessible, and maintainable.

  • Validate on blur first, then live. Show errors after the user leaves a field, then keep them updated in real-time once visible. This reduces noise without sacrificing feedback.
  • Always provide an error summary on submit. Individual field errors are easy to miss, especially on long forms or mobile. A summary at the top of the form, with focus management, guarantees users know what to fix.
  • Use novalidate with custom validation. Mixing native HTML5 validation with custom AgnosticUI error states creates double announcements for screen readers. Disable native validation and own the entire error communication yourself.
  • Match aria-live urgency to message urgency. Errors: assertive. Success confirmations, hints: polite. Status updates mid-submission: polite.
  • Mark required fields with both isRequired and visual indication. Never rely solely on an asterisk — always include “required” in the label or as accessible helper text.

The deeper principle behind all of these is that accessible forms are usable forms.
Every ARIA improvement you make — better error associations, logical focus management,
sensible live region usage — also benefits sighted keyboard users, users on mobile,
and users on slow connections. Accessibility and UX quality are not competing concerns.
They are, in practice, the same concern viewed from different angles.

AgnosticUI accelerates reaching this standard without requiring you to become a deep ARIA
expert for every project. The components encode the correct patterns; your job is to use
them correctly at the form level. The division of responsibility is clean, and the result
is a form that holds up under real-world usage conditions — including users who navigate
entirely by keyboard, users on screen readers, and users on unreliable mobile connections
who need clear, immediate feedback when something goes wrong.


Frequently Asked Questions

Is AgnosticUI fully WAI-ARIA compliant for form components?

Yes. AgnosticUI is designed with accessibility as a first-class concern, not a post-release
retrofit. Its form components — Input, ChoiceInput, Button
ship with correct ARIA attributes, fieldset/legend grouping for
radio and checkbox groups, and automatic aria-invalid and aria-describedby
wiring when errors are present. You still need to handle form-level concerns like error summaries
and focus management, but the component-level ARIA is handled for you.

How do I handle form validation errors in Svelte with AgnosticUI?

Bind reactive variables to each field in your form state object. Run validation functions
on blur events for initial interaction, then keep errors updated reactively
as the user corrects their input. Pass the error string to the AgnosticUI Input
component’s error prop — when non-empty, the component renders the error message,
applies error styling, and sets aria-invalid="true" automatically. On form submit,
validate all fields at once, and display an error summary with focus management for fields
that failed.

Can I use AgnosticUI ChoiceInput for both checkboxes and radio buttons?

Yes — that’s precisely what it’s designed for. Pass type="radio" for single
selection and type="checkbox" for multiple selection. Both variants use the
same options array format and the same on:change event API. The difference is
in what you bind: a single string value for radio buttons, an array of selected values for
checkboxes. The component handles grouping, ARIA roles, and keyboard navigation internally
for both types.


Nata e cresciuta a Rosignano Solvay , appassionata da sempre per tutto quello che ruota intorno al benessere della persona.Biologa, diplomata all'I.T.I.S Mattei