npm

Customization

Form Layout

You design a form by declaring it, not by writing CSS. Say how many columns the sections take and how many columns the fields take inside each section. A label always sits beside its control, and the alignment is handled for you.

How is a form put together?

Three levels, and you configure each one with a single setting. Read it from the outside in:

the model
Form
└─ Rows           you declare them: as many as you want
   └─ Sections    each row says how many sit side by side: 1, 2 or 3
      └─ Fields   always two columns, one field per row:

         ┌──────────────────────┬──────────────────────────┐
         │ [i]  Label        *  │  [ control              ]│   row 1
         │ [i]  Label           │  [ control              ]│   row 2
         │      Label        *  │  [ control              ]│   row 3
         └──────────────────────┴──────────────────────────┘
           tooltip · label · *          the control

A label and its control are never stacked. The eye runs along one line
from the name to the box, and every control in a card starts at the same x.

A field belongs to a section through its group. That is the only wiring: put the same group on some fields and they share a card.

How do I lay out an Add Order form?

Say it in rows. "One column, two rows: two sections on top, three underneath" is the declaration, more or less word for word.

what you are describing
ROW 1  ── two sections ───────────────────────────────────────────
┌── Customer ──────────────────┐  ┌── Delivery ──────────────────┐
│ [i] Full Name  * │ [_______] │  │ [i] Address  * │ [_______]   │
│ ─────────────────┼────────── │  │ ───────────────┼──────────   │
│     Email      * │ [_______] │  │     City       │ [_______]   │
└──────────────────┴───────────┘  └────────────────┴─────────────┘

ROW 2  ── three sections ─────────────────────────────────────────
┌── Items ─────────┐ ┌── Payment ───────┐ ┌── Notes ─────────┐
│ [i] SKU * │ [__] │ │ [i] Method│ [__] │ │     Note  │ [__] │
│ ──────────┼───── │ │ ──────────┼───── │ │           │      │
│     Qty   │ [__] │ │     Terms │ [__] │ │           │      │
└───────────┴──────┘ └───────────┴──────┘ └───────────┴──────┘

[ Cancel ]  [ Save ]   ← always pinned; the rows above scroll
order-form.tsx
const fields: RecordField<Order>[] = [
  { key: "fullName", label: "Full Name", group: "Customer", editable: true, required: true,
    description: "Who the invoice goes to, not who receives the parcel." },
  { key: "email", label: "Email", group: "Customer", editable: true, required: true, format: "email" },

  { key: "address", label: "Address", group: "Delivery", editable: true, required: true },
  { key: "city", label: "City", group: "Delivery", editable: true },

  { key: "sku", label: "SKU", group: "Items", editable: true, required: true },
  { key: "qty", label: "Qty", group: "Items", editable: true, input: "number" },

  { key: "method", label: "Method", group: "Payment", editable: true, options: PAYMENT_METHODS },
  { key: "terms", label: "Terms", group: "Payment", editable: true },

  { key: "note", label: "Note", group: "Notes", editable: true },
];

<RecordView
  title="Orders"
  singular="Order"
  fields={fields}
  formRows={[
    { sections: [{ group: "Customer" }, { group: "Delivery" }] },
    { sections: [{ group: "Items" }, { group: "Payment" }, { group: "Notes" }] },
  ]}
  /* … */
/>

A field joins a card by sharing its group. That is the only wiring, and it is why the rows list only ever names groups.

What each setting does

formRows

The form's rows, in order. Each row lists the sections that sit side by side on it. One section on a row fills the row, so nothing needs a width: a card's width is just how many share its row.

  • Three to a row is the most that stays readable. Past that the label column starts squeezing the control, so a fourth wraps onto the next line within the row.
  • A section with no fields is dropped, and its row with it if that empties the row.
  • A group you forgot to place gets a full-width row at the end rather than disappearing, so adding a field can never lose it.
  • Omit formRows entirely and every section gets its own full-width row, which is how forms looked before this existed.

Section options

A section is { group } plus an optional description, a line under the title saying what the card is for. Nothing else: order and width both come from the rows.

Inside a card

Not configurable, on purpose. Every card is two columns and one field per row: the tooltip, label and required mark on the left, the control on the right. A form gets wider by adding sections to a row, not by cramming fields into one, which is what keeps two screens built by two people looking like one product.

You never align anything yourself

The label column is sized to its content, so it widens to the longest label in that card and never wraps, and every control in the card starts at the same x. Hairlines sit between the two columns and between the rows, light enough to read the grid without drawing the eye. Card grids collapse to one column on small screens.

How do I explain a field to whoever fills it in?

Give the field a description. An info icon appears before its label, and hovering shows the text: [i] Label * [control]. It shows wherever the field renders, including a slide-over, and full-page forms also collect these into the Info panel beside the form.

Write it as an instruction rather than a definition. It is read with the cursor already in the box, so "The name on the invoice, not the trading name" helps and "The customer name" does not.

Setting a house style once

Rows belong to a form, because they name that form's sections. What an app can set once is how forms behave, like where a validation message appears:

app/(app)/layout.tsx
<VuiProvider
  config={{ form: { errorDisplay: "tooltip" } }}
>
  {children}
</VuiProvider>

Values resolve per-instance prop → provider config → package default, so a prop on one RecordView always wins.

Where do validation errors show?

On the field, not under it. A failing control gets a red border and its message moves onto the info icon, so hovering shows what is wrong. The message is not printed as a line of text under the control and never as a toast: text under a control pushes the rest of the form down while someone is still typing in it, and a toast is gone before they look.

This is the default across the package. Set form: { errorDisplay: "text" } on VuiProviderfor the old behaviour. Either way the message is also announced to screen readers, since a border colour and a hover aren't available to everyone.

Older props still work

sectionColumns and sections[].span are deprecated as of 1.59 and still honoured: they force one column count on the whole form, which is exactly what rows exist to fix. Migrate by writing the rows you actually want. formColumns on a full-page form keeps working too.

Is there a template I can hand to an agent?

Yes: form.md on the Templates page. Copy it, fill in the record, the cards and the fields, and hand it over. It only asks what you actually decide: the layout questions are already answered here, so a filled-in template plus this page is enough for an agent to build the form without guessing.