f-ui
Components

Form (Formily)

Formily 2.x form toolkit with FormField JSX fields, JSON Schema rendering, and reactive cross-field rules.

Formily wraps Formily 2.x with f-ui chrome: a createForm factory, a declarative FormField for everyday fields, connected f-ui input controls, and SchemaField for backend-driven JSON Schema forms. Cross-field rules (visible, required, value resets) are Formily reactions — no manual invalidation or whole-form re-renders.

It is the f-ui form backbone — reactive fields, JSON Schema, and connected f-ui input controls in one installable Plus package.

Validation cookbook

For FE / BE / hybrid authority, reveal modes, summaries, async uniqueness, and server issue mapping, start at Form Validation. This page keeps kinds, layout, and API reference.

Design Philosophy

Read this section first if you are deciding whether this package fits your app, or wondering when to reach for f-ui vs raw Formily.

What this is

Form (Formily) is f-ui's reactive form stack built on Formily 2.x. It ships as one Plus registry install (components/f-ui/formily/*) and gives you:

  • Presentation — shadcn Field chrome (labels, helpers, errors, horizontal layout) wired as Formily decorators
  • Controls — f-ui inputs (CurrencyInput, NumberInput, DatePicker, Select, …) pre-connected to field state
  • Policies — when errors appear, how server errors map, submit-attempt counting
  • Entry pointsFormField for JSX, SchemaField for JSON Schema, kind for typed shortcuts

What it is not: a engine-neutral form kit. State, validation, and cross-field logic live in Formily — f-ui does not hide or replace that engine.

Position in your app

Your page / feature
├── Business rules, API calls, routing          ← your code
├── @formily/core                               ← form VM (values, validation, effects)
├── @formily/react                              ← Field, ObjectField, ArrayField, connect
└── components/f-ui/formily                     ← f-ui layer (this package)
    ├── Form, FormField, FormItem               ← shell + chrome
    ├── connects/*                              ← shadcn controls ↔ field state
    ├── field-kinds, SchemaField                ← defaults & schema registry
    └── internals/*                             ← reveal policy, server errors, a11y ids

Rule of thumb: use f-ui for how fields look and behave in the UI; use Formily for what the form knows and when it changes. Most apps use both — Form + FormField for 80% of fields, @formily/react directly for arrays, nested objects, and advanced effects.

Why the package is called formily, not form

Three names, three jobs — all intentional:

SurfaceNameWhy
Docs nav & registry titleForm (Formily)User-facing: "this is our Form solution, powered by Formily"
Install path & importscomponents/f-ui/formily/…Honest about the engine — createForm returns a Formily Form, validators and effects are Formily APIs
shadcn's built-in form.tsxnot thisThat component is react-hook-form + Zod; a different stack entirely

Calling the install form would suggest a generic abstraction (like shadcn's RHF form) while every type and runtime behavior still is Formily. The formily path sets the right expectation: you are adopting Formily with f-ui chrome, not swapping engines later without noticing.

What lives where (package map)

PathRoleYou touch it when…
form.tsxFormProvider + <form noValidate> + layout context + locale syncWrapping any form
form-field.tsxJSX sugar over Formily Field + default FormItem decoratorMost fields
form-item.tsxx-decorator: label, *, description, error, orientationCustom decorators or raw Field
field-kinds.tskind="email" → connect + trigger + base rulesRepeated input types with defaults
connects/*connectField(ShadcnControl) — value, a11y, disabledCustom controls (copy the pattern)
schema-field.tsxSchemaField + schemaComponents registryBackend-driven forms
internals/create-form.tscreateForm import pathBootstrapping the VM
internals/reveal-errors.tstouch / submit / always error visibilityVia Form revealErrors prop
internals/form-issues.tsapplyFormIssues / getFormIssues / clearFormIssues + FormErrorSummaryAfter failed submit
internals/form-validator-*Locale sync for built-in format messagesAutomatic under <Form>

FormField and kind are convenience — they save you from repeating decorator={[FormItem, …]} + component={[Input, …]} + base validators on every line. They are not a separate form system.

Thin border, open core

f-ui wraps only where it adds UI or policy. Everything else stays on the public Formily API — import it directly; the Advanced demos do.

f-ui providesUse Formily / your app for
FormItem chrome, layout, densityeffects, onFieldValueChange
connects/* bound to shadcn controlsObjectField, ArrayField, raw Field
Error reveal policy (revealErrors)validator, format, custom rules
FormErrorSummary, submit-attempt counterreactions, visible, required
applyFormIssuesSchema x-reactions, business logic in onSubmit
a11y: connectField, scoped DOM idsi18n inside your own validator callbacks

We are skin + policy, not a facade. You do not need f-ui's permission to import { onFieldValueChange } from "@formily/core".

FormField.access and SchemaField x-access compile to Access-aware reactions — see Access.

Architecture (MVVM)

Formily is the ViewModel — observable field state, validation, async rules. f-ui does not copy that into React useState (the main trap of home-grown form libraries).

LayerImplementation
ViewModelFormily Form / Field
Binderconnects/* via connectFieldvalue / onValueChange + a11y
ViewFormItem layout branches — plain components; observer only at decorator/connect edges
Extensionsf-ui sidecars on the form instance (WeakMap) — submit attempts, reveal policy, alerts. No subclassing Formily

Accessibility

  • FormItem owns label / helper / error DOM and publishes ids through FieldChromeContext.
  • connectField merges aria-describedby in one place (buildDescribedBy).
  • <Form> prefixes every field id with React useId() so duplicate names across forms on one page never collide.
  • Custom connects outside FormItem can reuse nativeFieldA11yProps from connects/shared.

Quick decision guide

I need to…Start here
Build a standard settings / checkout formForm + FormField or kind
Drive fields from JSON Schema / the backendSchemaField; extend with createSchemaField
React to field changes at form levelcreateForm({ effects })@formily/core
Repeatable rows, nested objectsArrayField, ObjectField@formily/react
Drop chrome, keep FormilyField + decorator={[FormItem]} or decorator={null}
A new input type in my appCopy connectField from connects/*; wire a11y via nativeFieldA11yProps

Built-in format messages (e.g. kind="email") follow app locale when <Form> sits under your Paraglide tree. f-ui-owned kind messages (phone E.164) use key form_validation_phone_e164; custom validator strings are yours to translate.

When To Use

Formily pattern is four states. Mix them on one form when the page has system facts, a session lock, and a writable field. Set pattern per field on mixed pages — do not set form-level readPretty when anything must stay a control.

PatternUse whenEmpty value
editable (default)The operator can change this field this sessionBlank control
readOnlyLocked control in an Edit session (this role still cannot change it; concurrent lock). Unlock remounts the same control family. Not the approver inbox default.Blank control — not an em dash
readPrettyNever a control here (created timestamp, computed total, ID, audit) or Display / review until Unlock. FormItem uses Descriptions contrast: muted micro-label, foreground value — not fill-form label weight with the boxes removed.Empty Value Placeholder em dash
disabledTemporarily inapplicable because of a dependencyGrey, skipped in tab order — not a substitute for readOnly
  • Display / Check answers (no Unlock): createForm({ readPretty: true }) so the whole surface is knowledge.
  • Approver inbox (Unlock exists, not yet unlocked): applicant answers and system fields on readPretty; approver-only fields (comment) stay editable. Unlock flips applicant fields to editable. Fiori Display → Edit; Salesforce view → pencil.
  • Edit session with a leftover lock: pretty createdAt + readOnly sku and region + editable comment — see Mixed Pattern.
  • Hosts set field.readOnly, field.pattern = "readOnly", or form readOnly for locked controls. Do not use editable={false} as a lock. Do not paint the approver inbox as a wall of readOnly inputs.
  • Lock mark placement, field vs surface scope, and lockReason: Field Lock Affordance.

Table cells use the same pair: Editable Table — Mixed Pattern.

Installing

Form (Formily) is a Plus component — install through the authenticated @f-ui-plus registry with FUI_PLUS_REGISTRY_TOKEN configured.

pnpm dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/formily.json
npx shadcn@latest add https://ui.isaacfei.com/api/plus/r/formily.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/formily.json
bunx shadcn@latest add https://ui.isaacfei.com/api/plus/r/formily.json

With the @f-ui-plus namespace registered in components.json, shadcn add @f-ui-plus/formily also works and pulls formily-internals plus all connected input packages automatically.

Usage

import { Input } from "@/components/f-ui/formily/connects/input";
import { Form } from "@/components/f-ui/formily/form";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";

const form = useMemo(() => createForm({ initialValues: { email: "" } }), []);

<Form form={form} onSubmit={(values) => save(values)}>
  <FormField
    name="email"
    label="Email"
    required
    component={[Input, { placeholder: "you@example.com" }]}
  />
</Form>;

Connected controls live in formily/connects/* and share names with the components they wrap — alias at the import site when a file needs both (import { Input as ShadcnInput } from "@/components/ui/input").

Examples

Fields & Controls

Order Form

Baseline FormField usage: text, select, textarea, and an inline-label checkbox. Formily's required does not treat false as empty, so boolean confirmations pair required (the * mark) with a small validator.

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Checkbox } from "@/components/f-ui/formily/connects/checkbox";
import { Input } from "@/components/f-ui/formily/connects/input";
import { Select } from "@/components/f-ui/formily/connects/select";
import { Textarea } from "@/components/f-ui/formily/connects/textarea";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

const PRIORITIES = [
  { label: "Standard", value: "standard" },
  { label: "Express", value: "express" },
];

interface OrderValues {
  customer: string;
  priority: string;
  notes: string;
  confirmed: boolean;
}

/** Formily `required` does not treat `false` as empty on boolean fields. */
function validateConfirmed(value: boolean) {
  return value ? "" : "Please confirm the fulfillment policy";
}

export function FormilyDemo() {
  const form = useMemo(
    () =>
      createForm<OrderValues>({
        initialValues: {
          customer: "",
          priority: "standard",
          notes: "",
          confirmed: false,
        },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<OrderValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form form={form} onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}>
        <FormField
          name="customer"
          label="Customer"
          required
          component={[Input, { placeholder: "Acme Corp" }]}
        />
        <FormField
          name="priority"
          label="Priority"
          component={[Select, { options: PRIORITIES }]}
        />
        <FormField
          name="notes"
          label="Notes"
          component={[Textarea, { placeholder: "Optional fulfillment notes" }]}
        />
        <FormField
          name="confirmed"
          label="I confirm the fulfillment policy"
          inlineLabel
          required
          validator={{ validator: validateConfirmed }}
          component={[Checkbox]}
        />
        <FormActions>
          <Button type="submit">
            Submit order
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Mixed Pattern

One form in an Edit session can mix three chromes: Created is system knowledge (readPretty — text, not a picker), SKU and Region are locked fill-in fields (readOnly — Tab still lands on them; you cannot change them), Comment is writable. Every readOnly field carries a muted lock next to its label, so a locked textbox and a locked radio group read the same way; lockReason adds the why as static text under the control. Empty pretty values use an em dash; empty locked and editable controls stay blank. This is not the approver inbox — that page is Display (readPretty) until Unlock remounts applicant fields as controls. A Display page with no Unlock uses form-level readPretty. Full lock chrome matrix: Field Lock Affordance.

Aug 1, 2026

System timestamp — display only

Locked in this Edit session — still a textbox. Approver inbox uses display until Unlock, not this chrome.

Locked for this Edit session.

Read-only

Set by the approval route — reopen the request to change it.

Writable this session

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Input } from "@/components/f-ui/formily/connects/input";
import { Textarea } from "@/components/f-ui/formily/connects/textarea";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

interface ReviewValues {
  createdAt: Date | null;
  sku: string;
  region: string;
  comment: string;
}

export function FormilyMixedPatternDemo() {
  const form = useMemo(
    () =>
      createForm<ReviewValues>({
        initialValues: {
          createdAt: new Date(2026, 7, 1),
          sku: "SKU-104",
          region: "apac",
          comment: "",
        },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<ReviewValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form
        form={form}
        onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}
      >
        <FormField
          name="createdAt"
          label="Created"
          description="System timestamp — display only"
          readPretty
          kind="date"
        />
        <FormField
          name="sku"
          label="SKU"
          description="Locked in this Edit session — still a textbox. Approver inbox uses display until Unlock, not this chrome."
          readOnly
          lockReason="Locked for this Edit session."
          component={[Input]}
        />
        <FormField
          name="region"
          label="Region"
          readOnly
          kind="radio"
          lockReason="Set by the approval route — reopen the request to change it."
          componentProps={{
            options: [
              { label: "APAC", value: "apac" },
              { label: "EMEA", value: "emea" },
            ],
            orientation: "horizontal",
          }}
        />
        <FormField
          name="comment"
          label="Comment"
          description="Writable this session"
          component={[Textarea, { placeholder: "Approver note" }]}
        />
        <FormActions>
          <Button type="submit">
            Submit review
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

F-UI Inputs

Currency Input, Date Picker, Multi-Select, and Color Picker bound through connects — typed values (number | null, Date | null, string[], CSS string | null). Cleared fields submit null, never undefined.

Cleared fields submit null, never undefined

mmddyyyy

kind=color commits a CSS string (#rrggbb / rgba) or null when cleared

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

const TAG_OPTIONS = [
  { label: "Frontend", value: "fe" },
  { label: "Backend", value: "be" },
  { label: "Infra", value: "infra" },
];

interface InputsValues {
  amount: number | null;
  due: Date | null;
  tags: string[];
  brandColor: string | null;
}

export function FormilyInputsDemo() {
  const form = useMemo(
    () =>
      createForm<InputsValues>({
        initialValues: {
          amount: null,
          due: null,
          tags: [],
          brandColor: "#1677ff",
        },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<InputsValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form form={form} onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}>
        <FormField
          name="amount"
          label="Amount"
          description="Cleared fields submit null, never undefined"
          kind="currency"
          componentProps={{ currency: "USD", placeholder: "0.00" }}
        />
        <FormField name="due" label="Due date" kind="date" />
        <FormField
          name="tags"
          label="Tags"
          kind="multiSelect"
          componentProps={{ options: TAG_OPTIONS }}
        />
        <FormField
          name="brandColor"
          label="Brand color"
          kind="color"
          description="kind=color commits a CSS string (#rrggbb / rgba) or null when cleared"
          componentProps={{
            allowClear: true,
            showText: true,
            presets: [
              {
                label: "Brand",
                colors: ["#1677ff", "#52c41a", "#faad14", "#f5222d"],
              },
            ],
          }}
        />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Select

The connected f-ui Select supports static options (see Order Form) and async search via onSearch, with optional triggerSearchOnFocus preload on focus. When editing existing records, pass defaultOptions so the selected label renders before the user searches.

Async onSearch with focus preload; defaultOptions seeds the saved label

"use client";

import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";

import {
  Select,
  type SelectOption,
} from "@/components/f-ui/formily/connects/select";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

/** Static pool — stand in for your user directory API. */
const USERS: SelectOption[] = [
  { value: "u1", label: "Ada Lovelace" },
  { value: "u2", label: "Alan Turing" },
  { value: "u3", label: "Grace Hopper" },
  { value: "u4", label: "Katherine Johnson" },
  { value: "u5", label: "Margaret Hamilton" },
];

function delay(ms: number) {
  return new Promise<void>((resolve) => {
    setTimeout(resolve, ms);
  });
}

interface AssigneeValues {
  assigneeId: string | null;
}

export function FormilySelectDemo() {
  const form = useMemo(
    () =>
      createForm<AssigneeValues>({
        initialValues: { assigneeId: "u2" },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<AssigneeValues | null>(null);

  const searchUsers = useCallback(async (query: string) => {
    await delay(300);
    const q = query.trim().toLowerCase();
    if (!q) return USERS;
    return USERS.filter((user) => user.label.toLowerCase().includes(q));
  }, []);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form form={form} onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}>
        <FormField
          name="assigneeId"
          label="Assignee"
          required
          description="Async onSearch with focus preload; defaultOptions seeds the saved label"
          component={[
            Select,
            {
              onSearch: searchUsers,
              triggerSearchOnFocus: true,
              placeholder: "Search users",
              defaultOptions: [{ value: "u2", label: "Alan Turing" }],
            },
          ]}
        />
        <FormActions>
          <Button type="submit">
            Save assignee
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Field Kinds

kind pairs an input type with its connect component, default validation trigger, and base rules — kind="email" resolves EmailInput plus Formily's built-in format: "email" rule on blur. Pass static control props through componentProps. Custom validators are appended after the kind's base rules (kind rules run first); bare functions inherit the kind's default trigger (onBlur for email). kind and component are mutually exclusive — a compile-time error in TSX (a console warning remains for untyped callers).

Validates on blur with Formily's built-in email format

E.164 — try 5550001234 vs +1 555 000 1234

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

interface ContactValues {
  email: string;
  website: string;
  phone: string;
}

export function FormilyKindsDemo() {
  const form = useMemo(
    () =>
      createForm<ContactValues>({
        initialValues: { email: "", website: "", phone: "" },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<ContactValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form form={form} onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}>
        <FormField
          name="email"
          label="Email"
          required
          kind="email"
          description="Validates on blur with Formily's built-in email format"
        />
        <FormField name="website" label="Website" kind="url" />
        <FormField
          name="phone"
          label="Phone"
          kind="phone"
          description="E.164 — try 5550001234 vs +1 555 000 1234"
        />
        <FormActions>
          <Button type="submit">
            Save contact
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Choice Groups

kind="radio" and kind="checkboxGroup" connect Radio Group and Checkbox Group. Pass options (and optional orientation) through componentProps. Prefer these for short fixed choice lists; use Select / Multi-Select when the list is long or searchable.

Kind names are intentional: radio (not radioGroup), checkboxGroup (not checkbox — that kind is the single boolean Checkbox). Schema enum / default select kinds stay on Select; switch explicitly when you want the visible group.

Empty / clear semantics (read before wiring):

KindEmpty valueUser can clear?Required
radionull (no radio checked)No via the control — reset the field / form, or pick Select if clear-from-trigger mattersFails while still null
checkboxGroup[]Yes — uncheck all optionsFails while still []

Try submit with both empty, then fill them; use Reset fields to put radio back to null and checkbox group back to [].

Starts empty (null). After you pick one, radios cannot clear themselves — Reset field sets null from outside.

Empty is []. Uncheck all options to clear. Required means at least one checked.

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

const METHOD_OPTIONS = [
  { label: "Card", value: "card" },
  { label: "Wire", value: "wire" },
  { label: "Cash", value: "cash" },
];

const CHANNEL_OPTIONS = [
  { label: "Email", value: "email" },
  { label: "SMS", value: "sms" },
  { label: "Push", value: "push" },
];

interface ChoiceGroupValues {
  method: string | null;
  channels: string[];
}

export function FormilyChoiceGroupsDemo() {
  const form = useMemo(
    () =>
      createForm<ChoiceGroupValues>({
        initialValues: {
          method: null,
          channels: [],
        },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<ChoiceGroupValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form
        form={form}
        onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}
      >
        <FormField
          name="method"
          label="Payment method"
          required
          kind="radio"
          description="Starts empty (null). After you pick one, radios cannot clear themselves — Reset field sets null from outside."
          componentProps={{
            options: METHOD_OPTIONS,
            orientation: "horizontal",
          }}
        />
        <FormField
          name="channels"
          label="Channels"
          required
          kind="checkboxGroup"
          description="Empty is []. Uncheck all options to clear. Required means at least one checked."
          componentProps={{
            options: CHANNEL_OPTIONS,
          }}
        />
        <FormActions>
          <Button
            type="button"
            variant="outline"
            onClick={() => {
              form.setValues({ method: null, channels: [] });
              setSubmitted(null);
            }}
          >
            Reset fields
          </Button>
          <Button type="submit">
            Save preferences
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

File Upload

kind="file" connects File Upload via FileUploadControl. Pass transport and gates through componentProps (upload, accept, variant, maxCount, …). The kind’s base rule blocks submit while any item is still uploading. Formily’s required prop only draws the * / aria-required — pair it with validateFileRequired so “required” means at least one item with status === "done".

import { validateFileRequired } from "@/components/f-ui/formily/validators/file-upload";

<FormField
  name="attachments"
  label="Attachments"
  kind="file"
  required
  validator={[{ validator: validateFileRequired }]}
  componentProps={{ multiple: true, upload }}
/>

Required — upload at least one file. Progress is not an error; submit waits until uploads finish.

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import type { FileUploadItem } from "@/components/f-ui/file-upload/file-upload-types";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { validateFileRequired } from "@/components/f-ui/formily/validators/file-upload";
import { Button } from "@/components/ui/button";

import { createFakeUpload } from "./fake-upload";

interface AttachmentValues {
  attachments: FileUploadItem[];
}

const upload = createFakeUpload();

export function FileUploadFormFieldDemo() {
  const form = useMemo(
    () =>
      createForm<AttachmentValues>({
        initialValues: { attachments: [] },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<AttachmentValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form
        form={form}
        onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}
      >
        <FormField
          name="attachments"
          label="Attachments"
          description="Required — upload at least one file. Progress is not an error; submit waits until uploads finish."
          kind="file"
          required
          validator={[{ validator: validateFileRequired }]}
          componentProps={{
            multiple: true,
            upload,
          }}
        />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Rich Text

kind="richText" connects Rich Text Editor for authoring and Rich Text for read-pretty. Defaults to HTML; pass componentProps={{ format: "json" }} for structured persistence. Formily required uses semantic emptiness — empty paragraphs map to undefined, so an empty editor fails required validation without a custom empty-string check.

<FormField
  name="description"
  label="Description"
  kind="richText"
  required
  componentProps={{ placeholder: "Write the description…" }}
/>

Required — empty paragraphs do not count as content.

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

interface BodyValues {
  body?: string;
}

export function RichTextEditorFormFieldDemo() {
  const form = useMemo(
    () =>
      createForm<BodyValues>({
        initialValues: { body: undefined },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<BodyValues | null>(null);

  return (
    <div className="w-full max-w-xl space-y-4">
      <Form
        form={form}
        onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}
      >
        <FormField
          name="body"
          label="Description"
          description="Required — empty paragraphs do not count as content."
          kind="richText"
          required
          componentProps={{
            placeholder: "Write the description…",
            minHeight: 140,
          }}
        />
        <FormActions>
          <Button type="submit">Submit</Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Color Picker

kind="color" connects Color Picker. The field value is a CSS string (#rrggbb or rgba(...)) or null when cleared — never undefined. Pass picker props through componentProps (allowClear, showText, presets, disabledAlpha, …).

<FormField
  name="brandColor"
  label="Brand color"
  kind="color"
  componentProps={{
    allowClear: true,
    showText: true,
    presets: [{ label: "Brand", colors: ["#1677ff", "#52c41a"] }],
  }}
/>

Formily kind=color commits a CSS string (or null when cleared).

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

interface ThemeValues {
  brandColor: string | null;
}

export function ColorPickerFormilyDemo() {
  const form = useMemo(
    () =>
      createForm<ThemeValues>({
        initialValues: { brandColor: "#1677ff" },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<ThemeValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form
        form={form}
        onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}
      >
        <FormField
          name="brandColor"
          label="Brand color"
          kind="color"
          description="Formily kind=color commits a CSS string (or null when cleared)."
          componentProps={{
            allowClear: true,
            showText: true,
            presets: [
              {
                label: "Brand",
                colors: ["#1677ff", "#52c41a", "#faad14", "#f5222d", "#722ed1"],
              },
            ],
          }}
        />
        <FormActions>
          <Button type="submit">
            Save theme
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Stacked Validators

Kind base rules run first; pass validator to append domain or business checks on top. The work email below uses kind="email" (built-in format on blur) plus a custom rule that requires @acme.com — try not-an-email, me@gmail.com, then ada@acme.com. Recipes for hybrid authority and reveal timing: Form Validation.

kind only — built-in format: email on blur

Format first, then @acme.com — try me@gmail.com vs ada@acme.com

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

interface InviteValues {
  personalEmail: string;
  workEmail: string;
}

function acmeDomainValidator(value: string | undefined) {
  if (!value) return "";
  const domain = value.split("@")[1]?.toLowerCase();
  return domain === "acme.com"
    ? ""
    : "Email must be an @acme.com address";
}

export function FormilyStackedValidatorsDemo() {
  const form = useMemo(
    () =>
      createForm<InviteValues>({
        initialValues: { personalEmail: "", workEmail: "" },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<InviteValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form form={form} onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}>
        <FormField
          name="personalEmail"
          label="Personal email"
          kind="email"
          componentProps={{ placeholder: "you@example.com" }}
          description="kind only — built-in format: email on blur"
        />
        <FormField
          name="workEmail"
          label="Work email"
          required
          kind="email"
          componentProps={{ placeholder: "you@acme.com" }}
          description="Format first, then @acme.com — try me@gmail.com vs ada@acme.com"
          validator={acmeDomainValidator}
        />
        <FormActions>
          <Button type="submit">
            Send invite
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Async checks: return a Promise from validator, or { triggerType: "onBlur", validator: async (value) => … }. Multiple rules: validator={[ruleA, ruleB]}. In JSON Schema, keep format: "email" and add "x-validator" for the same constraint.

Layout & Structure

Layout decisions (vocabulary, card defaults, Select width, Submit placement, form-density money) live on Form Layout. This section shows the matching Formily APIs and demos.

Horizontal Layout

Set layout once on Form — fields inherit orientation, the label column width (labelWidth, a CSS variable), and labelAlign (defaults to end in horizontal forms). colon appends : after each label. FormActions offsets the button row into the control column; checkboxes and label-less fields keep the column aligned with an empty label-column spacer.

Use horizontal when you need a shared label | control column on a wide Form Page — not as a substitute for two-column fields in a DetailCard.

Shown on invoices

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Checkbox } from "@/components/f-ui/formily/connects/checkbox";
import { Input } from "@/components/f-ui/formily/connects/input";
import { Select } from "@/components/f-ui/formily/connects/select";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

const REGIONS = [
  { label: "Americas", value: "amer" },
  { label: "EMEA", value: "emea" },
  { label: "APAC", value: "apac" },
];

interface AccountValues {
  accountName: string;
  region: string;
  newsletter: boolean;
}

export function FormilyHorizontalDemo() {
  const form = useMemo(
    () =>
      createForm<AccountValues>({
        initialValues: { accountName: "", region: "amer", newsletter: false },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<AccountValues | null>(null);

  return (
    <div className="w-full max-w-md space-y-4">
      <Form
        form={form}
        orientation="horizontal"
        labelWidth="9rem"
        colon
        onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}
      >
        <FormField
          name="accountName"
          label="Account name"
          required
          description="Shown on invoices"
          component={[Input, { placeholder: "Acme Corp" }]}
        />
        <FormField
          name="region"
          label="Region"
          component={[Select, { options: REGIONS }]}
        />
        <FormField
          name="newsletter"
          label="Subscribe to the newsletter"
          inlineLabel
          component={[Checkbox]}
        />
        <FormActions>
          <Button type="submit">
            Save account
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Control Max Width

controlMaxWidth caps the control column (--fui-form-control-max-width) so inputs don't sprawl across wide settings pages — labels and helpers stay put, controls stop at the cap. Within that column, Text / Select / Textarea stay full width of the column so right edges align (Form Layout — Control Width).

In a two-column field grid, controlMaxWidth is usually unnecessary; each control is w-full of its cell. Only narrow a field when the answer type is intrinsically short (ZIP, ±adjust) — not to hug a Select’s current label.

Controls cap at 20rem even though the form spans the page

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

interface ProfileValues {
  displayName: string;
  bio: string;
}

export function FormilyControlWidthDemo() {
  const form = useMemo(
    () =>
      createForm<ProfileValues>({
        initialValues: { displayName: "", bio: "" },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<ProfileValues | null>(null);

  return (
    <div className="w-full space-y-4">
      <Form
        form={form}
        orientation="horizontal"
        labelWidth="9rem"
        controlMaxWidth="20rem"
        onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}
      >
        <FormField
          name="displayName"
          label="Display name"
          required
          kind="text"
          componentProps={{ placeholder: "Ada Lovelace" }}
          description="Controls cap at 20rem even though the form spans the page"
        />
        <FormField
          name="bio"
          label="Bio"
          kind="textarea"
          componentProps={{ placeholder: "A short introduction", rows: 3 }}
        />
        <FormActions>
          <Button type="submit">
            Save profile
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Sections

Group related fields with FormSection — a named FieldSet wrapper with legend, optional description, and the form's field rhythm baked in. The shell still spaces sections at 1.5× the field gap (--fui-form-field-gap). Pair with horizontal layout and controlMaxWidth for settings-style pages; field paths stay flat unless you nest an ObjectField.

Profile
Contact
Preferences
"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { FormSection } from "@/components/f-ui/formily/form-section";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

const TIMEZONES = [
  { label: "UTC", value: "utc" },
  { label: "America/New_York", value: "america/new_york" },
  { label: "Europe/London", value: "europe/london" },
  { label: "Asia/Tokyo", value: "asia/tokyo" },
];

interface SettingsValues {
  displayName: string;
  jobTitle: string;
  bio: string;
  email: string;
  phone: string;
  website: string;
  timezone: string;
  newsletter: boolean;
}

export function FormilySectionsDemo() {
  const form = useMemo(
    () =>
      createForm<SettingsValues>({
        initialValues: {
          displayName: "",
          jobTitle: "",
          bio: "",
          email: "",
          phone: "",
          website: "",
          timezone: "utc",
          newsletter: false,
        },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<SettingsValues | null>(null);

  return (
    <div className="w-full space-y-4">
      <Form
        form={form}
        orientation="horizontal"
        labelWidth="9rem"
        controlMaxWidth="24rem"
        colon
        onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}
      >
        <FormSection legend="Profile">
          <FormField
            name="displayName"
            label="Display name"
            required
            kind="text"
            componentProps={{ placeholder: "Ada Lovelace" }}
          />
          <FormField
            name="jobTitle"
            label="Job title"
            kind="text"
            componentProps={{ placeholder: "Software engineer" }}
          />
          <FormField
            name="bio"
            label="Bio"
            kind="textarea"
            componentProps={{ placeholder: "A short introduction", rows: 3 }}
          />
        </FormSection>

        <FormSection legend="Contact">
          <FormField
            name="email"
            label="Email"
            required
            kind="email"
            componentProps={{ placeholder: "you@example.com" }}
          />
          <FormField
            name="phone"
            label="Phone"
            kind="phone"
            componentProps={{ placeholder: "+1 (555) 000-0000" }}
          />
          <FormField
            name="website"
            label="Website"
            kind="url"
            componentProps={{ placeholder: "https://example.com" }}
          />
        </FormSection>

        <FormSection legend="Preferences">
          <FormField
            name="timezone"
            label="Timezone"
            kind="select"
            componentProps={{ options: TIMEZONES }}
          />
          <FormField
            name="newsletter"
            label="Product updates by email"
            inlineLabel
            kind="checkbox"
          />
        </FormSection>

        <FormActions>
          <Button type="submit">
            Save settings
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Two-Column Fields

Two-column fields means two fields side by side with vertical labels (label above value) — not Ant labelCol horizontal. Use a CSS grid inside Form; wrap full-width rows (textarea, intentionally long selects) in md:col-span-2. Reuse --fui-form-field-gap for row rhythm.

Default for wide DetailCards with several short fields. Decision table: Form Layout — Vocabulary.

mmddyyyy
"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

interface EmployeeValues {
  firstName: string;
  lastName: string;
  email: string;
  phone: string;
  department: string;
  startDate: Date | null;
  notes: string;
}

const DEPARTMENTS = [
  { label: "Engineering", value: "engineering" },
  { label: "Design", value: "design" },
  { label: "Operations", value: "operations" },
];

export function FormilyTwoColumnDemo() {
  const form = useMemo(
    () =>
      createForm<EmployeeValues>({
        initialValues: {
          firstName: "",
          lastName: "",
          email: "",
          phone: "",
          department: "engineering",
          startDate: null,
          notes: "",
        },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<EmployeeValues | null>(null);

  return (
    <div className="w-full max-w-3xl space-y-4">
      <Form form={form} onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}>
        <div className="grid grid-cols-1 gap-[var(--fui-form-field-gap)] md:grid-cols-2">
          <FormField
            name="firstName"
            label="First name"
            required
            kind="text"
            componentProps={{ placeholder: "Ada" }}
          />
          <FormField
            name="lastName"
            label="Last name"
            required
            kind="text"
            componentProps={{ placeholder: "Lovelace" }}
          />
          <FormField
            name="email"
            label="Work email"
            required
            kind="email"
            componentProps={{ placeholder: "ada@example.com" }}
          />
          <FormField
            name="phone"
            label="Phone"
            kind="phone"
            componentProps={{ placeholder: "+1 (555) 000-0000" }}
          />
          <FormField
            name="department"
            label="Department"
            kind="select"
            componentProps={{ options: DEPARTMENTS }}
          />
          <FormField name="startDate" label="Start date" kind="date" />
          <div className="md:col-span-2">
            <FormField
              name="notes"
              label="Onboarding notes"
              kind="textarea"
              componentProps={{
                placeholder: "Equipment requests, buddy assignment, …",
                rows: 3,
              }}
            />
          </div>
        </div>
        <FormActions>
          <Button type="submit">
            Create employee
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Card Sections

Prefer FormPanel (title/description + baked field-gap) when you need a bordered field group. The demo below uses FormPanel for each section — if you keep raw shadcn Card instead, put the same --fui-form-field-gap on CardContent (or switch to FormPanel).

Region forms in cards: vertical fields (optional two-column grid) and FormActions align="end"Form Layout — Surface Defaults.

Company

Legal identity shown on invoices

Billing contacts

Used for invoices and payment notices

Defaults

Applied to new invoices

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { FormPanel } from "@/components/f-ui/formily/form-panel";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

interface OrganizationValues {
  legalName: string;
  taxId: string;
  billingEmail: string;
  supportPhone: string;
  invoicePrefix: string;
  defaultCurrency: string;
}

const CURRENCIES = [
  { label: "USD", value: "usd" },
  { label: "EUR", value: "eur" },
  { label: "GBP", value: "gbp" },
];

export function FormilyCardSectionsDemo() {
  const form = useMemo(
    () =>
      createForm<OrganizationValues>({
        initialValues: {
          legalName: "",
          taxId: "",
          billingEmail: "",
          supportPhone: "",
          invoicePrefix: "INV",
          defaultCurrency: "usd",
        },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<OrganizationValues | null>(null);

  return (
    <div className="w-full max-w-2xl space-y-4">
      <Form form={form} onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}>
        <FormPanel
          title="Company"
          description="Legal identity shown on invoices"
        >
          <FormField
            name="legalName"
            label="Legal name"
            required
            kind="text"
            componentProps={{ placeholder: "Acme Corporation" }}
          />
          <FormField
            name="taxId"
            label="Tax ID"
            kind="text"
            componentProps={{ placeholder: "12-3456789" }}
          />
        </FormPanel>

        <FormPanel
          title="Billing contacts"
          description="Used for invoices and payment notices"
        >
          <FormField
            name="billingEmail"
            label="Billing email"
            required
            kind="email"
            componentProps={{ placeholder: "billing@example.com" }}
          />
          <FormField
            name="supportPhone"
            label="Support phone"
            kind="phone"
            componentProps={{ placeholder: "+1 (555) 000-0000" }}
          />
        </FormPanel>

        <FormPanel
          title="Defaults"
          description="Applied to new invoices"
        >
          <FormField
            name="invoicePrefix"
            label="Invoice prefix"
            kind="text"
            componentProps={{ placeholder: "INV" }}
          />
          <FormField
            name="defaultCurrency"
            label="Currency"
            kind="select"
            componentProps={{ options: CURRENCIES }}
          />
        </FormPanel>

        <FormActions>
          <Button type="submit">
            Save organization
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Panel + SchemaField

Preferred bordered path: FormSection + FormPanel + SchemaField. FormPanel content is the field-gap host — do not wrap SchemaField in an extra flex/gap div unless you opted out with gap={false}.

Account

Basics

FormPanel content owns field rhythm — no hand-rolled gap wrapper.

Privileges
"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormPanel } from "@/components/f-ui/formily/form-panel";
import { FormSection } from "@/components/f-ui/formily/form-section";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { SchemaField } from "@/components/f-ui/formily/schema-field";
import { Button } from "@/components/ui/button";

const ROLE_OPTIONS = [
  { label: "Member", value: "member" },
  { label: "Admin", value: "admin" },
];

const basicsSchema = {
  type: "object",
  properties: {
    email: {
      type: "string",
      title: "Email",
      required: true,
      format: "email",
      "x-decorator": "FormItem",
      "x-component": "EmailInput",
      "x-component-props": { placeholder: "you@example.com" },
    },
    role: {
      type: "string",
      title: "Role",
      "x-decorator": "FormItem",
      "x-component": "Select",
      "x-component-props": { options: ROLE_OPTIONS },
    },
  },
};

const accessSchema = {
  type: "object",
  properties: {
    adminCode: {
      type: "string",
      title: "Admin code",
      description: "Visible and required only for admins",
      "x-decorator": "FormItem",
      "x-component": "Input",
      "x-component-props": { placeholder: "1234", autoComplete: "off" },
      "x-reactions": {
        dependencies: ["role"],
        fulfill: {
          state: {
            visible: '{{$deps[0] === "admin"}}',
            required: '{{$deps[0] === "admin"}}',
          },
        },
      },
    },
  },
};

interface PanelSchemaValues {
  email: string;
  role: string;
  adminCode?: string;
}

export function FormilyPanelSchemaDemo() {
  const form = useMemo(
    () =>
      createForm<PanelSchemaValues>({
        initialValues: { email: "", role: "member" },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<PanelSchemaValues | null>(null);

  return (
    <div className="w-full max-w-lg space-y-4">
      <Form
        form={form}
        onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}
      >
        <FormSection legend="Account">
          <FormPanel
            title="Basics"
            description="FormPanel content owns field rhythm — no hand-rolled gap wrapper."
          >
            <SchemaField schema={basicsSchema} />
          </FormPanel>
        </FormSection>
        <FormSection legend="Privileges">
          <FormPanel variant="plain">
            <SchemaField schema={accessSchema} />
          </FormPanel>
        </FormSection>
        <FormActions>
          <Button type="submit">Submit</Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Settings Page Layout

Combine card sections with nested two-column grids for admin-style pages: an outer grid places cards (xl:grid-cols-2), each CardContent holds an inner md:grid-cols-2 field grid. density="compact" and FormActions align="end" match common save/cancel footers.

Workspace
Name and URL shown to members

Used in workspace links

Plan & integrations
Subscription and outbound hooks
"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";

interface WorkspaceSettingsValues {
  workspaceName: string;
  workspaceSlug: string;
  ownerName: string;
  ownerEmail: string;
  billingEmail: string;
  plan: string;
  seats: string;
  apiDomain: string;
  webhookUrl: string;
  auditLog: boolean;
}

const PLANS = [
  { label: "Starter", value: "starter" },
  { label: "Business", value: "business" },
  { label: "Enterprise", value: "enterprise" },
];

export function FormilySettingsPageDemo() {
  const form = useMemo(
    () =>
      createForm<WorkspaceSettingsValues>({
        initialValues: {
          workspaceName: "",
          workspaceSlug: "",
          ownerName: "",
          ownerEmail: "",
          billingEmail: "",
          plan: "starter",
          seats: "5",
          apiDomain: "",
          webhookUrl: "",
          auditLog: true,
        },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<WorkspaceSettingsValues | null>(
    null,
  );

  return (
    <div className="w-full space-y-4">
      <Form
        form={form}
        density="compact"
        onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}
      >
        <div className="grid grid-cols-1 gap-[var(--fui-form-field-gap)] xl:grid-cols-2">
          <Card className="h-fit">
            <CardHeader>
              <CardTitle>Workspace</CardTitle>
              <CardDescription>Name and URL shown to members</CardDescription>
            </CardHeader>
            <CardContent>
              <div className="grid grid-cols-1 gap-[var(--fui-form-field-gap)] md:grid-cols-2">
                <div className="md:col-span-2">
                  <FormField
                    name="workspaceName"
                    label="Workspace name"
                    required
                    kind="text"
                    componentProps={{ placeholder: "Apollo" }}
                  />
                </div>
                <FormField
                  name="workspaceSlug"
                  label="URL slug"
                  required
                  kind="text"
                  description="Used in workspace links"
                  componentProps={{ placeholder: "apollo" }}
                />
                <FormField
                  name="ownerName"
                  label="Owner name"
                  kind="text"
                  componentProps={{ placeholder: "Ada Lovelace" }}
                />
                <FormField
                  name="ownerEmail"
                  label="Owner email"
                  required
                  kind="email"
                  componentProps={{ placeholder: "ada@example.com" }}
                />
                <FormField
                  name="billingEmail"
                  label="Billing email"
                  kind="email"
                  componentProps={{ placeholder: "billing@example.com" }}
                />
              </div>
            </CardContent>
          </Card>

          <Card className="h-fit">
            <CardHeader>
              <CardTitle>Plan &amp; integrations</CardTitle>
              <CardDescription>Subscription and outbound hooks</CardDescription>
            </CardHeader>
            <CardContent>
              <div className="grid grid-cols-1 gap-[var(--fui-form-field-gap)] md:grid-cols-2">
                <FormField
                  name="plan"
                  label="Plan"
                  kind="select"
                  componentProps={{ options: PLANS }}
                />
                <FormField
                  name="seats"
                  label="Seats"
                  kind="text"
                  componentProps={{ placeholder: "5", inputMode: "numeric" }}
                />
                <FormField
                  name="apiDomain"
                  label="API domain"
                  kind="url"
                  componentProps={{ placeholder: "https://api.example.com" }}
                />
                <FormField
                  name="webhookUrl"
                  label="Webhook URL"
                  kind="url"
                  componentProps={{ placeholder: "https://hooks.example.com" }}
                />
                <div className="md:col-span-2">
                  <FormField
                    name="auditLog"
                    label="Retain audit log for 90 days"
                    inlineLabel
                    kind="checkbox"
                  />
                </div>
              </div>
            </CardContent>
          </Card>
        </div>

        <FormActions align="end">
          <Button type="button" variant="outline">
            Cancel
          </Button>
          <Button type="submit">
            Save changes
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Dialog Actions & Density

Vertical forms own dialogs and wizards: density="compact" tightens the field gap (--fui-form-field-gap), and FormActions defaults to align="end" for a dialog-style footer row. The same default applies to DetailCard region forms — do not pass align="start" on a product footer. Field rhythm lives on gap hostsForm's FieldGroup, FormSection, and FormPanel content (default). Do not hand-roll space-y-* on those hosts. Any other wrapper (div, raw CardContent, custom layout) must either use a gap host or set className="flex flex-col gap-[var(--fui-form-field-gap)]".

New project

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Input } from "@/components/f-ui/formily/connects/input";
import { Select } from "@/components/f-ui/formily/connects/select";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { FormPanel } from "@/components/f-ui/formily/form-panel";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

const VISIBILITY = [
  { label: "Private", value: "private" },
  { label: "Team", value: "team" },
  { label: "Public", value: "public" },
];

interface ProjectValues {
  name: string;
  visibility: string;
}

export function FormilyVerticalDialogDemo() {
  const form = useMemo(
    () =>
      createForm<ProjectValues>({
        initialValues: { name: "", visibility: "private" },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<ProjectValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form
        form={form}
        density="compact"
        onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}
      >
        <FormPanel title="New project">
          <FormField
            name="name"
            label="Project name"
            required
            component={[Input, { placeholder: "Apollo" }]}
          />
          <FormField
            name="visibility"
            label="Visibility"
            component={[Select, { options: VISIBILITY }]}
          />
          <FormActions>
            <Button
              type="button"
              variant="outline"
              onClick={() => void form.reset()}
            >
              Cancel
            </Button>
            <Button type="submit">Create</Button>
          </FormActions>
        </FormPanel>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Behavior & Data

Cross-Field Reactions

reactions reads sibling paths through the field graph: adminCode is visible and required only while role is admin, and its stale async error disappears when the role flips back. Async validators swap the description for validatingDescription and set aria-busy while running.

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Input } from "@/components/f-ui/formily/connects/input";
import { Select } from "@/components/f-ui/formily/connects/select";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

const ROLE_OPTIONS = [
  { label: "Member", value: "member" },
  { label: "Admin", value: "admin" },
];

const ASYNC_DELAY_MS = 300;

interface InviteValues {
  name: string;
  role: "member" | "admin";
  adminCode?: string;
}

async function validateAdminCode(value: string | undefined) {
  await new Promise((resolve) => setTimeout(resolve, ASYNC_DELAY_MS));
  if (!value) return "";
  return value === "1234" ? "" : "Unknown admin code";
}

export function FormilyReactionsDemo() {
  const form = useMemo(
    () =>
      createForm<InviteValues>({
        initialValues: { name: "", role: "member" },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<InviteValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form form={form} onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}>
        <FormField
          name="name"
          label="Name"
          required
          component={[Input, { placeholder: "Ada Lovelace" }]}
        />
        <FormField
          name="role"
          label="Role"
          required
          component={[Select, { options: ROLE_OPTIONS }]}
        />
        <FormField
          name="adminCode"
          label="Admin code"
          description="Required for admins. Hint: 1234"
          validatingDescription="Checking code…"
          component={[Input, { placeholder: "1234", autoComplete: "off" }]}
          reactions={(field) => {
            const isAdmin = field.query(".role").value() === "admin";
            field.visible = isAdmin;
            field.required = isAdmin;
          }}
          validator={{ triggerType: "onBlur", validator: validateAdminCode }}
        />
        <FormActions>
          <Button type="submit">
            Send invite
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Server Errors

applyFormIssues maps API rejection issues onto the form: matched paths become always-visible field errors that auto-clear when the user edits the field; unmatched paths stay as field-intent issues in the summary and clear on the next submit attempt. Full server-map and hybrid recipes: Form Validation.

Server rejects every email; edit the field to clear the error

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Input } from "@/components/f-ui/formily/connects/input";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { FormIssue } from "@/components/f-ui/formily/internals/form-issue";
import { applyFormIssues } from "@/components/f-ui/formily/internals/form-issues";
import { Button } from "@/components/ui/button";

const API_DELAY_MS = 400;

interface SignupValues {
  email: string;
  username: string;
}

/** Pretend API: rejects every submit with one field error and one form-level error. */
async function fakeSignup(): Promise<FormIssue[]> {
  await new Promise((resolve) => setTimeout(resolve, API_DELAY_MS));
  return [
    { source: "server", path: "email", message: "Email is already registered." },
    {
      source: "server",
      path: "captcha",
      message: "Captcha session expired — retry the submit.",
    },
  ];
}

export function FormilyServerErrorsDemo() {
  const form = useMemo(
    () =>
      createForm<SignupValues>({
        initialValues: { email: "", username: "" },
      }),
    [],
  );
  const [attempts, setAttempts] = useState(0);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form
        form={form}
        onSubmit={async () => {
          const issues = await fakeSignup();
          applyFormIssues(form, issues);
          setAttempts((n) => n + 1);
          toast.error("Server returned errors");
        }}
      >
        <FormErrorSummary scope="all" />
        <FormField
          name="email"
          label="Email"
          required
          kind="email"
          description="Server rejects every email; edit the field to clear the error"
          componentProps={{ placeholder: "you@example.com" }}
        />
        <FormField
          name="username"
          label="Username"
          required
          component={[Input, { placeholder: "lovelace" }]}
        />
        <FormActions>
          <Button type="submit">
            Sign up{attempts > 0 ? ` (attempt ${attempts + 1})` : ""}
          </Button>
        </FormActions>
      </Form>
    </div>
  );
}

JSON Schema

SchemaField renders a backend-driven field tree from JSON Schema. title maps to the FormItem label, description to the helper text, and x-reactions {{...}} expressions compile to the same reactive rules as JSX reactions. The built-in x-component registry covers every connected control, and validation shares rule names with Field Kinds: format: "email" + "x-component": "EmailInput" in Schema is the same contract as kind="email" in JSX — native Formily mechanisms, no magic injection.

"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { SchemaField } from "@/components/f-ui/formily/schema-field";
import { Button } from "@/components/ui/button";

const ROLE_OPTIONS = [
  { label: "Member", value: "member" },
  { label: "Admin", value: "admin" },
];

const schema = {
  type: "object",
  properties: {
    email: {
      type: "string",
      title: "Email",
      required: true,
      format: "email",
      "x-decorator": "FormItem",
      "x-component": "EmailInput",
      "x-component-props": { placeholder: "you@example.com" },
    },
    role: {
      type: "string",
      title: "Role",
      "x-decorator": "FormItem",
      "x-component": "Select",
      "x-component-props": { options: ROLE_OPTIONS },
    },
    adminCode: {
      type: "string",
      title: "Admin code",
      description: "Visible and required only for admins",
      "x-decorator": "FormItem",
      "x-component": "Input",
      "x-component-props": { placeholder: "1234", autoComplete: "off" },
      "x-reactions": {
        dependencies: ["role"],
        fulfill: {
          state: {
            visible: '{{$deps[0] === "admin"}}',
            required: '{{$deps[0] === "admin"}}',
          },
        },
      },
    },
  },
};

interface SchemaValues {
  email: string;
  role: string;
  adminCode?: string;
}

export function FormilySchemaDemo() {
  const form = useMemo(
    () =>
      createForm<SchemaValues>({
        initialValues: { email: "", role: "member" },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<SchemaValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form form={form} onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}>
        <SchemaField schema={schema} />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Dynamic Arrays

ArrayField binds a collection path; each row is an ObjectField with the same FormField + FormItem chrome as scalar fields. Add and remove rows with arrayField.push / arrayField.remove — there is no FormFieldArray sugar on the Formily side.

Line items
"use client";

import { useMemo, useState } from "react";
import { ArrayField, ObjectField } from "@formily/react";
import { toast } from "sonner";

import { Input } from "@/components/f-ui/formily/connects/input";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { FieldLegend, FieldSet } from "@/components/ui/field";

const SAVE_DELAY_MS = 600;

const lineItemDefaults = { sku: "", qty: 1 };

type OrderValues = {
  lineItems: (typeof lineItemDefaults)[];
};

export function FormilyArrayDemo() {
  const form = useMemo(
    () =>
      createForm<OrderValues>({
        initialValues: { lineItems: [{ ...lineItemDefaults }] },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<OrderValues | null>(null);

  return (
    <div className="w-full max-w-md space-y-4">
      <Form
        form={form}
        onSubmit={async (value) => {
          await new Promise((resolve) => setTimeout(resolve, SAVE_DELAY_MS));
          setSubmitted(value);
          toast.success("Submitted successfully");
        }}
      >
        <FieldSet className="space-y-3">
          <FieldLegend>Line items</FieldLegend>
          <ArrayField name="lineItems">
            {(arrayField) => (
              <div className="space-y-3">
                {arrayField.value?.map((_, index) => (
                  <div
                    key={index}
                    className="flex gap-2"
                  >
                    <ObjectField name={index}>
                      <div className="grid flex-1 grid-cols-2 gap-2">
                        <FormField
                          name="sku"
                          label="SKU"
                          required
                          component={[Input, { placeholder: "SKU-001" }]}
                        />
                        <FormField
                          name="qty"
                          label="Qty"
                          kind="number"
                          componentProps={{ min: 1, step: 1 }}
                        />
                      </div>
                    </ObjectField>
                    <Button
                      type="button"
                      variant="outline"
                      className="self-end"
                      disabled={(arrayField.value?.length ?? 0) <= 1}
                      onClick={() => arrayField.remove(index)}
                    >
                      Remove
                    </Button>
                  </div>
                ))}
                <Button
                  type="button"
                  variant="secondary"
                  disabled={(arrayField.value?.length ?? 0) >= 5}
                  onClick={() => arrayField.push({ ...lineItemDefaults })}
                >
                  Add line item
                </Button>
              </div>
            )}
          </ArrayField>
        </FieldSet>
        <FormActions>
          <Button type="submit">
            Save order
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Advanced

Escape hatches for Formily capabilities f-ui does not sugar-wrap. For everyday fields, prefer FormField + kind; use these patterns when you need form-wide effects, extended Schema registries, nested object paths, or full decorator control.

When to use what

NeedUse
Everyday field + chromeFormField + kind
Custom control, one-offFormField + component
Recurring custom typeYour own *Field wrapper (see Custom Inputs below)
Backend-driven treeSchemaField / extended createSchemaField
Form-wide side effectscreateForm({ effects })
Dynamic collectionsArrayField / ObjectField (see Dynamic Arrays above)
Full decorator controlRaw FormilyField + [FormItem, props]

Form Effects

Form-level effects subscribe to the field graph once — useful for derived values that touch multiple paths without repeating reactions on every field. Compare with Cross-Field Reactions (per-field reactions).

USD 0.00

Derived by form effects — not a per-field reaction

"use client";

import { onFieldValueChange } from "@formily/core";
import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

interface InvoiceValues {
  quantity: number;
  unitPrice: number;
  lineTotal: number;
}

function recalcLineTotal(form: ReturnType<typeof createForm<InvoiceValues>>) {
  const qty = Number(form.values.quantity) || 0;
  const price = Number(form.values.unitPrice) || 0;
  form.setValuesIn("lineTotal", qty * price);
}

export function FormilyEffectsDemo() {
  const form = useMemo(
    () =>
      createForm<InvoiceValues>({
        initialValues: { quantity: 1, unitPrice: 0, lineTotal: 0 },
        effects(form) {
          form.setFieldState("lineTotal", (state) => {
            state.pattern = "readPretty";
          });
          onFieldValueChange("quantity", () => recalcLineTotal(form));
          onFieldValueChange("unitPrice", () => recalcLineTotal(form));
        },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<InvoiceValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form form={form} onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}>
        <FormField
          name="quantity"
          label="Quantity"
          required
          kind="number"
          componentProps={{ min: 1, step: 1 }}
        />
        <FormField
          name="unitPrice"
          label="Unit price"
          required
          kind="currency"
          componentProps={{ currency: "USD" }}
        />
        <FormField
          name="lineTotal"
          label="Line total"
          kind="currency"
          componentProps={{ currency: "USD" }}
          description="Derived by form effects — not a per-field reaction"
        />
        <FormActions>
          <Button type="submit">
            Save invoice
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Schema Extension

Extend the built-in registry in your code — never edit vendored schema-field.tsx. Spread schemaComponents and register user connects (see also Custom Inputs).

"use client";

import { connect, createSchemaField, mapProps } from "@formily/react";
import type { ComponentProps } from "react";
import { useMemo, useState } from "react";
import { toast } from "sonner";

import { nativeFieldA11yProps } from "@/components/f-ui/formily/connects/shared";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import {
  schemaComponents,
} from "@/components/f-ui/formily/schema-field";
import { Input as ShadcnInput } from "@/components/ui/input";
import { Button } from "@/components/ui/button";

/**
 * User-owned connect — mirrors what an app would add beside vendored f-ui.
 * Not part of schemaComponents; registered only in ExtendedSchemaField below.
 */
const PasswordInput = connect(
  ShadcnInput,
  mapProps((props, field) => {
    const inputProps = props as ComponentProps<typeof ShadcnInput>;
    return {
      ...inputProps,
      type: "password",
      autoComplete: inputProps.autoComplete ?? "new-password",
      ...nativeFieldA11yProps(field, {
        id: inputProps.id,
        "aria-describedby": inputProps["aria-describedby"],
        disabled: inputProps.disabled,
        readOnly: inputProps.readOnly,
      }),
    };
  }),
);

const ExtendedSchemaField = createSchemaField({
  components: { ...schemaComponents, PasswordInput },
});

const schema = {
  type: "object",
  properties: {
    email: {
      type: "string",
      title: "Email",
      required: true,
      format: "email",
      "x-decorator": "FormItem",
      "x-component": "EmailInput",
      "x-component-props": { placeholder: "you@example.com" },
    },
    password: {
      type: "string",
      title: "Password",
      required: true,
      minLength: 8,
      "x-decorator": "FormItem",
      "x-component": "PasswordInput",
      "x-component-props": { placeholder: "At least 8 characters" },
    },
  },
};

interface SignupValues {
  email: string;
  password: string;
}

export function FormilySchemaExtensionDemo() {
  const form = useMemo(
    () =>
      createForm<SignupValues>({
        initialValues: { email: "", password: "" },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<SignupValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form form={form} onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}>
        <ExtendedSchemaField schema={schema} />
        <FormActions>
          <Button type="submit">
            Create account
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Nested Schema Objects

Nested JSON Schema paths (contact.email) compose with ObjectField for the prefix and FormSection for layout chrome. Leaf fields can stay Schema-driven inside the scoped subtree.

Contact

Nested paths: contact.email, contact.phone

"use client";

import { ObjectField } from "@formily/react";
import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { FormSection } from "@/components/f-ui/formily/form-section";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { SchemaField } from "@/components/f-ui/formily/schema-field";
import { Button } from "@/components/ui/button";

const contactFieldsSchema = {
  type: "object",
  properties: {
    email: {
      type: "string",
      title: "Email",
      required: true,
      format: "email",
      "x-decorator": "FormItem",
      "x-component": "EmailInput",
      "x-component-props": { placeholder: "you@example.com" },
    },
    phone: {
      type: "string",
      title: "Phone",
      "x-decorator": "FormItem",
      "x-component": "PhoneInput",
      "x-component-props": { placeholder: "+1 (555) 000-0000" },
    },
  },
};

interface ProfileValues {
  displayName: string;
  contact: {
    email: string;
    phone: string;
  };
}

export function FormilySchemaNestedDemo() {
  const form = useMemo(
    () =>
      createForm<ProfileValues>({
        initialValues: {
          displayName: "",
          contact: { email: "", phone: "" },
        },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<ProfileValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form form={form} onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}>
        <FormField
          name="displayName"
          label="Display name"
          required
          kind="text"
          componentProps={{ placeholder: "Ada Lovelace" }}
        />
        <ObjectField name="contact">
          <FormSection
            legend="Contact"
            description="Nested paths: contact.email, contact.phone"
          >
            <SchemaField schema={contactFieldsSchema} />
          </FormSection>
        </ObjectField>
        <FormActions>
          <Button type="submit">
            Save profile
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Raw Field

FormField is sugar over Formily Field + FormItem. Drop to raw FormilyField when you need decorator props FormField does not forward, void fields, or non-standard field types.

Everyday sugar — maps label to field.title

Drop sugar when you need full decorator / field props

"use client";

import { Field as FormilyField } from "@formily/react";
import { useMemo, useState } from "react";
import { toast } from "sonner";

import { Input } from "@/components/f-ui/formily/connects/input";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { FormItem } from "@/components/f-ui/formily/form-item";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

interface CompareValues {
  viaFormField: string;
  viaRawField: string;
}

export function FormilyRawFieldDemo() {
  const form = useMemo(
    () =>
      createForm<CompareValues>({
        initialValues: { viaFormField: "", viaRawField: "" },
      }),
    [],
  );
  const [submitted, setSubmitted] = useState<CompareValues | null>(null);

  return (
    <div className="w-full max-w-sm space-y-4">
      <Form form={form} onSubmit={(values) => {
          setSubmitted({ ...values });
          toast.success("Submitted successfully");
        }}>
        <FormField
          name="viaFormField"
          label="Via FormField"
          description="Everyday sugar — maps label to field.title"
          kind="text"
          componentProps={{ placeholder: "FormField path" }}
        />
        <FormilyField
          name="viaRawField"
          title="Via Formily Field"
          description="Drop sugar when you need full decorator / field props"
          decorator={[
            FormItem,
            {
              label: "Via Formily Field",
              description:
                "Drop sugar when you need full decorator / field props",
            },
          ]}
          component={[Input, { placeholder: "FormilyField path" }]}
        />
        <FormActions>
          <Button type="submit">
            Compare submit
          </Button>
        </FormActions>
      </Form>

      {submitted ? (
        <pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(submitted, null, 2)}</code>
        </pre>
      ) : null}
    </div>
  );
}

Composition

Form                          ← FormProvider + <form noValidate> + layout context + FieldGroup rhythm
├── FormErrorSummary          ← root + field issue summary with navigation links
├── FormField                 ← Formily Field + decorator=[FormItem, chrome]
│   ├── FormItem              ← x-decorator: label column, *, description, error
│   └── connects/*            ← x-component: value + onValueChange only
├── FormActions               ← button row; auto-offsets into the control column
├── FormPanel                 ← surface + default field-gap content host (gap={false} to opt out)
├── FormSection               ← FieldSet + legend/description + field rhythm
├── Card (+ Header / Content) ← bordered section panels
├── CSS grid (1–2 columns)    ← compose inside Form or CardContent; span full width when needed
├── SchemaField               ← JSON Schema tree over the same registry
└── Advanced (docs)           ← effects, Schema extension, nested objects, raw Field

For full control use Formily Field / ObjectField / ArrayField directly with decorator={[FormItem]}FormField is sugar, not a wall. Each <Form> scopes field DOM ids via React useId() so duplicate field names across forms on one page stay a11y-safe. See Dynamic Arrays for ArrayField line items and Advanced for effects, Schema extension, and raw Field.

API Reference

Props

Form

PropTypeDefaultDescription
formForm<T>From createForm
onSubmit(values: T) => FormSubmitResult | Promise<FormSubmitResult>Called after validation passes; void = success; { status: "error", issues } = known rejection
onInvalid() => voidCalled when Formily validation fails before onSubmit
onSubmitError(error: unknown) => voidCalled for unexpected throws; a root system issue is also applied
focusOnInvalid"summary" | "first-field" | falsefalseFocus strategy after invalid submit / known submit errors
revealErrors"touch" | "submit" | "always""submit"When invalid-field errors become visible. submit (default): after failed Submit, then inline recovery. touch: on blur (empty required included), then clear-as-you-fix. always: live (password / character count). First keystroke on a fresh field never paints under submit/touch.
orientation"vertical" | "horizontal" | "responsive""vertical"Layout for all fields; field-level orientation overrides
labelWidthCSS length10remHorizontal label column width (--fui-form-label-width)
labelAlign"start" | "end""end" horizontal/responsive, "start" verticalHorizontal label alignment
density"comfortable" | "compact""comfortable"Field row gap: 1.5rem / 1rem (--fui-form-field-gap)
colonbooleanfalseAppends : after horizontal labels; ignored in vertical
controlMaxWidthCSS lengthCaps the control column (--fui-form-control-max-width)

FormField — thin wrapper over Formily Field; all Formily field props (name, component, validator, reactions, initialValue, required, …) pass through.

PropTypeDefaultDescription
nameFormily pathField path (user.email, items[2].name)
component[Component, props?]Connected control + static props
kindFieldKindNameResolves component + base validators (text, email, url, phone, password, number, currency, date, dateRange, time, select, multiSelect, checkbox, switch, radio, checkboxGroup, file, color); mutually exclusive with component (compile-time)
componentPropstyped per kindProps for the kind's component, typed from its connect; only valid together with kind
label / descriptionReactNodeFormItem chrome
validatingDescriptionReactNodeReplaces description while async validators run
requiredboolean* mark + aria-required; default message is {label} is required (field and Banner match). Host validator / explicit required message wins. Reactions can set required dynamically
inlineLabelbooleanfalseCheckbox-style label beside the control
orientation"vertical" | "horizontal" | "responsive"from FormPer-field override
hideHelperOnErrorbooleanfalseVisually hide helper while an error shows (kept in aria-describedby)
validatorFormily validatorSync/async; with kind, appended after kind base rules; bare functions inherit the kind's default trigger
reactions(field) => voidJSX reactions are functions; {{...}} strings are Schema-only
decoratorFormily decorator[FormItem, chrome]Override (or null) for chrome-less fields

FormErrorSummary

Mount when the operator needs an index of non-table problems after a failed Submit. Turn it off when the only problems are table cells. Use scope="form" only for path-less system copy. The Banner card (bg-card, 1px border) is shared with the table region rollup — different owners, not a second pink Alert. Full matrix: Form Validation.

PropTypeDefaultDescription
scope"all" | "form""all""all" — root + field issues; "form" — root issues only
classNamestringBanner container class
onNavigate(issue: FormIssue) => voidHeadless hook when a field issue link is activated
titleReactNode"There is a problem"Summary heading

Scope recipe: scope="form" keeps only root/system issues (issue.path == null) — not header fields or array-root paths like lineItems. After cell-owner registration, scope="all" drops owned cell descendants (lineItems.*) but still lists header paths and the array-root (lineItems). Table-heavy pages with header or array-root rules: keep scope="all" (FormPage default) so the kit strips cells while the summary still surfaces non-cell issues. Pure cell errors only (rollup owns the aggregate): errorSummary={false} on FormPage, or mount scope="form" for system/root only. Full surface matrix: Object Messaging And Table Chrome.

FormActions

PropTypeDefaultDescription
align"start" | "end""end"Button alignment inside the control column. Default end for DetailCard, dialog, and settings footers — Form Layout — Form Actions Placement. Pass start only for a Wrong demo.
offsetbooleanautoOverride label-column offset; unset auto-offsets for horizontal/responsive forms; false for full-width filter/toolbar bars
classNamestringRow container class

Action row buttons use default size (h-8) — do not pass size="sm".

FormPanel

PropTypeDefaultDescription
variant"card" | "plain""card"card renders the shared panel token (bg-card rounded-xl border p-4); plain is borderless for embedding
title / descriptionReactNodeOptional header above the content host (mb-5); not inside the field-gap flex
gapbooleantrueWhen true, content is flex flex-col with --fui-form-field-gap; set false for a custom layout child
contentClassNamestringMerged onto data-slot="form-panel-content"
classNamestringMerged onto the panel surface (data-slot="form-panel")
headerClassNamestringMerged onto the header block when title/description set

FormSection

PropTypeDefaultDescription
legendReactNodeSection title (FieldLegend)
descriptionReactNodeHelper copy under the legend (FieldDescription)
classNamestringMerged onto the underlying FieldSet

Connected Controls

formily/connects/* exports Input, Textarea, Select, Checkbox, Switch, RadioGroup, CheckboxGroup, CurrencyInput, NumberInput, DatePicker, DateRangePicker, TimeInput, MultiSelect, EmailInput, UrlInput, PhoneInput, PasswordInput, FileUpload, ColorPicker. Each maps Formily field state onto the control: value/onValueChange round-trip, reveal-gated aria-invalid, aria-busy while validating, disabled/readOnly, and aria-labelledby for composite controls. Cleared nullable values commit null, never undefined. The connected Select forwards the full f-ui Select API — static options, async onSearch, and triggerSearchOnFocus preload; pass defaultOptions when editing records so the current value's label renders before search. FileUpload round-trips FileUploadItem[] (empty = []). NumberInput commits number | null with Open default commitBehavior="validate".

Field Kinds Reference

KindConnectTriggerBase rule
text / textareaInput / TextareaonInput
emailEmailInputonBlurFormily built-in format: "email"
urlUrlInputonBlurFormily built-in format: "url"
phonePhoneInputonBlurCustom E.164 check — message key form_validation_phone_e164 (not Formily's CN phone format)
passwordPasswordInputonBlur— (no built-in validator; add app rules for strength or policy)
numberNumberInputonInput— (number | null; empty clears to null. Prefer this over Input + type: "number". Pass step / min / max via componentProps, or via column decimals on tables — see Editable Table)
currencyCurrencyInputonBlur— (null allowed; pair with required)
date / dateRange / timeDatePicker / DateRangePicker / TimeInputonChange
select / multiSelect / radio / checkboxGroupSelect / MultiSelect / RadioGroup / CheckboxGrouponChange
checkbox / switchCheckbox / SwitchonChange— (required pairs with a truthy validator)
fileFileUploadonInputBlocks while any item is uploading; pair required with validateFileRequired (≥1 done)
colorColorPickeronChange— (CSS string | null; see Color Picker)

JSON Schema forms use the same rule names natively (format: "email", x-validator) — the kind table adds no magic to the SchemaField registry.

Custom Inputs

f-ui sources are vendored read-only — never edit field-kinds.ts (or any installed f-ui file) to add kinds; local edits are overwritten on re-install. Extend in your own code instead:

ScenarioPath
One-off custom controlcomponent={[MyInput, props]} on FormField
Recurring input type with its own defaultsYour own *Field wrapper over FormField — bundle component, trigger, and base rules; type it with the exported FormFieldComponentProps
Backend-driven (Schema) custom controlcreateSchemaField({ components: { ...schemaComponents, MyControl } })

Custom connects can reuse the a11y bridge by importing nativeFieldA11yProps from formily/connects/shared — import, never edit. New built-in kinds land in f-ui releases only when they carry distinct behavioral defaults (trigger timing, base rules, or value semantics); props-only variants stay componentProps.

Helpers

createForm(options?) — wraps @formily/core createForm; accepts the full IFormProps (initialValues, effects, validateFirst, …).

createAsyncValidator(fn, { debounceMs? }) — optional adapter for network or debounced checks. Wraps a Formily-compatible async validator with latest-result-wins, passes an AbortSignal to cancel stale runs, and optionally debounces input. Opt in per field — sync validators stay unchanged.

import { createAsyncValidator } from "@/components/f-ui/formily/internals/create-async-validator";

const checkUsername = createAsyncValidator(
  async (value, { signal }) => {
    const res = await fetch(`/api/check-username?q=${value}`, { signal });
    if (!res.ok) return "Username unavailable";
  },
  { debounceMs: 300 },
);

<FormField name="username" validator={{ triggerType: "onInput", validator: checkUsername }} />

Built-in format messages (e.g. kind="email") follow the app locale when <Form> mounts under the Paraglide tree — Form syncs Formily's validator language on locale change. f-ui-owned kind messages (phone E.164) use Paraglide key form_validation_phone_e164; required empty uses form_validation_required ({label} is required). Wire your own copy with configureFormValidatorMessages if you do not use Paraglide. Custom validator callbacks own their own i18n.

applyFormIssues(form, issues) — applies non-validation FormIssue[] to the form sidecar. Mounted field paths receive always-visible feedback (exempt from the reveal policy) that auto-clears when the field's value changes; unmounted paths remain field-intent issues in FormErrorSummary.

SchemaField / schemaComponents — JSON Schema renderer with the built-in x-component registry. Extend with custom components via createSchemaField({ components: { ...schemaComponents, MyControl } }).

On this page