f-ui
Design

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=&quot;16rem&quot; — 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=&quot;start&quot; — 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=&quot;end&quot; — 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

Line total
250.00

Statistic KPI between Form fields — wrong density for in-form totals

Right

Order line

USD 250.00

Derived at form density — same FormItem chrome

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”

PhraseMeansf-ui pattern
Two-column fieldsTwo fields side by side; each field is still label above value (shadcn vertical)md:grid-cols-2 inside FormTwo-Column Fields
Horizontal FormLabel | 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

SurfaceDefault field chromeField gridSubmit / footer
DetailCard / short region formVertical (label above value)Optional md:grid-cols-2 when ≥4 related fields and the card is wideFormActions align="end"
Dialog / wizardVertical + density="compact"Usually single columnFormActions align="end"
Wide settings Form PageOften horizontal + controlMaxWidthOptional nested two-column field grids inside cardsFormActions 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

RuleDoDon’t
Horizontal Form control columnCap with Form controlMaxWidth; controls are w-full of that column so Text / Select / Textarea share a common right edgeLet controls sprawl to the full page content band
Select in that columnTrigger 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 fieldsNarrow by content type when the answer is short and fixed (ZIP, ±0.05 adjust, ISO currency code) — left edges still align under the labelRandomly shorten one Select in a settings stack “for balance” while neighbors stay wide
Two-column field gridEach control is w-full of its cell; cap the grid/card, not each trigger independentlyStretch 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 (or maxWidth on 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

SurfacePlacement
Card, dialog, settings footerFormActions 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 barsoffset={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.

See Also

On this page