FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Toggle
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup
  • Select
  • Combobox
  • Toast
  • Slider
  • Field & Form

Resources

  • Roadmap & changelog
  • GitHub ↗

Field and Form

Field wires a label, a description and an error message to a form control; Form runs the form's action. There is no validation engine: the browser's own constraint validation (required, type, pattern, minLength, …) is the truth, the platform's :user-invalid heuristic decides when an error is shown, and the browser's localised message is the default text. What the library adds is the wiring the platform does not do (ids, aria-describedby, an explicit aria-invalid), a styleable error element instead of the native bubble, the first-invalid focus, and the Async React half: the submission runs in a transition with data-pending, server errors land on their fields and go away on the next edit, and the form is reset only when the action reported no errors.

We never share it.

Lowercase letters, digits and underscores.

Show code
FieldSignUp.tsx
"use client";
import { useActionState } from "react";
import { Field } from "@funstack/ui/field";
import { Form } from "@funstack/ui/form";
import type { FormErrors } from "@funstack/ui/form";
import { sleep } from "./fake-api.js";

interface SignUpState {
  readonly errors?: FormErrors;
  readonly created?: string;
}

// A pretend Server Function. It validates on the server and returns field errors keyed by
// name; it never throws for bad input, which is what `useActionState` is for.
async function signUp(_prev: SignUpState, formData: FormData): Promise<SignUpState> {
  await sleep(900);
  const email = String(formData.get("email"));
  const handle = String(formData.get("handle"));
  const errors: Record<string, string> = {};
  if (email.toLowerCase() === "taken@example.com") {
    errors["email"] = "This email is already registered.";
  }
  if (handle.toLowerCase().includes("admin")) errors["handle"] = "That handle is reserved.";
  if (Object.keys(errors).length > 0) return { errors };
  return { created: handle };
}

// Try: submit empty (the browser's messages, the first field focused); a short password; a
// mismatched confirmation (re-checked when either field changes); "taken@example.com" or a
// handle containing "admin" (server errors, focused, gone on the next keystroke). A successful
// sign-up resets the form; a failed one keeps every value.
export function FieldSignUp() {
  const [state, formAction] = useActionState(signUp, {});
  return (
    <Form.Root action={formAction} errors={state.errors} className="demo-form">
      <Field.Root name="email" required className="demo-fieldset">
        <Field.Label className="demo-label">Email</Field.Label>
        <Field.Control type="email" autoComplete="off" className="demo-input" />
        <Field.Description className="demo-description">We never share it.</Field.Description>
        <Field.Error className="demo-field-error" />
      </Field.Root>
      <Field.Root name="handle" required className="demo-fieldset">
        <Field.Label className="demo-label">Handle</Field.Label>
        <Field.Control
          pattern="[a-z0-9_]+"
          autoComplete="off"
          className="demo-input"
          aria-describedby="handle-hint"
        />
        <p id="handle-hint" className="demo-description">
          Lowercase letters, digits and underscores.
        </p>
        <Field.Error className="demo-field-error">
          {({ validity, messages }) =>
            validity.patternMismatch
              ? "Use lowercase letters, digits and underscores."
              : messages.join(" ")
          }
        </Field.Error>
      </Field.Root>
      <Field.Root name="password" required className="demo-fieldset">
        <Field.Label className="demo-label">Password</Field.Label>
        <Field.Control
          type="password"
          minLength={8}
          autoComplete="new-password"
          className="demo-input"
        />
        <Field.Error className="demo-field-error">
          {({ validity, messages }) =>
            validity.tooShort ? "Use at least 8 characters." : messages.join(" ")
          }
        </Field.Error>
      </Field.Root>
      <Field.Root
        name="confirm"
        required
        className="demo-fieldset"
        validate={(value, formData) =>
          value === formData.get("password") ? null : "Passwords do not match."
        }
      >
        <Field.Label className="demo-label">Confirm password</Field.Label>
        <Field.Control type="password" autoComplete="new-password" className="demo-input" />
        <Field.Error className="demo-field-error" />
      </Field.Root>
      <div className="demo-row">
        <Form.Submit className="demo-button demo-primary demo-submit">
          {({ pending }) => (
            <>
              {pending && <span className="demo-spinner" aria-hidden />}
              {pending ? "Creating…" : "Create account"}
            </>
          )}
        </Form.Submit>
        <button type="reset" className="demo-button">
          Reset
        </button>
      </div>
      {state.created !== undefined && (
        <output className="demo-readout">
          Created <code>@{state.created}</code> (the form was reset).
        </output>
      )}
    </Form.Root>
  );
}
field.css
/* Field and Form. Every Field part carries data-invalid / data-required / data-disabled /
   data-pending; the control also matches the platform's :user-invalid and :user-valid, which
   work before hydration too. */
.demo-form {
  display: grid;
  gap: 1rem;
  max-inline-size: 26rem;
}

.demo-fieldset {
  display: grid;
  gap: 0.3rem;
}

/* A checkbox or switch first, then its label. */
.demo-fieldset-inline {
  grid-template-columns: auto 1fr;
  align-items: center;
  column-gap: 0.6em;
}
.demo-fieldset-inline .demo-field-error {
  grid-column: 1 / -1;
}

.demo-label {
  font-weight: 600;
  color: var(--color-text);
}
.demo-fieldset[data-required] > .demo-label::after {
  content: " *";
  color: #dc2626;
}

.demo-input {
  padding: 0.4em 0.6em;
  border: 1px solid var(--color-border);
  border-radius: 6px;
  background: var(--color-bg);
  color: var(--color-text);
  font: inherit;
}
.demo-input:user-valid {
  border-color: #16a34a;
}
.demo-input[data-invalid] {
  border-color: #dc2626;
}

/* No JS auto-resize: the textarea grows with its content. */
.demo-grow {
  field-sizing: content;
  min-block-size: 2lh;
  max-block-size: 8lh;
  resize: none;
}

.demo-description {
  margin: 0;
  font-size: 0.85rem;
  color: var(--color-text-secondary);
}

/* Rendered only while an error is shown: enter with @starting-style, leave instantly. */
.demo-field-error {
  font-size: 0.85rem;
  color: #dc2626;
  opacity: 1;
  transition: opacity 150ms;
  @starting-style {
    opacity: 0;
  }
}

.demo-radio-row {
  display: flex;
  gap: 1.2em;
  margin: 0;
  padding: 0;
  border: 0;
}

/* The form's submission: the whole form dims, the button blocks repeat clicks with CSS. */
.demo-form[data-pending] .demo-fieldset {
  opacity: 0.7;
}
.demo-submit[data-pending] {
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .demo-field-error {
    transition: none;
  }
}

Anatomy

import { Field } from "@funstack/ui/field";
import { Form } from "@funstack/ui/form";

<Form.Root action={formAction} errors={state.errors}>              {/* form */}
  <Field.Root name="email" required>                               {/* div */}
    <Field.Label>Email</Field.Label>                               {/* label[for] */}
    <Field.Control type="email" autoComplete="email" />            {/* input */}
    <Field.Description>We never share it.</Field.Description>     {/* p[id] */}
    <Field.Error />                                                {/* div[id], only while invalid */}
  </Field.Root>
  <Field.Root name="newsletter">
    <Field.Label>Newsletter</Field.Label>
    <Checkbox />                                                   {/* any FUNSTACK form control */}
  </Field.Root>
  <Form.Submit>Create account</Form.Submit>                         {/* button[type=submit] */}
</Form.Root>
  • Field.Root is a div that owns the ids and the field's state. name, required and disabled go to the control unless it sets its own.
  • Field.Control is a native <input>, or a <textarea> / <select> through render. Every other input prop passes through (type, defaultValue, a controlled value / onChange, pattern, …).
  • Any FUNSTACK form control is a Field's control without a wrapper: Checkbox, Switch, RadioGroup, CheckboxGroup, Select, Combobox and Slider (see Slider inside a Field). A group (RadioGroup, CheckboxGroup, a Slider with several thumbs) is named by Field.Label through aria-labelledby instead of for. A RadioGroup carries aria-invalid on its radiogroup; a CheckboxGroup and a Slider on every item, because ARIA 1.2 does not support it on a group.
  • Field.Error is rendered only while an error is shown. Write it once, unconditionally; its text is the browser's message, a server error, or what your function children return.
  • Field.Description is always referenced by the control, rendered or not.

When an error is shown

A field shows an error when its control matches :user-invalid or the Form has a server error for it:

  • Not while the user is still typing for the first time. :user-invalid starts matching after the user changed the value and left the field, or after a submission attempt; it then updates live on every keystroke and stops after a form reset. There is no validationMode: this is the heuristic every native form already uses.
  • A submission attempt with an invalid field: the Field cancels the browser's validation bubble (only when a Field.Error is mounted), renders the message, and the first invalid control in document order is focused. Its description, error included, is read at focus. When focus is already there (Enter pressed in that field), the message is announced with ariaNotify() instead, where the browser has it. A field without Field.Error keeps the browser's bubble; so does every field before hydration.
  • form.checkValidity() shows nothing and reportValidity() shows the browser's bubble, as without the library.
  • The display state is updated synchronously from the DOM event, not in a transition: the error must be in the DOM when focus reaches the field. So no Field part takes a transition prop, Field.Error animates its entry with CSS @starting-style, and content rendered inside it must not suspend.

Custom rules: validate

<Field.Root
  name="confirm"
  required
  validate={(value, formData) => (value === formData.get("password") ? null : "Passwords do not match.")}
>

validate is synchronous and receives the field's value (formData.get(name) as a string) and the whole form's FormData. Its message is written into the control with setCustomValidity, so it blocks submission, matches :invalid / :user-invalid and is shown like a native constraint. It runs at mount, on every input / change anywhere in the form and on a FUNSTACK control's value change (cross-field rules stay current), on blur, after a reset, on a submission attempt, and whenever the prop changes, so a rule that reads outside state (a list that loads, a value in React state) updates on render. An inline function is a new prop on every render and so re-runs each time; wrap it in useCallback to re-run it only when its inputs change. A field with validate is rendered from a client component (a function is not serialisable).

Submission and server errors

With a function action (a Server Function, a useActionState dispatch, or a client function), Form.Root renders it on the <form> unwrapped, so before hydration the form posts to the Server Function. After hydration it runs the action itself, inside its own transition:

  • data-pending is on the form, on Form.Submit and on every Field.Root (and its parts) while the submission is in flight, including the Suspense wait of what the result re-renders. useFormStatus() works as usual in your own components, also through a portal.
  • errors (keyed by field name, usually state.errors from useActionState) reach each field. A server error is shown at once, written into the control's custom validity (a resubmission of the same value is blocked, with the usual focus), and dismissed by the user's next edit. The next settled submission shows its errors again, even identical strings.
  • After a submission with errors, the first field with an error is focused (its message is announced with ariaNotify() when focus is already on it). Errors present at mount (a progressive-enhancement post-back, a Server Component prop) are shown without moving focus.
  • An error about a value the user changed while the submission was in flight is not shown: it is about a value they already replaced.
  • Reset only on success. React resets a form after every successful action, including one that returned field errors, which wipes what the user typed. The Form resets (form.reset()) only when the submission settles with no error in errors; pass autoReset={false} to keep the values (a settings form).
  • An action that throws reaches the nearest Error Boundary above Form.Root; nothing is reset. Return errors from the action to keep the form.
  • A second submit while one is pending is not dropped: a useActionState dispatch queues it. Block repeat clicks with CSS on Form.Submit[data-pending] if you need to; the button is never disabled.

In a Dialog: Dialog.Form rendered as Form.Root

const [state, dispatch] = useActionState(saveProfile, {});

<Dialog.Popup>
  <Dialog.Form action={dispatch} render={<Form.Root errors={state.errors} />} closeOnSettle={false}>
    <Field.Root name="email">…</Field.Root>
    <Form.Submit>Save</Form.Submit>
  </Dialog.Form>
</Dialog.Popup>

Dialog.Form renders a plain React form, which takes React's path: the form is reset after every successful action, also one that returned field errors. Rendered as Form.Root (render={<Form.Root errors={state.errors} />}), Form.Root receives the action and runs it, so its policy applies: server errors are routed to the fields and focused, and the form is reset only when the result has no error (autoReset, which you can pass on the Form.Root element). Dialog.Form's default closeOnSettle still closes the dialog when the action settles, errors or not: pass closeOnSettle={false} and close it yourself on success (for example with a controlled open) to let the user fix the errors.

A submitter with its own formAction

A Form.Submit formAction={fn} (or any button with a formAction) takes that submission out of the Form's hands: React runs it and resets the form afterwards, as it always does. A string action is a plain navigating form.

FUNSTACK controls in a Field

You can change it later.

Grows as you type (field-sizing: content). Links are rejected by the server.

Show code
FieldControls.tsx
"use client";
import { startTransition, useState } from "react";
import { Checkbox } from "@funstack/ui/checkbox";
import { Field } from "@funstack/ui/field";
import { Form } from "@funstack/ui/form";
import type { FormErrors } from "@funstack/ui/form";
import { RadioGroup } from "@funstack/ui/radio-group";
import { Switch } from "@funstack/ui/switch";
import { fakeSave } from "./fake-api.js";

// FUNSTACK controls are a Field's control as they are: the Field labels and describes them,
// gives them its `name` / `required` / `disabled`, and shows their native validation. The Switch
// saves on its own (its own `data-pending`); the form's submission is the Field's `data-pending`.
// `autoReset={false}`: a settings form keeps its values after a successful save.
export function FieldControls() {
  const [errors, setErrors] = useState<FormErrors>({});
  const [saved, setSaved] = useState<string | null>(null);
  return (
    <Form.Root
      className="demo-form"
      autoReset={false}
      errors={errors}
      action={async (formData) => {
        await fakeSave("the profile");
        const bio = String(formData.get("bio"));
        startTransition(() => {
          setErrors(bio.includes("http") ? { bio: "Links are not allowed in the bio." } : {});
          setSaved(bio.includes("http") ? null : `plan=${String(formData.get("plan"))}`);
        });
      }}
    >
      <Field.Root name="plan" required className="demo-fieldset">
        <Field.Label className="demo-label">Plan</Field.Label>
        <RadioGroup.Root className="demo-radio-row">
          <label className="demo-field">
            <RadioGroup.Item value="free" className="demo-radio" /> Free
          </label>
          <label className="demo-field">
            <RadioGroup.Item value="pro" className="demo-radio" /> Pro
          </label>
        </RadioGroup.Root>
        <Field.Description className="demo-description">You can change it later.</Field.Description>
        <Field.Error className="demo-field-error" />
      </Field.Root>
      <Field.Root name="bio" className="demo-fieldset">
        <Field.Label className="demo-label">Bio</Field.Label>
        <Field.Control
          render={<textarea rows={1} maxLength={280} />}
          className="demo-input demo-grow"
        />
        <Field.Description className="demo-description">
          Grows as you type (<code>field-sizing: content</code>). Links are rejected by the server.
        </Field.Description>
        <Field.Error className="demo-field-error" />
      </Field.Root>
      <Field.Root name="news" className="demo-fieldset demo-fieldset-inline">
        <Checkbox className="demo-checkbox" />
        <Field.Label className="demo-label">Email me product news</Field.Label>
      </Field.Root>
      <Field.Root name="terms" required className="demo-fieldset demo-fieldset-inline">
        <Checkbox className="demo-checkbox" />
        <Field.Label className="demo-label">I accept the terms</Field.Label>
        <Field.Error className="demo-field-error" />
      </Field.Root>
      <Field.Root name="beta" className="demo-fieldset demo-fieldset-inline">
        <Switch
          className="demo-switch"
          checkedChangeAction={() => fakeSave("the beta setting", { ms: 1200 })}
        />
        <Field.Label className="demo-label">Beta features (saves on its own)</Field.Label>
      </Field.Root>
      <div className="demo-row">
        <Form.Submit className="demo-button demo-primary demo-submit">
          {({ pending }) => (
            <>
              {pending && <span className="demo-spinner" aria-hidden />}
              Save
            </>
          )}
        </Form.Submit>
      </div>
      {saved !== null && (
        <output className="demo-readout">
          Saved <code>{saved}</code>; the values stay (<code>autoReset=&#123;false&#125;</code>).
        </output>
      )}
    </Form.Root>
  );
}
field.css
/* Field and Form. Every Field part carries data-invalid / data-required / data-disabled /
   data-pending; the control also matches the platform's :user-invalid and :user-valid, which
   work before hydration too. */
.demo-form {
  display: grid;
  gap: 1rem;
  max-inline-size: 26rem;
}

.demo-fieldset {
  display: grid;
  gap: 0.3rem;
}

/* A checkbox or switch first, then its label. */
.demo-fieldset-inline {
  grid-template-columns: auto 1fr;
  align-items: center;
  column-gap: 0.6em;
}
.demo-fieldset-inline .demo-field-error {
  grid-column: 1 / -1;
}

.demo-label {
  font-weight: 600;
  color: var(--color-text);
}
.demo-fieldset[data-required] > .demo-label::after {
  content: " *";
  color: #dc2626;
}

.demo-input {
  padding: 0.4em 0.6em;
  border: 1px solid var(--color-border);
  border-radius: 6px;
  background: var(--color-bg);
  color: var(--color-text);
  font: inherit;
}
.demo-input:user-valid {
  border-color: #16a34a;
}
.demo-input[data-invalid] {
  border-color: #dc2626;
}

/* No JS auto-resize: the textarea grows with its content. */
.demo-grow {
  field-sizing: content;
  min-block-size: 2lh;
  max-block-size: 8lh;
  resize: none;
}

.demo-description {
  margin: 0;
  font-size: 0.85rem;
  color: var(--color-text-secondary);
}

/* Rendered only while an error is shown: enter with @starting-style, leave instantly. */
.demo-field-error {
  font-size: 0.85rem;
  color: #dc2626;
  opacity: 1;
  transition: opacity 150ms;
  @starting-style {
    opacity: 0;
  }
}

.demo-radio-row {
  display: flex;
  gap: 1.2em;
  margin: 0;
  padding: 0;
  border: 0;
}

/* The form's submission: the whole form dims, the button blocks repeat clicks with CSS. */
.demo-form[data-pending] .demo-fieldset {
  opacity: 0.7;
}
.demo-submit[data-pending] {
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .demo-field-error {
    transition: none;
  }
}
  • Inside a Field.Root the control's id belongs to the Field (so Field.Label can point at it); name, required and disabled default from the Field.
  • The two channels stay separate: a control's own action (the Switch above saves on its own) gives it data-pending; the form's submission gives the Field data-pending, never the control.
  • Combobox: the Combobox.Input is the control, the hidden input takes the Field's name, and the listbox is named by Field.Label when no Combobox.Label is rendered. validate reads the selection (the hidden input), and picking an option counts as an edit: it re-runs validate and dismisses a server error, like typing.
  • "Submit on change": checkedChangeAction={() => formRef.current?.requestSubmit()} goes through the Form like a click. Never call form.submit(), which skips validation and the Form.

From a Server Component

// app/subscribe.tsx: a Server Component, no client code of yours.
import { Field } from "@funstack/ui/field";
import { Form } from "@funstack/ui/form";
import { subscribe } from "./actions"; // "use server"

export function Subscribe() {
  return (
    <Form.Root action={subscribe}>
      <Field.Root name="email" required>
        <Field.Label>Email</Field.Label>
        <Field.Control type="email" autoComplete="email" />
        <Field.Error />
      </Field.Root>
      <Field.Root name="body" required>
        <Field.Label>Comment</Field.Label>
        <Field.Control render={<textarea rows={2} maxLength={2000} />} className="comment" />
        <Field.Error />
      </Field.Root>
      <Form.Submit>Subscribe</Form.Submit>
    </Form.Root>
  );
}

Every part may be rendered from a Server Component with serialisable props. Before hydration the browser validates and shows its bubble, and the form posts to the Server Function; the ids, aria-describedby and aria-invalid="false" are in the server HTML. A group control's label carries for until hydration replaces it with the group's aria-labelledby.

Styling

.field[data-invalid] .control            { border-color: var(--danger); }
.field:has(.control:user-valid) .control { border-color: var(--ok); }      /* no JS needed */
.field[data-disabled]                    { opacity: .5; }
.field[data-required] .label::after      { content: " *"; }
.error { color: var(--danger); opacity: 1; transition: opacity 150ms; @starting-style { opacity: 0; } }

/* The control grows with its content: no auto-resize hook. */
textarea.control { field-sizing: content; min-height: 3lh; max-height: 12lh; }
select.control   { field-sizing: content; }

form[data-pending]           { cursor: progress; }
.submit[data-pending]        { pointer-events: none; }   /* your "no double submit" */

field-sizing: content (Chrome 123, Safari 26.2, Firefox 152) makes a textarea or a text input size to its content. Tailwind: data-invalid:border-red-500 data-pending:opacity-70 field-sizing-content.

Props

Field.Root

PropTypeDefaultDescription
namestring–The control's default name, the key in Form.Root errors, and the entry validate reads.
requiredbooleanfalseMakes the control natively required and marks every part data-required.
disabledbooleanfalseNatively disables the control and marks every part data-disabled.
validate(value: string, formData: FormData) => FieldErrorValue–A synchronous custom rule, written with setCustomValidity. Return a message (or several) for an error, null / undefined / "" for none.
classNamestring | ((state: FieldState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: FieldState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: FieldState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((props, state: FieldState) => ReactNode)–Replace the default <div> (element form) or take over rendering (function form).

FieldState, shared by every Field part: { invalid, messages, validity, disabled, required, pending }. invalid: an error is shown. messages: the shown messages (the browser's validationMessage, validate's strings, or the server's), [] while valid. validity: a FieldValidity snapshot of the control's ValidityState (valueMissing, typeMismatch, tooShort, …, customError, valid). disabled / required: the Field's or the control's own. pending: the enclosing form's submission is in flight. The Root owns its four data-* attributes.

Field.Label, Field.Description

PropTypeDefaultDescription
classNamestring | ((state: FieldState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: FieldState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: FieldState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((props, state: FieldState) => ReactNode)–Replace the default <label> (must stay a <label>) or <p>.

The Label owns id and htmlFor (absent for a group control); the Description owns id. Both carry the Field's four data-* attributes.

Field.Control

PropTypeDefaultDescription
namestringthe Field'sNative name.
required, disabledbooleanthe Field'sNative; the Field's value also applies (either one is enough).
className, stylestring | CSSProperties | ((state: FieldState) => …)–A value, or a function of the Field's state. No children (void element).
renderReactElement | ((props, state: FieldState) => ReactNode)–Must render an <input>, <textarea> or <select>: render={<textarea rows={3} />}.
…native–Every other <input> prop passes through; the Field's listeners are native, so your onInput / onBlur run independently.

Owns id, name, required, disabled, aria-invalid (always "true" or "false": it stops Chromium announcing a typeMismatch before it is shown, and exposes valueMissing, which it otherwise never does), and aria-describedby (your tokens first, then the description, then the error while shown).

Field.Error

PropTypeDefaultDescription
childrenReactNode | ((state: FieldState) => ReactNode)messages.join(" ")Rendered only while invalid. A function for per-constraint wording: ({ validity, messages }) => validity.valueMissing ? "Enter your email" : messages.join(" "). Must not suspend.
className, stylestring | CSSProperties | ((state: FieldState) => …)–A value, or a function of the Field's state.
renderReactElement | ((props, state: FieldState) => ReactNode)–Replace the default <div> (element form) or take over rendering (function form).

Owns id. No role and no live region: after a submission focus moves to the field and reads the error with the description (a live region would read it twice). Add aria-live="polite" yourself if you want blur-time errors announced. One per Field.

Form.Root

PropTypeDefaultDescription
actionstring | ((formData: FormData) => unknown)–A Server Function, a useActionState dispatch or a client function, rendered unwrapped (progressive enhancement) and run by the Form in its own transition after hydration. A string is a navigating form.
errorsFormErrors–Record<string, string | readonly string[] | null | undefined> keyed by field name. Shown on the matching Field, dismissed by its next edit, re-armed by the next settled submission.
autoResetbooleantrueReset the form when a submission the Form ran settles with no non-empty entry in errors. false: never.
methodstring–Only with a string action.
classNamestring | ((state: FormRootState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: FormRootState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: FormRootState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((props, state: FormRootState) => ReactNode)–Replace the default <form> (element form) or take over rendering (function form).

FormRootState: { pending }. Owns action, method, onSubmit and data-pending. noValidate and onReset pass through. A render element's own onSubmit runs first; calling preventDefault() in it cancels the submission.

Form.Submit

PropTypeDefaultDescription
disabledbooleanfalseNative disabled. Never set by the library while pending.
classNamestring | ((state: FormSubmitState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: FormSubmitState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: FormSubmitState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((props, state: FormSubmitState) => ReactNode)–Replace the default <button> (element form) or take over rendering (function form).

FormSubmitState: { pending, disabled }, where pending is useFormStatus().pending of the enclosing form; it works in any React form (Dialog.Form, a plain <form action>). Owns type="submit" (on a <button>), disabled, data-pending, data-disabled; name, value and formAction pass through.

Data attributes and pseudo-classes

AttributeOnMeaning
data-invalidevery Field partAn error is shown (FieldState.invalid).
data-requiredevery Field partField.Root required or the control's own required.
data-disabledevery Field part, Form.SubmitDisabled by prop.
data-pendingevery Field part, Form.Root, Form.SubmitThe form's submission is in flight. A FUNSTACK control inside keeps its own data-pending for its own action.
:user-invalid, :user-validthe controlThe platform's; work before hydration. data-invalid adds server errors and the parts that are not the control.

Keyboard and ARIA

Nothing beyond the platform: Tab order is the DOM's, Enter in a text field submits, a click on the label focuses or activates the control. After a failed attempt, assistive technology hears the control, "invalid entry", the description and the error, in that order. aria-errormessage is not used: VoiceOver does not announce it, and aria-describedby works in every screen reader.