Beta, expect API change, and bugs. Hit one? Tell us.

Skip to content

Forms

Validation, Field, and submission.

Form, Field, and Fieldset build on the browser's constraint validation API, so required, pattern, and other native rules work without extra wiring. <Form> renders a real <form> with novalidate: it validates itself and shows messages through <Field.Error> instead of the browser bubble, so the copy is yours to style and translate. Field state — value, invalid, dirty, touched — is controllable, so a field can sit under a schema validator or an external form library.

<script setup lang="ts">
import { Button } from '@shardsui/vue/button'
import { Field } from '@shardsui/vue/field'
import { Form, type FormErrors } from '@shardsui/vue/form'
import { shallowRef } from 'vue'

const errors = shallowRef<FormErrors>({})
const loading = shallowRef(false)

async function joinWaitlist(email: string): Promise<FormErrors> {
  await new Promise((resolve) => setTimeout(resolve, 1000))
  if (email.endsWith('@example.com')) {
    return { email: 'This email is already on the waitlist' }
  }
  return {}
}

async function onSubmit(event: SubmitEvent) {
  event.preventDefault()
  const email = new FormData(event.currentTarget as HTMLFormElement).get('email')
  loading.value = true
  errors.value = await joinWaitlist(String(email))
  loading.value = false
}
</script>

<template>
  <Form class="flex w-full max-w-64 flex-col gap-4" :errors="errors" @submit="onSubmit">
    <Field.Root name="email" class="flex flex-col items-start gap-1">
      <Field.Label class="text-sm font-semibold text-gray-900">Email</Field.Label>
      <Field.Control
        type="email"
        required
        placeholder="you@company.com"
        class="h-8 w-full rounded-md border border-gray-200 px-2 text-sm font-normal text-gray-900 focus:outline-2 focus:-outline-offset-1 focus:outline-gray-950 any-pointer-coarse:text-base"
      />
      <Field.Error class="text-sm text-red-800" />
    </Field.Root>
    <Button
      type="submit"
      :disabled="loading"
      class="font-inherit m-0 flex h-8 items-center justify-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 text-sm/6 font-normal text-nowrap text-gray-900 outline-0 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 data-disabled:text-gray-500 hover:data-disabled:bg-gray-50"
    >
      Join waitlist
    </Button>
  </Form>
</template>

The shape of a field

A field is a control plus everything that describes it. <Field.Root> groups them and threads the accessible wiring — for/id, aria-describedby, aria-invalid — between the parts so you never hand-match an id.

<script setup>
import { Form } from '@shardsui/vue/form'
import { Field } from '@shardsui/vue/field'
</script>

<template>
  <Form>
    <Field.Root>
      <Field.Label />
      <Field.Control />
      <Field.Description />
      <Field.Error />
    </Field.Root>
  </Form>
</template>

The name on <Field.Root> identifies the value on submit. It takes precedence over a name on the control itself, so put it on the root and let it cascade. That same name is the key the errors prop and schema validators match against later.

As the field moves through its lifecycle it reflects state onto every part as data-* attributes: data-invalid, data-valid, data-touched, data-dirty, data-filled, data-focused, data-disabled. Style against those instead of tracking validity in your own refs:

<template>
  <Field.Control class="border-gray-200 data-focused:outline-2 data-invalid:border-red-600" />
</template>

Giving every control a name

A control with no accessible name is invisible to a screen reader. How you supply it depends on the control.

Plain inputsInput, Autocomplete, the input half of Combobox, and the checkable controls Checkbox / Radio / Switch — take <Field.Label>. The control can sit inside its label:

<script setup>
import { Field } from '@shardsui/vue/field'
import { Switch } from '@shardsui/vue/switch'
</script>

<template>
  <Field.Root>
    <Field.Label>
      <Switch.Root />
      Developer mode
    </Field.Label>
    <Field.Description>Enables extra tools for web developers.</Field.Description>
  </Field.Root>
</template>

Trigger-based controls carry their own label part, because the thing you click isn't a native <input>:

  • Select<Select.Label>
  • Combobox with the input inside the popup → <Combobox.Label>
  • Slider<Slider.Label>; on a multi-thumb slider, add an aria-label to each <Slider.Thumb> so the thumbs are told apart.

for only points at native form controls, so when the field's control is a <button>, set as="span" on <Field.Label>: it drops the for and forwards a click to the registered control instead.

No visible label at all? Put aria-label directly on the control.

<Field.Description> is registered as the control's accessible description automatically:

<script setup>
import { Form } from '@shardsui/vue/form'
import { Field } from '@shardsui/vue/field'
import { Select } from '@shardsui/vue/select'
import { Slider } from '@shardsui/vue/slider'
</script>

<template>
  <Form>
    <Field.Root>
      <Select.Root>
        <Select.Label>Time zone</Select.Label>
        <Select.Trigger />
      </Select.Root>
      <Field.Description>Used for notifications and reminders.</Field.Description>
    </Field.Root>

    <Field.Root>
      <Slider.Root :value="50">
        <Slider.Label>Zoom level</Slider.Label>
        <Field.Description>Adjust the size of the interface.</Field.Description>
        <Slider.Control>
          <Slider.Track>
            <Slider.Thumb />
          </Slider.Track>
        </Slider.Control>
      </Slider.Root>
    </Field.Root>
  </Form>
</template>

Grouping controls under one legend

When a single label covers several controls — a price range with two thumbs, a set of radio options — reach for Fieldset. <Fieldset.Root> renders a <fieldset>, and <Fieldset.Legend> names it with no aria-labelledby to wire up. A RadioGroup nested inside adopts that legend as its own accessible name; other composites keep their own label part, so a multi-thumb slider still needs an aria-label per <Slider.Thumb>:

<script setup>
import { Form } from '@shardsui/vue/form'
import { Field } from '@shardsui/vue/field'
import { Fieldset } from '@shardsui/vue/fieldset'
import { Radio } from '@shardsui/vue/radio'
import { RadioGroup } from '@shardsui/vue/radio-group'
import { Slider } from '@shardsui/vue/slider'
</script>

<template>
  <Form>
    <Field.Root>
      <Fieldset.Root>
        <Fieldset.Legend>Price range</Fieldset.Legend>
        <Slider.Root :value="[25, 75]">
          <Slider.Control>
            <Slider.Track>
              <Slider.Indicator />
              <Slider.Thumb :index="0" aria-label="Minimum price" />
              <Slider.Thumb :index="1" aria-label="Maximum price" />
            </Slider.Track>
          </Slider.Control>
        </Slider.Root>
      </Fieldset.Root>
    </Field.Root>

    <Field.Root>
      <Fieldset.Root>
        <Fieldset.Legend>Storage type</Fieldset.Legend>
        <RadioGroup>
          <Radio.Root value="ssd" />
          <Radio.Root value="hdd" />
        </RadioGroup>
      </Fieldset.Root>
    </Field.Root>
  </Form>
</template>

When each option in a checkbox or radio group needs its own label and description, wrap it in <Field.Item>:

<script setup>
import { Field } from '@shardsui/vue/field'
import { Fieldset } from '@shardsui/vue/fieldset'
import { Checkbox } from '@shardsui/vue/checkbox'
import { CheckboxGroup } from '@shardsui/vue/checkbox-group'
</script>

<template>
  <Field.Root>
    <Fieldset.Root>
      <Fieldset.Legend>Backup schedule</Fieldset.Legend>
      <CheckboxGroup>
        <Field.Item>
          <Checkbox.Root value="daily" />
          <Field.Label>Daily</Field.Label>
          <Field.Description>Every day at 00:00.</Field.Description>
        </Field.Item>
        <Field.Item>
          <Checkbox.Root value="monthly" />
          <Field.Label>Monthly</Field.Label>
          <Field.Description>On the 5th at 23:59.</Field.Description>
        </Field.Item>
      </CheckboxGroup>
    </Fieldset.Root>
  </Field.Root>
</template>

Non-native controls (Select, Slider, a checkbox or radio group) submit through a hidden input whose name comes from the surrounding <Field.Root>.

Native constraint validation

<Field.Control> forwards standard HTML validation attributes, and the field reads their result straight from the constraint validation API:

  • required: the field must have a value.
  • minlength / maxlength: bounds on text length.
  • pattern: a regular expression the value must match.
  • step: a numeric increment the value must be a multiple of.
<script setup>
import { Field } from '@shardsui/vue/field'
</script>

<template>
  <Field.Root name="website">
    <Field.Control type="url" required pattern="https?://.*" />
    <Field.Error />
  </Field.Root>
</template>

An empty <Field.Error> renders the browser's own message for whichever constraint failed.

Custom validation

Hand <Field.Root> a validate function for logic the native attributes can't express. It receives the field's value and the full form values, and returns an error string, an array of strings, or null when valid. It's allowed to be async, but an async result never holds up a submission: the form submits before a pending validate resolves. Checks that must gate the submit belong on the server, fed back through errors.

validationMode decides when it fires:

  • onSubmit (default): validate every field when the <Form> submits; after that first attempt, each field revalidates as its value changes.
  • onBlur: validate when focus leaves the field.
  • onChange: validate on every value change, e.g. each keystroke.

For onChange against a network call, validationDebounceTime (milliseconds) debounces the callback.

<script setup>
import { Field } from '@shardsui/vue/field'

async function validateUsername(value) {
  if (value === 'admin') {
    return 'Reserved for system use.'
  }

  const result = await fetch(`https://api.example.com/usernames/${value}`)
  if (!result.ok) {
    return `${value} is unavailable.`
  }

  return null
}
</script>

<template>
  <Field.Root
    name="username"
    validation-mode="onChange"
    :validation-debounce-time="300"
    :validate="validateUsername"
  >
    <Field.Control required minlength="3" />
    <Field.Error />
  </Field.Root>
</template>

validationMode set on a field wins over the one on <Form>.

Showing the error

<Field.Error> with no content shows the field's current message whenever it's invalid, or a <ul> of them when more than one applies. Give it a match prop to take control:

  • a ValidityState key like "valueMissing" or "patternMismatch" renders only when that specific flag is set, giving you the hook for per-reason, translatable copy;
  • :match="true" always renders; :match="false" (or omitted) renders whenever the field is invalid or carries a form error.
<template>
  <Field.Root name="username">
    <Field.Control required minlength="3" />
    <Field.Error match="valueMissing">You must choose a username.</Field.Error>
    <Field.Error match="tooShort">At least 3 characters.</Field.Error>
  </Field.Root>
</template>

<Field.Error> participates in enter/exit animation. It exposes data-starting-style and data-ending-style.

For anything the match cases can't cover, <Field.Validity> hands you the raw validity state in a scoped slot:

<template>
  <Field.Validity v-slot="{ errors }">
    <ul v-if="errors.length">
      <li v-for="message in errors" :key="message">{{ message }}</li>
    </ul>
  </Field.Validity>
</template>

The slot also receives validity (the ValidityState flags), error (the first message), value, initialValue, and transitionStatus.

Submitting

Two listeners, depending on the shape you want the values in.

Reach for the native submit event when you want the raw FormData. It runs only after validation passes, and you own the preventDefault():

<script setup>
import { Form } from '@shardsui/vue/form'

async function onSubmit(event) {
  event.preventDefault()
  const formData = new FormData(event.currentTarget)
  await fetch('https://api.example.com', { method: 'POST', body: formData })
}
</script>

<template>
  <Form @submit="onSubmit" />
</template>

Reach for the formSubmit event — bound as @form-submit — when you'd rather reshape the values as a plain object before sending. It calls preventDefault() on the native submit event for you:

<script setup>
import { Form } from '@shardsui/vue/form'

async function onFormSubmit(values) {
  const payload = {
    product_id: values.id,
    order_quantity: values.quantity
  }
  await fetch('https://api.example.com', {
    method: 'POST',
    body: JSON.stringify(payload)
  })
}
</script>

<template>
  <Form @form-submit="onFormSubmit" />
</template>

Server-returned errors

Validation that can only happen on the server — a promo code that's expired, a username already taken — comes back as an errors prop. Shape it as an object keyed by field name, each value one message or an array of them. The library merges those into the matching fields' state and focuses the first invalid one. As soon as a field's value changes, its server error drops out on its own.

<script setup>
import { shallowRef } from 'vue'
import { Form } from '@shardsui/vue/form'
import { Field } from '@shardsui/vue/field'

async function submitToServer(payload) {
  return { errors: { promoCode: 'This promo code has expired.' } }
}

const errors = shallowRef({})

async function onSubmit(event) {
  event.preventDefault()
  const response = await submitToServer(/* … */)
  errors.value = response.errors
}
</script>

<template>
  <Form :errors="errors" @submit="onSubmit">
    <Field.Root name="promoCode">
      <Field.Control />
      <Field.Error />
    </Field.Root>
  </Form>
</template>

With a Nuxt server route

Put the check in a server route, return failures keyed by field name, and let the page feed them back through errors. Because <Form> renders a genuine <form>, method and action pass straight through and the form works with JavaScript off; with it on, post the FormData from the submit listener instead.

function authenticateUser(data: FormData): { success: boolean } {
  // your auth logic
}

export default defineEventHandler(async (event) => {
  const data = await readFormData(event)
  const result = authenticateUser(data)

  if (!result.success) {
    return { errors: { password: 'Invalid username or password.' } }
  }
  return { errors: {} }
})
<script setup lang="ts">
import { shallowRef } from 'vue'
import { Form, type FormErrors } from '@shardsui/vue/form'
import { Field } from '@shardsui/vue/field'

const errors = shallowRef<FormErrors>({})

async function onSubmit(event: SubmitEvent) {
  event.preventDefault()
  const body = new FormData(event.currentTarget as HTMLFormElement)
  const response = await $fetch('/api/login', { method: 'POST', body })
  errors.value = response.errors
}
</script>

<template>
  <Form method="POST" action="/api/login" :errors="errors" @submit="onSubmit">
    <Field.Root name="password">
      <Field.Control type="password" />
      <Field.Error />
    </Field.Root>
  </Form>
</template>

The demo below simulates the server error shape on the client.

<script setup lang="ts">
import { Button } from '@shardsui/vue/button'
import { Field } from '@shardsui/vue/field'
import { Form, type FormErrors } from '@shardsui/vue/form'
import { shallowRef } from 'vue'

const serverErrors = shallowRef<FormErrors>({})
const loading = shallowRef(false)

// Stands in for a Nuxt server route posted to from the submit listener.
async function submitForm(formData: FormData): Promise<FormErrors> {
  await new Promise((resolve) => setTimeout(resolve, 1000))
  const username = formData.get('username')
  if (username === 'admin') {
    return { username: "'admin' is reserved. Choose another." }
  }
  return {}
}

async function onSubmit(event: SubmitEvent) {
  event.preventDefault()
  const formData = new FormData(event.currentTarget as HTMLFormElement)
  loading.value = true
  serverErrors.value = await submitForm(formData)
  loading.value = false
}
</script>

<template>
  <Form
    method="POST"
    class="flex w-full max-w-64 flex-col gap-4"
    :errors="serverErrors"
    @submit="onSubmit"
  >
    <Field.Root name="username" class="flex flex-col items-start gap-1">
      <Field.Label class="text-sm font-semibold text-gray-900">Username</Field.Label>
      <Field.Control
        type="text"
        required
        value="admin"
        placeholder="e.g. alice132"
        class="h-8 w-full rounded-md border border-gray-200 px-2 text-sm font-normal text-gray-900 focus:outline-2 focus:-outline-offset-1 focus:outline-gray-950 any-pointer-coarse:text-base"
      />
      <Field.Error class="text-sm text-red-800" />
    </Field.Root>
    <Button
      type="submit"
      :disabled="loading"
      class="font-inherit m-0 flex h-8 items-center justify-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 text-sm/6 font-normal text-nowrap text-gray-900 outline-0 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 data-disabled:text-gray-500 hover:data-disabled:bg-gray-50"
    >
      Claim username
    </Button>
  </Form>
</template>

Schema validation with Zod

Keep validation rules in one schema object. Parse the submitted values with safeParse, then map the schema's per-field errors onto errors. z.flattenError(result.error).fieldErrors is already keyed by field name, so it drops in with no remapping:

<script setup>
import { shallowRef } from 'vue'
import { z } from 'zod'
import { Form } from '@shardsui/vue/form'
import { Field } from '@shardsui/vue/field'

const schema = z.object({
  name: z.string().min(1, 'Name is required'),
  age: z.coerce.number('Age must be a number').positive('Age must be positive')
})

async function submitForm(values) {
  const result = schema.safeParse(values)
  if (!result.success) {
    return { errors: z.flattenError(result.error).fieldErrors }
  }
  return { errors: {} }
}

const errors = shallowRef({})

async function onFormSubmit(values) {
  const response = await submitForm(values)
  errors.value = response.errors
}
</script>

<template>
  <Form :errors="errors" @form-submit="onFormSubmit">
    <!-- fields keyed by name -->
  </Form>
</template>
<script setup lang="ts">
import { Button } from '@shardsui/vue/button'
import { Field } from '@shardsui/vue/field'
import { Form, type FormErrors } from '@shardsui/vue/form'
import { shallowRef } from 'vue'
import { z } from 'zod'

const schema = z.object({
  name: z.string().min(1, 'Name is required'),
  questionCount: z.coerce
    .number('Count must be a number')
    .positive('Count must be a positive number')
})

const fields = [
  { name: 'name', label: 'Name', placeholder: 'My project' },
  { name: 'questionCount', label: 'Count', placeholder: '10' }
]

function validate(formValues: Record<string, unknown>): FormErrors {
  const result = schema.safeParse(formValues)
  if (result.success) return {}

  const errors: FormErrors = {}
  for (const [name, messages] of Object.entries(z.flattenError(result.error).fieldErrors)) {
    if (messages) errors[name] = messages
  }
  return errors
}

const errors = shallowRef<FormErrors>({})
</script>

<template>
  <Form
    class="flex w-full max-w-64 flex-col gap-4"
    :errors="errors"
    @form-submit="(formValues) => (errors = validate(formValues))"
  >
    <Field.Root
      v-for="field in fields"
      :key="field.name"
      :name="field.name"
      class="flex flex-col items-start gap-1"
    >
      <Field.Label class="text-sm font-semibold text-gray-900">{{ field.label }}</Field.Label>
      <Field.Control
        :placeholder="field.placeholder"
        class="h-8 w-full rounded-md border border-gray-200 px-2 text-sm font-normal text-gray-900 focus:outline-2 focus:-outline-offset-1 focus:outline-gray-950 any-pointer-coarse:text-base"
      />
      <Field.Error class="text-sm text-red-800" />
    </Field.Root>
    <Button
      type="submit"
      class="font-inherit m-0 flex h-8 items-center justify-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 text-sm/6 font-normal text-nowrap text-gray-900 outline-0 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 data-disabled:text-gray-500 hover:data-disabled:bg-gray-50"
    >
      Create
    </Button>
  </Form>
</template>

Driving a field from an external library

When something else owns form state — a dedicated form library, or your own store — a field becomes fully controlled. <Field.Root> accepts invalid, dirty, and touched; <Field.Control> accepts v-model:value and the native listeners the library needs to keep those in sync; and <Field.Error :match="..."> lets it decide when the message shows:

<script setup lang="ts">
import { Field } from '@shardsui/vue/field'

// supplied by your form library
const { name, value, invalid, dirty, touched, error, onValueChange, onBlur } = defineProps<{
  name: string
  value: string
  invalid: boolean
  dirty: boolean
  touched: boolean
  error?: string
  onValueChange: (value: string) => void
  onBlur: (event: FocusEvent) => void
}>()
</script>

<template>
  <Field.Root :name="name" :invalid="invalid" :dirty="dirty" :touched="touched">
    <Field.Label>Username</Field.Label>
    <Field.Description>
      May appear where you contribute or are mentioned. Remove it any time.
    </Field.Description>
    <Field.Control
      placeholder="e.g. alice132"
      :value="value"
      @update:value="onValueChange"
      @blur="onBlur"
    />
    <Field.Error :match="!!error">{{ error }}</Field.Error>
  </Field.Root>
</template>

When the library orchestrates validation and submission, <Form> is optional. A plain <form> works. To trigger validation yourself, a template ref on <Form> exposes a validate() that validates every field, or one of them when passed a field name.