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.

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, 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/map-server-errors.tsAPI errors → field feedback + FormErrorAfter 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
FormError, submit-attempt counterreactions, visible, required
mapServerErrorsSchema 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".

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.

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 { 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 })}>
        <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" size="sm">
            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>
  );
}

F-UI Inputs

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

Cleared fields submit null, never undefined

mmddyyyy
"use client";

import { useMemo, useState } from "react";

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[];
}

export function FormilyInputsDemo() {
  const form = useMemo(
    () =>
      createForm<InputsValues>({
        initialValues: { amount: null, due: null, tags: [] },
      }),
    [],
  );
  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 })}>
        <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 }}
        />
        <FormActions>
          <Button type="submit" size="sm">
            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 {
  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 })}>
        <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" size="sm">
            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 { 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 })}>
        <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" size="sm">
            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>
  );
}

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.

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 { 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 })}>
        <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" size="sm">
            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

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.

Shown on invoices

"use client";

import { useMemo, useState } from "react";

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 })}
      >
        <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" size="sm">
            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.

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

"use client";

import { useMemo, useState } from "react";

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 })}
      >
        <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" size="sm">
            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 { 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 })}
      >
        <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" size="sm">
            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

Use a CSS grid inside Form for side-by-side fields — wrap full-width rows (textarea, long selects) in md:col-span-2. Vertical labels work best in dense grids; reuse --fui-form-field-gap for row rhythm.

mmddyyyy
"use client";

import { useMemo, useState } from "react";

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 })}>
        <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" size="sm">
            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

Swap FormSection for shadcn Card when sections need a bordered panel — one Form still owns validation and submit. Put fields in CardContent with the same field-gap variable; CardHeader carries the section title and helper copy.

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 { 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 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 })}>
        <Card>
          <CardHeader>
            <CardTitle>Company</CardTitle>
            <CardDescription>Legal identity shown on invoices</CardDescription>
          </CardHeader>
          <CardContent className="flex flex-col gap-[var(--fui-form-field-gap)]">
            <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" }}
            />
          </CardContent>
        </Card>

        <Card>
          <CardHeader>
            <CardTitle>Billing contacts</CardTitle>
            <CardDescription>Used for invoices and payment notices</CardDescription>
          </CardHeader>
          <CardContent className="flex flex-col gap-[var(--fui-form-field-gap)]">
            <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" }}
            />
          </CardContent>
        </Card>

        <Card>
          <CardHeader>
            <CardTitle>Defaults</CardTitle>
            <CardDescription>Applied to new invoices</CardDescription>
          </CardHeader>
          <CardContent className="flex flex-col gap-[var(--fui-form-field-gap)]">
            <FormField
              name="invoicePrefix"
              label="Invoice prefix"
              kind="text"
              componentProps={{ placeholder: "INV" }}
            />
            <FormField
              name="defaultCurrency"
              label="Currency"
              kind="select"
              componentProps={{ options: CURRENCIES }}
            />
          </CardContent>
        </Card>

        <FormActions>
          <Button type="submit" size="sm">
            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>
  );
}

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 { 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 })}
      >
        <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" size="sm">
            Cancel
          </Button>
          <Button type="submit" size="sm">
            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 align="end" renders a dialog-style footer row. Form wraps its children in a FieldGroup, so field rhythm needs no ad-hoc space-y-* classes.

New project

"use client";

import { useMemo, useState } from "react";

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 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">
      <div className="rounded-lg border p-4 shadow-sm">
        <h3 className="mb-4 text-sm font-medium">New project</h3>
        <Form
          form={form}
          density="compact"
          onSubmit={(values) => setSubmitted({ ...values })}
        >
          <FormField
            name="name"
            label="Project name"
            required
            component={[Input, { placeholder: "Apollo" }]}
          />
          <FormField
            name="visibility"
            label="Visibility"
            component={[Select, { options: VISIBILITY }]}
          />
          <FormActions align="end">
            <Button
              type="button"
              variant="outline"
              size="sm"
              onClick={() => void form.reset()}
            >
              Cancel
            </Button>
            <Button type="submit" size="sm">
              Create
            </Button>
          </FormActions>
        </Form>
      </div>

      {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 { 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 })}>
        <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" size="sm">
            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

mapServerErrors maps an API's { field, message }[] payload onto the form: matched paths become always-visible field errors that auto-clear when the user edits the field; unmatched paths surface through FormError and clear on the next submit attempt.

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

"use client";

import { useMemo, useState } from "react";

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 { FormError } from "@/components/f-ui/formily/form-error";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import {
  mapServerErrors,
  type ServerFieldError,
} from "@/components/f-ui/formily/internals/map-server-errors";
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<ServerFieldError[]> {
  await new Promise((resolve) => setTimeout(resolve, API_DELAY_MS));
  return [
    { field: "email", message: "Email is already registered." },
    { field: "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 errors = await fakeSignup();
          mapServerErrors(form, errors);
          setAttempts((n) => n + 1);
        }}
      >
        <FormError />
        <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" size="sm">
            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 { 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 })}>
        <SchemaField schema={schema} />
        <FormActions>
          <Button type="submit" size="sm">
            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 { 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);
        }}
      >
        <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"
                          component={[
                            Input,
                            { type: "number", inputMode: "numeric" },
                          ]}
                        />
                      </div>
                    </ObjectField>
                    <Button
                      type="button"
                      variant="outline"
                      size="sm"
                      className="self-end"
                      disabled={(arrayField.value?.length ?? 0) <= 1}
                      onClick={() => arrayField.remove(index)}
                    >
                      Remove
                    </Button>
                  </div>
                ))}
                <Button
                  type="button"
                  variant="secondary"
                  size="sm"
                  disabled={(arrayField.value?.length ?? 0) >= 5}
                  onClick={() => arrayField.push({ ...lineItemDefaults })}
                >
                  Add line item
                </Button>
              </div>
            )}
          </ArrayField>
        </FieldSet>
        <FormActions>
          <Button type="submit" size="sm">
            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).

$0.00

Derived by form effects — not a per-field reaction

"use client";

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

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.readOnly = true;
          });
          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 })}>
        <FormField
          name="quantity"
          label="Quantity"
          required
          kind="text"
          componentProps={{ type: "number", min: 1, inputMode: "numeric" }}
        />
        <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" size="sm">
            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 { 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 })}>
        <ExtendedSchemaField schema={schema} />
        <FormActions>
          <Button type="submit" size="sm">
            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 { 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 })}>
        <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" size="sm">
            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 { 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 })}>
        <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" size="sm">
            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
├── FormError                 ← form-level alerts (unmatched server errors)
├── 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
├── 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) => void | Promise<void>Called after validation passes
revealErrors"touch" | "submit" | "always""submit"When invalid-field errors become visible
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.25rem / 0.75rem (--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, currency, date, dateRange, time, select, multiSelect, checkbox, switch, radio); 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; reactions can set it 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

FormError

PropTypeDefaultDescription
classNamestringAlert container class

FormActions

PropTypeDefaultDescription
align"start" | "end""start"Button alignment inside the control column
classNamestringRow container class

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, CurrencyInput, DatePicker, DateRangePicker, TimeInput, MultiSelect, EmailInput, UrlInput, PhoneInput, PasswordInput. 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.

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)
currencyCurrencyInputonBlur— (null allowed; pair with required)
date / dateRange / timeDatePicker / DateRangePicker / TimeInputonChange
select / multiSelect / radioSelect / MultiSelect / RadioGrouponChange
checkbox / switchCheckbox / SwitchonChange— (required pairs with a truthy validator)

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, …).

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; wire your own copy with configureFormValidatorMessages if you do not use Paraglide. Custom validator callbacks own their own i18n.

mapServerErrors(form, errors) — maps server { field: string; message: string }[] onto the form. Mounted field paths receive an always-visible server feedback (exempt from the reveal policy) that auto-clears when the field's value changes; unmatched paths surface through FormError and clear on the next submit attempt.

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

On this page