Form

Accessible form and field composition for labels, descriptions, errors, and native controls.

Used for sign-in and account recovery.

vue
<script setup lang="ts">
import { ref } from 'vue'
import {
  Button,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldLabel,
  FieldRoot,
  FormRoot,
  TextField,
} from '@typlog/ui'

const email = ref('')
const submitted = ref(false)
</script>

<template>
  <FormRoot class="flex max-w-sm flex-col gap-4" @submit="submitted = true">
    <FieldRoot name="email" required>
      <FieldLabel>Email</FieldLabel>
      <FieldControl as-child>
        <TextField v-model="email" type="email" required placeholder="you@example.com" />
      </FieldControl>
      <FieldDescription>Used for sign-in and account recovery.</FieldDescription>
      <FieldError />
    </FieldRoot>
    <Button type="submit">Continue</Button>
    <p v-if="submitted" class="text-sm text-gray-11">Submitted: {{ email }}</p>
  </FormRoot>
</template>

Form provides a small, validator-neutral composition layer for existing Typlog controls. It connects labels, descriptions, and errors to a control, while preserving native controls and browser constraint validation behavior. Valid submissions emit submit after field validation; FormRoot prevents the browser's default navigation so the application can handle the result.

It does not manage nested values, field arrays, or schema state. Validation can come from the browser, a field callback, a server response, or an external schema library such as Zod.

Examples ​

Validation ​

Native validation ​

Native constraints such as required and type continue to work. Invalid events update the field state, associate the message with the control, and focus the first invalid field when submission is blocked.

Enter a valid email address.

vue
<script setup lang="ts">
import { ref } from 'vue'
import {
  Button,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldLabel,
  FieldRoot,
  FormRoot,
  TextField,
} from '@typlog/ui'

const email = ref('')
const sent = ref(false)
</script>

<template>
  <FormRoot class="flex max-w-sm flex-col gap-4" @submit="sent = true">
    <FieldRoot name="email" required>
      <FieldLabel>Email address</FieldLabel>
      <FieldControl as-child>
        <TextField v-model="email" type="email" required placeholder="you@example.com" />
      </FieldControl>
      <FieldDescription>Enter a valid email address.</FieldDescription>
      <FieldError />
    </FieldRoot>
    <div class="flex gap-2">
      <Button type="submit">Sign in</Button>
      <Button type="reset" variant="soft">Reset</Button>
    </div>
    <p v-if="sent" class="text-sm text-green-11">Native validation passed.</p>
  </FormRoot>
</template>

Server errors ​

Pass an error map to FormRoot, keyed by each FieldRoot name. New input clears the matching visible error; a later server response can provide a new error map. Form-level errors remain application-owned.

vue
<script setup lang="ts">
import { ref } from 'vue'
import {
  Button,
  FieldControl,
  FieldError,
  FieldLabel,
  FieldRoot,
  FormRoot,
  TextField,
} from '@typlog/ui'

const username = ref('')
const errors = ref<Record<string, string>>({})
const formError = ref('')

const submit = () => {
  errors.value = username.value === 'taken' ? { username: 'That username is already taken.' } : {}
  formError.value = username.value === 'offline' ? 'The service is unavailable. Try again.' : ''
}
</script>

<template>
  <FormRoot
    class="flex max-w-sm flex-col gap-4"
    :errors="errors"
    @submit="submit"
  >
    <p v-if="formError" role="alert" class="text-sm text-red-11">{{ formError }}</p>
    <FieldRoot name="username" required>
      <FieldLabel>Username</FieldLabel>
      <FieldControl as-child>
        <TextField v-model="username" required placeholder="taken or offline" />
      </FieldControl>
      <FieldError />
    </FieldRoot>
    <Button type="submit">Save</Button>
  </FormRoot>
</template>

Zod ​

Run schema validation in application code, normalize issues into the controlled error map, and use the parsed output on success. Zod is used only by this example and is not a runtime dependency of @typlog/ui.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { z } from 'zod'
import {
  Button,
  FieldControl,
  FieldError,
  FieldLabel,
  FieldRoot,
  FormRoot,
  TextField,
} from '@typlog/ui'

const schema = z.object({
  email: z.string().email('Enter a valid email.').transform(value => value.toLowerCase()),
})
const email = ref('')
const errors = ref<Record<string, string>>({})
const parsedEmail = ref('')

const submit = async () => {
  const result = await schema.safeParseAsync({ email: email.value })
  if (!result.success) {
    errors.value = Object.fromEntries(result.error.issues.map(issue => [issue.path.join('.'), issue.message]))
    parsedEmail.value = ''
    return
  }
  errors.value = {}
  parsedEmail.value = result.data.email
}
</script>

<template>
  <FormRoot class="flex max-w-sm flex-col gap-4" :errors="errors" @submit="submit">
    <FieldRoot name="email">
      <FieldLabel>Email</FieldLabel>
      <FieldControl as-child>
        <TextField v-model="email" placeholder="you@example.com" />
      </FieldControl>
      <FieldError />
    </FieldRoot>
    <Button type="submit">Validate</Button>
    <p v-if="parsedEmail" class="text-sm text-green-11">Parsed output: {{ parsedEmail }}</p>
  </FormRoot>
</template>

Controls ​

Use FieldControl with native elements or compose it through as-child with TextField, TextArea, Select, Combobox, Checkbox, Radio Group, Switch, and Pin Input. Compound controls keep their existing submission behavior.

vue
<script setup lang="ts">
import { ref } from 'vue'
import {
  Checkbox,
  ComboboxContent,
  ComboboxInput,
  ComboboxItem,
  ComboboxRoot,
  FieldControl,
  FieldLabel,
  FieldRoot,
  FormRoot,
  PinInputInput,
  PinInputRoot,
  RadioGroupItem,
  RadioGroupRoot,
  SelectContent,
  SelectItem,
  SelectRoot,
  SelectTrigger,
  Switch,
  TextArea,
  TextField,
} from '@typlog/ui'

const checkbox = ref(false)
const radio = ref('one')
const select = ref('one')
const combo = ref('one')
const toggle = ref(false)
const pin = ref<string[]>([])
</script>

<template>
  <FormRoot class="grid max-w-xl gap-4 sm:grid-cols-2">
    <FieldRoot name="text">
      <FieldLabel>TextField</FieldLabel>
      <FieldControl as-child><TextField /></FieldControl>
    </FieldRoot>
    <FieldRoot name="notes">
      <FieldLabel>TextArea</FieldLabel>
      <FieldControl as-child><TextArea /></FieldControl>
    </FieldRoot>
    <FieldRoot name="select">
      <FieldLabel>Select</FieldLabel>
      <SelectRoot v-model="select" name="select">
        <FieldControl as-child><SelectTrigger placeholder="Choose" /></FieldControl>
        <SelectContent><SelectItem value="one">One</SelectItem><SelectItem value="two">Two</SelectItem></SelectContent>
      </SelectRoot>
    </FieldRoot>
    <FieldRoot name="combo">
      <FieldLabel>Combobox</FieldLabel>
      <ComboboxRoot v-model="combo" name="combo">
        <FieldControl as-child><ComboboxInput placeholder="Choose" /></FieldControl>
        <ComboboxContent><ComboboxItem value="one">One</ComboboxItem><ComboboxItem value="two">Two</ComboboxItem></ComboboxContent>
      </ComboboxRoot>
    </FieldRoot>
    <FieldRoot name="checkbox">
      <FieldLabel>Checkbox</FieldLabel>
      <FieldControl as-child><Checkbox v-model="checkbox" name="checkbox" /></FieldControl>
    </FieldRoot>
    <FieldRoot name="radio">
      <FieldLabel>Radio Group</FieldLabel>
      <FieldControl as-child>
        <RadioGroupRoot v-model="radio" name="radio" class="flex flex-col gap-2">
          <RadioGroupItem value="one">One</RadioGroupItem>
          <RadioGroupItem value="two">Two</RadioGroupItem>
        </RadioGroupRoot>
      </FieldControl>
    </FieldRoot>
    <FieldRoot name="switch">
      <FieldLabel>Switch</FieldLabel>
      <FieldControl as-child><Switch v-model="toggle" name="switch" /></FieldControl>
    </FieldRoot>
    <FieldRoot name="pin">
      <FieldLabel>Pin Input</FieldLabel>
      <FieldControl as-child>
        <PinInputRoot v-model="pin" name="pin"><PinInputInput v-for="index in 4" :key="index" :index="index - 1" /></PinInputRoot>
      </FieldControl>
    </FieldRoot>
  </FormRoot>
</template>

API Reference ​

Form ​

FormRoot ​

The form container. It coordinates field validation, controlled errors, submission, reset, and focus movement after an invalid submission. It prevents the browser's default submit navigation and emits submit only when every registered field is valid.

It accepts native <form> attributes and events. Use errors to provide a controlled error map keyed by field name; native attributes are omitted here.

PropDefaultType
errors
–
Record<string, FormError>

Field errors keyed by field name.

Field ​

FieldRoot ​

Provides the field name, generated IDs, validation state, and shared context for the remaining field parts. Use validate for a validator-neutral synchronous or asynchronous field callback.

PropDefaultType
disabledfalse
boolean

Disables the field control.

errors
–
FormError

Initial field errors controlled by the consumer.

id
–
string

Explicitly sets the generated control ID.

name
–
string

The form field name used for errors and native submission.

requiredfalse
boolean

Marks the field as required.

validate
–
FieldValidator

A validator-neutral callback returning one or more messages.

FieldLabel ​

Labels the field control using the generated or explicit control ID.

PropDefaultType
as"label"
AsTagComponent
asChild
–
boolean
for
–
string

FieldControl ​

Applies the field's ID, name, required and disabled state, and ARIA attributes to a native element or an existing component through as-child.

PropDefaultType
as"input"
AsTagComponent
asChild
–
boolean
id
–
string

FieldDescription ​

Provides persistent supporting text for the control. It remains visible when an error is present because descriptions and errors serve different purposes.

PropDefaultType
as"p"
AsTagComponent
asChild
–
boolean

FieldError ​

Renders the current native, field-validator, or controlled error messages. The part is omitted when the field has no visible errors.

PropDefaultType
as"p"
AsTagComponent
asChild
–
boolean
match
–
keyof ValidityState

Behavior ​

Error lifecycle ​

Errors become visible after native invalid events, field validation, or a controlled error update. New input immediately clears the field's visible local, native, and controlled error state. Blur or the next submission validates the field again.

FieldDescription remains visible alongside FieldError. Both IDs are included in aria-describedby while they are rendered.

Reset ​

Native form.reset() restores native control defaults and clears field touched, dirty, validity, and local error state.

Accessibility ​

Generated IDs associate labels, descriptions, and visible errors with the field control. Pass an explicit id to FieldControl when an application needs a stable ID. Invalid controls receive aria-invalid="true", and keyboard focus moves to the first invalid control after a blocked submission.

Last updated Sep 15, 2026