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
Fieldchrome (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 points —
FormFieldfor JSX,SchemaFieldfor JSON Schema,kindfor 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 idsRule 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:
| Surface | Name | Why |
|---|---|---|
| Docs nav & registry title | Form (Formily) | User-facing: "this is our Form solution, powered by Formily" |
| Install path & imports | components/f-ui/formily/… | Honest about the engine — createForm returns a Formily Form, validators and effects are Formily APIs |
shadcn's built-in form.tsx | not this | That 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)
| Path | Role | You touch it when… |
|---|---|---|
form.tsx | FormProvider + <form noValidate> + layout context + locale sync | Wrapping any form |
form-field.tsx | JSX sugar over Formily Field + default FormItem decorator | Most fields |
form-item.tsx | x-decorator: label, *, description, error, orientation | Custom decorators or raw Field |
field-kinds.ts | kind="email" → connect + trigger + base rules | Repeated input types with defaults |
connects/* | connectField(ShadcnControl) — value, a11y, disabled | Custom controls (copy the pattern) |
schema-field.tsx | SchemaField + schemaComponents registry | Backend-driven forms |
internals/create-form.ts | createForm import path | Bootstrapping the VM |
internals/reveal-errors.ts | touch / submit / always error visibility | Via Form revealErrors prop |
internals/form-issues.ts | applyFormIssues / getFormIssues / clearFormIssues + FormErrorSummary | After failed submit |
internals/form-validator-* | Locale sync for built-in format messages | Automatic 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 provides | Use Formily / your app for |
|---|---|
FormItem chrome, layout, density | effects, onFieldValueChange |
connects/* bound to shadcn controls | ObjectField, ArrayField, raw Field |
Error reveal policy (revealErrors) | validator, format, custom rules |
FormErrorSummary, submit-attempt counter | reactions, visible, required |
applyFormIssues | Schema x-reactions, business logic in onSubmit |
a11y: connectField, scoped DOM ids | i18n 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).
| Layer | Implementation |
|---|---|
| ViewModel | Formily Form / Field |
| Binder | connects/* via connectField → value / onValueChange + a11y |
| View | FormItem layout branches — plain components; observer only at decorator/connect edges |
| Extensions | f-ui sidecars on the form instance (WeakMap) — submit attempts, reveal policy, alerts. No subclassing Formily |
Accessibility
FormItemowns label / helper / error DOM and publishes ids throughFieldChromeContext.connectFieldmergesaria-describedbyin one place (buildDescribedBy).<Form>prefixes every field id with ReactuseId()so duplicatenames across forms on one page never collide.- Custom connects outside
FormItemcan reusenativeFieldA11yPropsfromconnects/shared.
Quick decision guide
| I need to… | Start here |
|---|---|
| Build a standard settings / checkout form | Form + FormField or kind |
| Drive fields from JSON Schema / the backend | SchemaField; extend with createSchemaField |
| React to field changes at form level | createForm({ effects }) — @formily/core |
| Repeatable rows, nested objects | ArrayField, ObjectField — @formily/react |
| Drop chrome, keep Formily | Field + decorator={[FormItem]} or decorator={null} |
| A new input type in my app | Copy 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.
| Pattern | Use when | Empty value |
|---|---|---|
editable (default) | The operator can change this field this session | Blank control |
readOnly | Locked 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 |
readPretty | Never 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 — |
disabled | Temporarily inapplicable because of a dependency | Grey, 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) stayeditable. Unlock flips applicant fields toeditable. Fiori Display → Edit; Salesforceview→ pencil. - Edit session with a leftover lock: pretty
createdAt+ readOnlyskuandregion+ editablecomment— see Mixed Pattern. - Hosts set
field.readOnly,field.pattern = "readOnly", or formreadOnlyfor locked controls. Do not useeditable={false}as a lock. Do not paint the approver inbox as a wall ofreadOnlyinputs. - 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.jsonnpx shadcn@latest add https://ui.isaacfei.com/api/plus/r/formily.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/formily.jsonbunx shadcn@latest add https://ui.isaacfei.com/api/plus/r/formily.jsonWith 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.
"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.
"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.
"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).
"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):
| Kind | Empty value | User can clear? | Required |
|---|---|---|---|
radio | null (no radio checked) | No via the control — reset the field / form, or pick Select if clear-from-trigger matters | Fails while still null |
checkboxGroup | [] | Yes — uncheck all options | Fails while still [] |
Try submit with both empty, then fill them; use Reset fields to put radio back to null and checkbox group back to [].
"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 }}
/>"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…" }}
/>"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"] }],
}}
/>"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.
"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.
"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.
"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.
"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.
"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.
"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}.
"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.
"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 & 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 hosts — Form'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)]".
"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.
"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.
"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
| Need | Use |
|---|---|
| Everyday field + chrome | FormField + kind |
| Custom control, one-off | FormField + component |
| Recurring custom type | Your own *Field wrapper (see Custom Inputs below) |
| Backend-driven tree | SchemaField / extended createSchemaField |
| Form-wide side effects | createForm({ effects }) |
| Dynamic collections | ArrayField / ObjectField (see Dynamic Arrays above) |
| Full decorator control | Raw 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).
"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.
"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.
"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 FieldFor 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
| Prop | Type | Default | Description |
|---|---|---|---|
form | Form<T> | — | From createForm |
onSubmit | (values: T) => FormSubmitResult | Promise<FormSubmitResult> | — | Called after validation passes; void = success; { status: "error", issues } = known rejection |
onInvalid | () => void | — | Called when Formily validation fails before onSubmit |
onSubmitError | (error: unknown) => void | — | Called for unexpected throws; a root system issue is also applied |
focusOnInvalid | "summary" | "first-field" | false | false | Focus 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 |
labelWidth | CSS length | 10rem | Horizontal label column width (--fui-form-label-width) |
labelAlign | "start" | "end" | "end" horizontal/responsive, "start" vertical | Horizontal label alignment |
density | "comfortable" | "compact" | "comfortable" | Field row gap: 1.5rem / 1rem (--fui-form-field-gap) |
colon | boolean | false | Appends : after horizontal labels; ignored in vertical |
controlMaxWidth | CSS length | — | Caps 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.
| Prop | Type | Default | Description |
|---|---|---|---|
name | Formily path | — | Field path (user.email, items[2].name) |
component | [Component, props?] | — | Connected control + static props |
kind | FieldKindName | — | Resolves 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) |
componentProps | typed per kind | — | Props for the kind's component, typed from its connect; only valid together with kind |
label / description | ReactNode | — | FormItem chrome |
validatingDescription | ReactNode | — | Replaces description while async validators run |
required | boolean | — | * mark + aria-required; default message is {label} is required (field and Banner match). Host validator / explicit required message wins. Reactions can set required dynamically |
inlineLabel | boolean | false | Checkbox-style label beside the control |
orientation | "vertical" | "horizontal" | "responsive" | from Form | Per-field override |
hideHelperOnError | boolean | false | Visually hide helper while an error shows (kept in aria-describedby) |
validator | Formily validator | — | Sync/async; with kind, appended after kind base rules; bare functions inherit the kind's default trigger |
reactions | (field) => void | — | JSX reactions are functions; {{...}} strings are Schema-only |
decorator | Formily 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.
| Prop | Type | Default | Description |
|---|---|---|---|
scope | "all" | "form" | "all" | "all" — root + field issues; "form" — root issues only |
className | string | — | Banner container class |
onNavigate | (issue: FormIssue) => void | — | Headless hook when a field issue link is activated |
title | ReactNode | "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
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
offset | boolean | auto | Override label-column offset; unset auto-offsets for horizontal/responsive forms; false for full-width filter/toolbar bars |
className | string | — | Row container class |
Action row buttons use default size (h-8) — do not pass size="sm".
FormPanel
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "card" | "plain" | "card" | card renders the shared panel token (bg-card rounded-xl border p-4); plain is borderless for embedding |
title / description | ReactNode | — | Optional header above the content host (mb-5); not inside the field-gap flex |
gap | boolean | true | When true, content is flex flex-col with --fui-form-field-gap; set false for a custom layout child |
contentClassName | string | — | Merged onto data-slot="form-panel-content" |
className | string | — | Merged onto the panel surface (data-slot="form-panel") |
headerClassName | string | — | Merged onto the header block when title/description set |
FormSection
| Prop | Type | Default | Description |
|---|---|---|---|
legend | ReactNode | — | Section title (FieldLegend) |
description | ReactNode | — | Helper copy under the legend (FieldDescription) |
className | string | — | Merged 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
| Kind | Connect | Trigger | Base rule |
|---|---|---|---|
text / textarea | Input / Textarea | onInput | — |
email | EmailInput | onBlur | Formily built-in format: "email" |
url | UrlInput | onBlur | Formily built-in format: "url" |
phone | PhoneInput | onBlur | Custom E.164 check — message key form_validation_phone_e164 (not Formily's CN phone format) |
password | PasswordInput | onBlur | — (no built-in validator; add app rules for strength or policy) |
number | NumberInput | onInput | — (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) |
currency | CurrencyInput | onBlur | — (null allowed; pair with required) |
date / dateRange / time | DatePicker / DateRangePicker / TimeInput | onChange | — |
select / multiSelect / radio / checkboxGroup | Select / MultiSelect / RadioGroup / CheckboxGroup | onChange | — |
checkbox / switch | Checkbox / Switch | onChange | — (required pairs with a truthy validator) |
file | FileUpload | onInput | Blocks while any item is uploading; pair required with validateFileRequired (≥1 done) |
color | ColorPicker | onChange | — (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:
| Scenario | Path |
|---|---|
| One-off custom control | component={[MyInput, props]} on FormField |
| Recurring input type with its own defaults | Your own *Field wrapper over FormField — bundle component, trigger, and base rules; type it with the exported FormFieldComponentProps |
| Backend-driven (Schema) custom control | createSchemaField({ 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 } }).