Form Layout
Choose vertical fields, two-column field grids, control widths, and FormActions placement — without confusing “two columns” with Ant labelCol.
Use this page when assembling a fill form in a DetailCard, settings page, dialog, or Form Page. Component APIs and demos live on Form (Formily); this page is the decision guide so layout choices stay consistent.
Examples
Two-Column Fields
Left side forces orientation="horizontal" on a short ship-to card to occupy empty space. Right side keeps vertical labels and a two-field grid — the default for DetailCard region forms.
Wrong
Ship-to card
Horizontal Form on a short DetailCard to "fill the right side"
Right
Ship-to card
Two-column fields — vertical labels, fields side by side
"use client";
import { useMemo } 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 { DesignCompare } from "@/demos/_design/design-compare";
interface ShipToValues {
contactName: string;
phone: string;
city: string;
postalCode: string;
}
const INITIAL: ShipToValues = {
contactName: "Ada Lovelace",
phone: "+1 (555) 010-2000",
city: "Portland",
postalCode: "97201",
};
function WrongCard() {
const form = useMemo(
() => createForm<ShipToValues>({ initialValues: { ...INITIAL } }),
[],
);
return (
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">Ship-to card</p>
<Form
form={form}
orientation="horizontal"
labelWidth="7rem"
density="compact"
onSubmit={() => undefined}
>
<FormField name="contactName" label="Contact" kind="text" />
<FormField name="phone" label="Phone" kind="phone" />
<FormField name="city" label="City" kind="text" />
<FormField name="postalCode" label="Postal" kind="text" />
<FormActions align="end">
<Button type="submit">Save</Button>
</FormActions>
</Form>
<p className="text-xs text-muted-foreground">
Horizontal Form on a short DetailCard to "fill the right side"
</p>
</div>
);
}
function RightCard() {
const form = useMemo(
() => createForm<ShipToValues>({ initialValues: { ...INITIAL } }),
[],
);
return (
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">Ship-to card</p>
<Form form={form} density="compact" onSubmit={() => undefined}>
<div className="grid grid-cols-2 gap-[var(--fui-form-field-gap)]">
<FormField name="contactName" label="Contact" kind="text" />
<FormField name="phone" label="Phone" kind="phone" />
<FormField name="city" label="City" kind="text" />
<FormField name="postalCode" label="Postal" kind="text" />
</div>
<FormActions align="end">
<Button type="submit">Save</Button>
</FormActions>
</Form>
<p className="text-xs text-muted-foreground">
Two-column fields — vertical labels, fields side by side
</p>
</div>
);
}
export function FormLayoutTwoColumnFieldsDemo() {
return <DesignCompare wrong={<WrongCard />} right={<RightCard />} />;
}Horizontal Settings Form
Wide settings pages need a shared label | control column. Left side still uses a two-column field grid; right side switches to horizontal Form with controlMaxWidth.
Wrong
Notification settings
Two-column field grid on a wide settings page — labels don’t share a column
Right
Notification settings
Horizontal Form — shared label | control column + controlMaxWidth
"use client";
import { useMemo } 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 { DesignCompare } from "@/demos/_design/design-compare";
interface NotifyValues {
workspaceName: string;
replyTo: string;
region: string;
}
const REGIONS = [
{ label: "Americas", value: "amer" },
{ label: "EMEA", value: "emea" },
{ label: "APAC", value: "apac" },
];
const INITIAL: NotifyValues = {
workspaceName: "Northwind Ops",
replyTo: "ops@example.com",
region: "amer",
};
function WrongSettings() {
const form = useMemo(
() => createForm<NotifyValues>({ initialValues: { ...INITIAL } }),
[],
);
return (
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">
Notification settings
</p>
<Form form={form} density="compact" onSubmit={() => undefined}>
<div className="grid grid-cols-2 gap-[var(--fui-form-field-gap)]">
<FormField name="workspaceName" label="Workspace" kind="text" />
<FormField name="replyTo" label="Reply-to" kind="email" />
<FormField
name="region"
label="Region"
kind="select"
componentProps={{ options: REGIONS }}
/>
</div>
<FormActions align="end">
<Button type="submit">Save changes</Button>
</FormActions>
</Form>
<p className="text-xs text-muted-foreground">
Two-column field grid on a wide settings page — labels don’t share a
column
</p>
</div>
);
}
function RightSettings() {
const form = useMemo(
() => createForm<NotifyValues>({ initialValues: { ...INITIAL } }),
[],
);
return (
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">
Notification settings
</p>
<Form
form={form}
orientation="horizontal"
labelWidth="7.5rem"
controlMaxWidth="16rem"
density="compact"
onSubmit={() => undefined}
>
<FormField name="workspaceName" label="Workspace" kind="text" />
<FormField name="replyTo" label="Reply-to" kind="email" />
<FormField
name="region"
label="Region"
kind="select"
componentProps={{ options: REGIONS }}
/>
<FormActions align="end">
<Button type="submit">Save changes</Button>
</FormActions>
</Form>
<p className="text-xs text-muted-foreground">
Horizontal Form — shared label | control column + controlMaxWidth
</p>
</div>
);
}
export function FormLayoutHorizontalSettingsDemo() {
return <DesignCompare wrong={<WrongSettings />} right={<RightSettings />} />;
}Control Width
Without a cap, horizontal controls sprawl across a wide content band. Right side uses Form controlMaxWidth="16rem" so Text / Select / Textarea share one shorter control column — same right edge, clearly shorter than Wrong. Do not shrink a Select to hug “Standard”.
Wrong
Order profile
No controlMaxWidth — Text / Select / Notes all stretch to the full panel width (same right edge, but far too wide on a settings page)
Right
Order profile
controlMaxWidth="16rem" — same shared right edge, but the control column stops at 16rem (look how much shorter than Wrong)
"use client";
import { useMemo } 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 { DesignCompare } from "@/demos/_design/design-compare";
interface OrderMetaValues {
displayName: string;
fulfillment: string;
notes: string;
}
const FULFILLMENT = [
{ label: "Standard", value: "standard" },
{ label: "Express", value: "express" },
{ label: "Pickup", value: "pickup" },
];
const INITIAL: OrderMetaValues = {
displayName: "PO-10482",
fulfillment: "standard",
notes: "",
};
function WrongWidth() {
const form = useMemo(
() => createForm<OrderMetaValues>({ initialValues: { ...INITIAL } }),
[],
);
return (
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">Order profile</p>
<Form
form={form}
orientation="horizontal"
labelWidth="7rem"
density="compact"
onSubmit={() => undefined}
>
<FormField name="displayName" label="Display name" kind="text" />
<FormField
name="fulfillment"
label="Fulfillment"
kind="select"
componentProps={{ options: FULFILLMENT }}
/>
<FormField
name="notes"
label="Notes"
kind="textarea"
componentProps={{ rows: 2 }}
/>
<FormActions align="end">
<Button type="submit">Save</Button>
</FormActions>
</Form>
<p className="text-xs text-muted-foreground">
No controlMaxWidth — Text / Select / Notes all stretch to the full panel
width (same right edge, but far too wide on a settings page)
</p>
</div>
);
}
function RightWidth() {
const form = useMemo(
() => createForm<OrderMetaValues>({ initialValues: { ...INITIAL } }),
[],
);
return (
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">Order profile</p>
<Form
form={form}
orientation="horizontal"
labelWidth="7rem"
controlMaxWidth="16rem"
density="compact"
onSubmit={() => undefined}
>
<FormField name="displayName" label="Display name" kind="text" />
<FormField
name="fulfillment"
label="Fulfillment"
kind="select"
componentProps={{ options: FULFILLMENT }}
/>
<FormField
name="notes"
label="Notes"
kind="textarea"
componentProps={{ rows: 2 }}
/>
<FormActions align="end">
<Button type="submit">Save</Button>
</FormActions>
</Form>
<p className="text-xs text-muted-foreground">
controlMaxWidth="16rem" — same shared right edge, but the control
column stops at 16rem (look how much shorter than Wrong)
</p>
</div>
);
}
/** Stack panels full-width so the 16rem cap is obvious vs an uncapped band. */
export function FormLayoutControlWidthDemo() {
return (
<DesignCompare
className="sm:grid-cols-1"
wrong={<WrongWidth />}
right={<RightWidth />}
/>
);
}Form Actions Placement
Card and dialog footers put finalizing actions at the end of the row. Left side forces FormActions align="start"; right side uses the default align="end".
Wrong
Purchase request
align="start" — finalizing actions tuck under the left edge
Right
Purchase request
Default align="end" — Save / Submit at the footer end
"use client";
import { useMemo } 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 { DesignCompare } from "@/demos/_design/design-compare";
interface ApproveValues {
reason: string;
}
const INITIAL: ApproveValues = { reason: "" };
function WrongActions() {
const form = useMemo(
() => createForm<ApproveValues>({ initialValues: { ...INITIAL } }),
[],
);
return (
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">
Purchase request
</p>
<Form form={form} density="compact" onSubmit={() => undefined}>
<FormField
name="reason"
label="Approval note"
kind="textarea"
componentProps={{
placeholder: "Optional note for the requester",
rows: 2,
}}
/>
<FormActions align="start">
<Button type="button" variant="outline">
Cancel
</Button>
<Button type="submit">Submit for approval</Button>
</FormActions>
</Form>
<p className="text-xs text-muted-foreground">
align="start" — finalizing actions tuck under the left edge
</p>
</div>
);
}
function RightActions() {
const form = useMemo(
() => createForm<ApproveValues>({ initialValues: { ...INITIAL } }),
[],
);
return (
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">
Purchase request
</p>
<Form form={form} density="compact" onSubmit={() => undefined}>
<FormField
name="reason"
label="Approval note"
kind="textarea"
componentProps={{
placeholder: "Optional note for the requester",
rows: 2,
}}
/>
<FormActions>
<Button type="button" variant="outline">
Cancel
</Button>
<Button type="submit">Submit for approval</Button>
</FormActions>
</Form>
<p className="text-xs text-muted-foreground">
Default align="end" — Save / Submit at the footer end
</p>
</div>
);
}
export function FormLayoutActionsPlacementDemo() {
return <DesignCompare wrong={<WrongActions />} right={<RightActions />} />;
}Derived Money Density
Amounts computed inside a fill form stay at form density. Left side mounts a Statistic KPI between fields; right side uses a read-only currency FormField with the same FormItem chrome.
Wrong
Order line
Statistic KPI between Form fields — wrong density for in-form totals
Right
Order line
Read-only FormField for the total — not a list/header Statistic
"use client";
import { onFieldValueChange } from "@formily/core";
import { useMemo } from "react";
import { Statistic } from "@/components/f-ui/statistic/statistic";
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 { DesignCompare } from "@/demos/_design/design-compare";
interface LineValues {
quantity: number;
unitPrice: number;
lineTotal: number;
}
function recalcLineTotal(form: ReturnType<typeof createForm<LineValues>>) {
const qty = Number(form.values.quantity) || 0;
const price = Number(form.values.unitPrice) || 0;
form.setValuesIn("lineTotal", qty * price);
}
function WrongMoney() {
const form = useMemo(
() =>
createForm<LineValues>({
initialValues: { quantity: 4, unitPrice: 62.5, lineTotal: 250 },
}),
[],
);
return (
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">Order line</p>
<Form form={form} density="compact" onSubmit={() => undefined}>
<FormField
name="quantity"
label="Quantity"
kind="number"
componentProps={{ min: 1, step: 1 }}
/>
<FormField
name="unitPrice"
label="Unit price"
kind="currency"
componentProps={{ currency: "USD" }}
/>
<Statistic title="Line total" value={250} precision={2} />
<FormActions align="end">
<Button type="submit">Save line</Button>
</FormActions>
</Form>
<p className="text-xs text-muted-foreground">
Statistic KPI between Form fields — wrong density for in-form totals
</p>
</div>
);
}
function RightMoney() {
const form = useMemo(
() =>
createForm<LineValues>({
initialValues: { quantity: 4, unitPrice: 62.5, lineTotal: 250 },
effects(form) {
form.setFieldState("lineTotal", (state) => {
state.pattern = "readPretty";
});
onFieldValueChange("quantity", () => recalcLineTotal(form));
onFieldValueChange("unitPrice", () => recalcLineTotal(form));
},
}),
[],
);
return (
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">Order line</p>
<Form form={form} density="compact" onSubmit={() => undefined}>
<FormField
name="quantity"
label="Quantity"
kind="number"
componentProps={{ min: 1, step: 1 }}
/>
<FormField
name="unitPrice"
label="Unit price"
kind="currency"
componentProps={{ currency: "USD" }}
/>
<FormField
name="lineTotal"
label="Line total"
kind="currency"
componentProps={{ currency: "USD" }}
description="Derived at form density — same FormItem chrome"
/>
<FormActions align="end">
<Button type="submit">Save line</Button>
</FormActions>
</Form>
<p className="text-xs text-muted-foreground">
Read-only FormField for the total — not a list/header Statistic
</p>
</div>
);
}
export function FormLayoutDerivedMoneyDemo() {
return <DesignCompare wrong={<WrongMoney />} right={<RightMoney />} />;
}Vocabulary — “Two Columns”
| Phrase | Means | f-ui pattern |
|---|---|---|
| Two-column fields | Two fields side by side; each field is still label above value (shadcn vertical) | md:grid-cols-2 inside Form — Two-Column Fields |
| Horizontal Form | Label | control on one row (Ant labelCol / wrapperCol) | orientation="horizontal" — Horizontal Layout |
Saying “two columns” without this distinction is how forms thrash between layouts. Prefer two-column fields for card / dense admin grids; reserve horizontal Form for wide settings rows that need a shared label column.
Surface Defaults
| Surface | Default field chrome | Field grid | Submit / footer |
|---|---|---|---|
| DetailCard / short region form | Vertical (label above value) | Optional md:grid-cols-2 when ≥4 related fields and the card is wide | FormActions align="end" |
| Dialog / wizard | Vertical + density="compact" | Usually single column | FormActions align="end" |
| Wide settings Form Page | Often horizontal + controlMaxWidth | Optional nested two-column field grids inside cards | FormActions align="end" (or horizontal offset under the control column) |
Do not force orientation="horizontal" on a short DetailCard just to “use the empty right side.” Use a field grid instead.
Control Width
| Rule | Do | Don’t |
|---|---|---|
| Horizontal Form control column | Cap with Form controlMaxWidth; controls are w-full of that column so Text / Select / Textarea share a common right edge | Let controls sprawl to the full page content band |
| Select in that column | Trigger fills the capped column (same width as sibling Input). Option list may be wider than the trigger (longest entry) | Shrink the Select trigger to hug the current label (“Standard”) — jagged column, and SAP Fiori Select says do not auto-adjust width based on the selection |
| Intrinsically short fields | Narrow by content type when the answer is short and fixed (ZIP, ±0.05 adjust, ISO currency code) — left edges still align under the label | Randomly shorten one Select in a settings stack “for balance” while neighbors stay wide |
| Two-column field grid | Each control is w-full of its cell; cap the grid/card, not each trigger independently | Stretch a short Settlement Select to half a wide card only when the cell itself is that wide |
Industry anchors
- SAP Fiori Select — Width: form width comes from the layout container; do not auto-adjust the control to the selection; only shorten when length is fixed and non-localized (e.g. currency codes). Option list adapts to the longest entry (cap ~600px).
- SAP Fiori Input Field — Width: prefer layout containers / 12-column grid over ad-hoc fixed widths.
- NN/g — Match field length to expected content: size by answer type (ZIP vs address), not by making one dropdown visually shorter than peers in the same settings column.
- Carbon Forms: field widths reflect intended content while still aligning to the grid.
- Ant Design horizontal Form: shared
labelCol/wrapperCol(ormaxWidthon the form) — siblings share the control column.
Correction (2026-08-10): an earlier “Right” demo that content-sized Fulfillment to max-w-[12rem] while neighbors stayed at controlMaxWidth produced a jagged right edge. That is not the locked rule — shared capped column is.
Form Actions Placement
| Surface | Placement |
|---|---|
| Card, dialog, settings footer | FormActions align="end" — finalizing Submit / Save at the end of the footer row |
| Horizontal Form (label | control) | Default label-column offset so actions sit under the control column (FormActions auto-offset) |
| Filter / toolbar bars | offset={false} when the row must stay full-width |
Industry anchor: SAP Fiori Action Placement — right-align finalizing actions in the footer toolbar.
Derived Values Inside Forms
Amounts and read-only totals inside a fill form stay at form density: same FormItem chrome as editable fields (VoidField + FormItem, or read-only FormField). Optional one stronger Final line (font-semibold body) — not hero KPI type.
Do not mount default Statistic between Form fields. Statistic belongs in list exclusive areas and detail header KPI bands — see List Page Statistics.
Shared Chrome Only
Editable and derived rows must share one Formily FormItem language (label column, mark, helpers, controlMaxWidth). Do not hand-roll a parallel Field with a private label width — it will drift from required marks and action offset.