Skip to content
Docs

Form

Collect information from your users using validation rules.

Collect information from your users using validation rules.

<Form.root id="form-primitive" class="DemoFormRoot">
  <Form.field class="DemoFormField" name="email">
    <div style="display: flex; align-items: baseline; justify-content: space-between;">
      <Form.label class="DemoFormLabel">Email</Form.label>
      <Form.message class="DemoFormMessage" match="valueMissing">
        Please enter your email
      </Form.message>
      <Form.message class="DemoFormMessage" match="typeMismatch">
        Please provide a valid email
      </Form.message>
    </div>
    <Form.control>
      <input class="DemoInput" type="email" name="email" required />
    </Form.control>
  </Form.field>
  <Form.field class="DemoFormField" name="question">
    <div style="display: flex; align-items: baseline; justify-content: space-between;">
      <Form.label class="DemoFormLabel">Question</Form.label>
      <Form.message class="DemoFormMessage" match="valueMissing">
        Please enter a question
      </Form.message>
    </div>
    <Form.control>
      <textarea class="DemoTextarea" name="question" required></textarea>
    </Form.control>
  </Form.field>
  <Form.submit class="DemoButton" style="margin-top: 10px;">
    Post question
  </Form.submit>
</Form.root>
Built on top of the native browser [constraint validation API](https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation).
Supports built-in validation.
Supports custom validation.
Full customization of validation messages.
Accessible validation messages.
Supports client-side and server-side scenarios.
Focus is fully managed.

Anatomy

Import all parts and piece them together.

<Form.root>
  <Form.field>
    <Form.label />
    <Form.control />
    <Form.message />
    <Form.validity_state />
  </Form.field>

  <Form.message />
  <Form.validity_state />

  <Form.submit />
</Form.root>

Anatomy

Root
Contains all the parts of a form.
Field
The wrapper for a field. It handles id/name and label accessibility automatically.
Label
A label element which is automatically wired when nested inside a `field` part.
Control
A wrapper around the native control (`input`, `select`, or `textarea`) nested inside a `field` part.
Message
A validation message wired to a control when nested inside a `field`, or targeted by `name` when used outside.
Validity State
A slot wrapper that mirrors the field's validity state onto `data-*` attributes for styling or conditional markup.
Submit
The submit button.

API Reference

Root

Contains all the parts of a form. Renders a native <form> with novalidate and a FormRoot hook that manages validation, message visibility, and focus on submit.

Prop Type Default Description
id string
id
Type string Default

Field

The wrapper for a field. It handles id/name and label accessibility automatically.

Prop Type Default Description
id string nil
name string
server_invalid boolean false
id
Type string Default nil
name
Type string Default
server_invalid
Type boolean Default false

Data attributes

Attribute Values Description
[data-invalid] Present when the field is invalid (client- or server-side).
[data-server-invalid] Present when `server_invalid` is true.

Label

A label element which is automatically wired when nested inside a field part. The FormRoot hook sets for to match the control's generated or explicit id.

Prop Type Default Description
for string nil
id string nil
for
Type string Default nil
id
Type string Default nil

Control

A wrapper around the native control element nested inside a field part. Place an input, select, or textarea with a name matching the field inside this part.

Prop Type Default Description
id string nil
id
Type string Default nil

Data attributes

Attribute Values Description
[data-invalid] Present on the native control when the field is invalid.

Message

A validation message which is automatically wired (functionality and accessibility) to a given control when nested inside a field part. It can be used for built-in and custom client-side validation, as well as server-side validation. When used outside a field you must pass a name matching a field.

Form.message accepts a match attribute which determines when the message should show. For built-in validation it matches the native HTML ValidityState keys (required, min, max, and so on). Supported values:

valueMissing, typeMismatch, patternMismatch, tooLong, tooShort, rangeUnderflow, rangeOverflow, stepMismatch, badInput, customError, valid

When match is omitted, the message shows whenever the field is invalid. Use force_match to show a message regardless of client-side matching (useful for server-side errors).

Prop Type Default Description
force_match boolean false
id string nil
match string nil
name string nil
force_match
Type boolean Default false
id
Type string Default nil
match
Type string Default nil
name
Type string Default nil

Validity State

A slot wrapper—not a render prop—that mirrors a field's validity state onto data-* attributes on the wrapper element. When nested inside a field, the field is inferred automatically; otherwise pass name to target a field.

The FormRoot hook keeps these attributes in sync with the control's native validity:

  • data-valid"true" or "false"
  • data-valueMissing, data-typeMismatch, and the other ValidityState keys — "true" or "false"
  • data-invalid — present when the field is invalid

Style against the wrapper or use descendant selectors; children are ordinary HEEx markup.

Prop Type Default Description
id string nil
name string nil
id
Type string Default nil
name
Type string Default nil

Data attributes

Attribute Values Description
[data-valid] true | false Whether the control's validity is valid.
[data-invalid] Present when the field is invalid.
[data-valueMissing] true | false Mirrors ValidityState.valueMissing.
[data-typeMismatch] true | false Mirrors ValidityState.typeMismatch.
[data-customError] true | false Mirrors ValidityState.customError.

Submit

The submit button.

Prop Type Default Description
id string nil
id
Type string Default nil

Examples

Providing your own validation messages

When no slot content is provided, Form.message has no default text—you should always supply a message. Pass slot content for clearer copy or internationalization:

<Form.message match="valueMissing">Please provide a name</Form.message>

Custom validation

Built-in match values map to the platform's constraint validation API. For custom client-side rules, use match="customError" and report the error on the native control with setCustomValidity/1 (via a small phx-hook or other JS). The message shows when validity.customError is true:

<Form.field name="name">
  <Form.label>Full name</Form.label>
  <Form.control>
    <input type="text" name="name" phx-hook="NameValidator" />
  </Form.control>
  <Form.message match="customError">Only John is allowed.</Form.message>
</Form.field>

For server-returned errors, use a custom match string together with server_invalid on the field—the message shows while the field is marked server-invalid:

<Form.field name="name" server_invalid={@server_errors.name}>
  <Form.label>Full name</Form.label>
  <Form.control>
    <input type="text" name="name" />
  </Form.control>
  <Form.message match="name-rejected">Only John is allowed.</Form.message>
</Form.field>

Styling based on validity

The hook sets data-invalid on the field and native control. Use it to style parts inside the field—for example, the label:

<Form.root id="styled-form">
  <Form.field name="email">
    <Form.label class="DemoFormLabel">Email</Form.label>
    <Form.control>
      <input class="DemoInput" type="email" name="email" />
    </Form.control>
  </Form.field>
</Form.root>
.DemoFormLabel {
  color: white;
}
[data-radix-form-field][data-invalid] .DemoFormLabel {
  color: red;
}
[data-radix-form-field]:not([data-invalid]) .DemoFormLabel {
  color: green;
}

Accessing the validity state for more control

Wrap markup in Form.validity_state and read mirrored data-* attributes on the wrapper—useful for icons or library-specific state classes:

<Form.field name="name">
  <Form.label>Full name</Form.label>
  <Form.validity_state>
    <Form.control>
      <input type="text" name="name" class="DemoInput" />
    </Form.control>
    <span class="DemoValidityIcon" aria-hidden="true"></span>
  </Form.validity_state>
</Form.field>
[data-radix-form-validity-state][data-valid="false"] .DemoValidityIcon {
  color: red;
}
[data-radix-form-validity-state][data-valid="true"] .DemoValidityIcon {
  color: green;
}

Server-side validation

Reuse the same Form.message parts for server errors. Pass force_match to show a client-side message when the server marks the field invalid, or omit match to show a server-only message.

Mark the field invalid with server_invalid. On submit, phx-submit runs only if client-side validation passes; map your server response into assigns and re-render.

<Form.root id="signup" phx-submit="submit">
  <Form.field name="email" server_invalid={@server_errors.email}>
    <Form.label>Email address</Form.label>
    <Form.control>
      <input type="email" name="email" required phx-change="clear_email_error" />
    </Form.control>
    <Form.message match="valueMissing">Please enter your email.</Form.message>
    <Form.message match="typeMismatch" force_match={@server_errors.email}>
      Please provide a valid email.
    </Form.message>
  </Form.field>

  <Form.field name="password" server_invalid={@server_errors.password}>
    <Form.label>Password</Form.label>
    <Form.control>
      <input type="password" name="password" required phx-change="clear_password_error" />
    </Form.control>
    <Form.message match="valueMissing">Please enter a password.</Form.message>
    <%= if @server_errors.password do %>
      <Form.message>
        Please provide a valid password. It should contain at least 1 number and 1 special character.
      </Form.message>
    <% end %>
  </Form.field>

  <Form.submit>Submit</Form.submit>
</Form.root>
def handle_event("submit", params, socket) do
  case Accounts.register(params) do
    {:ok, _user} ->
      {:noreply, push_navigate(socket, to: ~p"/")}

    {:error, errors} ->
      {:noreply,
       assign(socket,
         server_errors: %{
           email: Map.has_key?(errors, :email),
           password: Map.has_key?(errors, :password)
         }
       )}
  end
end

def handle_event("clear_email_error", _params, socket) do
  {:noreply, update(socket, :server_errors, &Map.put(&1, :email, false))}
end

def handle_event("clear_password_error", _params, socket) do
  {:noreply, update(socket, :server_errors, &Map.put(&1, :password, false))}
end

The FormRoot hook also removes data-server-invalid from the DOM when the user edits a field. When server_invalid is driven by LiveView assigns, clear the assign in phx-change (as above) so server errors do not reappear on the next render.

Accessibility

The component follows the "inline errors" pattern for validation:

  • Label and control are associated using the name provided on Form.field
  • When one or more client-side error messages display, they are automatically associated with their matching control and announced accordingly
  • Focus is moved to the first invalid control on submit