Form field
The vocabulary every Yuno setup form is assembled from: one labelled field, the two-column row it lives in, the block that groups rows, the checkbox that reveals a fieldset, and the group you can add more of. Provider setup, routing rules and checkout config are hundreds of different forms, and they stay consistent because they are all assembled from these pieces instead of laid out by hand one screen at a time.
Anatomy
A field is a label row over a control. The label row is a justify-between, so the (optional) marker always lands in the same place, the right edge of the field, no matter how long the label is. Helper or error text sits under the control.
Leave it empty and the provider default applies.
- 1Label row
Label on the left, the Info affordance right after it, and the right-aligned (optional) marker at the far edge of the field.
- 2Info
Phosphor Info, weight light, 16px muted. Pass copy instead of true and it becomes a Tooltip trigger.
- 3Control
Input by default, Select when you pass options, or anything you hand it as children: Textarea, DatePicker, Combobox, a controlled field.
- 4Helper / error
text-xs under the control. Muted for description, destructive for error, which also tints the control border.
The grid invariant
Fields live in rows of two. A row with a single leftover field keeps the width of ONE column: it does not stretch to fill the row. Full width is reserved for a primary field that sits alone in its own block (Name) and is opt-in via FieldRow's full. This is what keeps hundreds of provider forms reading as one form. Three fields are always two on top and one at half width below, never a layout that reflows differently per provider.
<FieldRow full>
<Field label="Name" placeholder="Enter a name" />
</FieldRow>
<FieldRow>
<Field label="Merchant name" placeholder="Enter merchant name" />
<Field label="Merchant URL" placeholder="www.acmeco.com" />
</FieldRow>
{/* Three fields make two rows. The leftover keeps HALF the width. */}
<FieldRow>
<Field label="Merchant country code" placeholder="US" info />
</FieldRow>Controls
Two shorthands cover the common case: an Input by default, a dropdown when you pass options. Search is not a second component you have to choose — it is a property of that dropdown, and the length of the list earns it. Under eight options the list opens fully visible and reading it beats typing, so a search box would be furniture; past eight it starts scrolling, and the countries and currencies a provider form is full of stop being findable by eye. The kit ships a Select and a Combobox, which is an inheritance rather than a decision a form author should make field by field, so Field makes it for them — and `searchable` overrides it where the count lies. Everything the shorthands do not cover goes in as children and still gets the field's id, so the label keeps pointing at it.
{/* Two options: a plain dropdown. */}
<Field label="3DS version" options={["2.1.0", "2.2.0"]} defaultValue="2.2.0" info />
{/* Same prop, a long list: the dropdown carries a search box.
`searchable` overrides it when the count is a bad proxy. */}
<Field label="Country" options={COUNTRIES} placeholder="Select country" />
{/* Anything the shorthands do not cover goes in as children. */}
<Field label="Notes" optional>
<Textarea placeholder="Anything the reviewer should know" />
</Field>Helper and error
description prints muted helper text under the control. error replaces it, turns it destructive and tints the control border with the same triplet the Input doc documents, so a field in error looks identical whether it was built here or by hand.
Minutes before an unfinished authentication expires.
Acquirer BIN must be 6 digits.
<Field
label="Transactions expire time"
defaultValue="10"
description="Minutes before an unfinished authentication expires."
/>
<Field
label="Acquirer BIN"
defaultValue="45"
error="Acquirer BIN must be 6 digits."
/>Blocks
A block is a titled group of rows, 24px apart. Blocks are separated by a Separator that the screen owns: the block never draws its own rule, so the screen decides where the divisions go.
Authentication
Acquirer
<FieldBlock title="Authentication">
<FieldRow>
<Field label="Challenge indicator" options={["01", "02", "03"]} info />
<Field label="3DS version" options={["2.1.0", "2.2.0"]} info />
</FieldRow>
</FieldBlock>
<Separator />
<FieldBlock title="Acquirer">
<FieldRow>
<Field label="Acquirer country code" placeholder="Enter country code" info />
<Field label="SIRET" placeholder="Enter SIRET" info optional />
</FieldRow>
</FieldBlock>The box
FieldGroup is the box a group of fields sits in. Acquirer BIN, metadata, Requestor ID and the multi-MID add box are not four boxes that look alike — they are this one box, and the next box of the same kind is this one too rather than a fifth built by hand. It is deliberately NOT a card: a card is a top-level surface on the page canvas, and this is the step below it, the third rung of the ladder — canvas 250/10, panel 255/23, group 255/38. Its fill is dark-only on purpose. In light the group is carried by its border, exactly as it is today, so adopting it changes nothing in the default theme — which is why any grey here reads as too much. In dark a border alone was not enough: the group had the same fill as the panel around it and dissolved into it. Ported from dashboard-web's FieldGroup molecule, so the kit and production say the same thing rather than each inventing a box.
<FieldGroup>
<FieldRow>
<Field label="Brand" placeholder="Enter brand" />
<Field label="Acquirer BIN" placeholder="Enter acquirer BIN" />
</FieldRow>
</FieldGroup>Progressive disclosure
A checkbox that reveals the fieldset it governs. The common path stays short and the extra fields only exist once the merchant opts into them. Common in Connection and Routing setup.
<CheckboxReveal label="3DS Standalone" optional defaultChecked>
<FieldRow>
<Field label="Merchant name" placeholder="Enter merchant name" />
<Field label="Merchant URL" placeholder="www.acmeco.com" />
</FieldRow>
</CheckboxReveal>Repeatable groups
Anything the merchant can add more of: acquirer BINs, requestor IDs, metadata. One container card, item cards inside, an Add control, and a delete column that only appears once there is more than one item, because you cannot remove the last one.
Acquirer BIN
<RepeatableGroup
title="Acquirer BIN"
addLabel="Add acquirer BIN"
renderItem={() => (
<FieldRow>
<Field label="Brand" placeholder="Enter brand" />
<Field label="Acquirer BIN" placeholder="Enter acquirer BIN" />
</FieldRow>
)}
/>The exception: the inline bar
One row in this vocabulary is not two columns. FieldBar puts N short fields on a single line, because it is collecting one short record the merchant is about to add to a list, over and over, and not the settings of one thing. It is a 12-column grid, which is what span always meant: the columns line up down the box and a line costs its control height and nothing else. Labels print ONCE, as a row of the box rather than as children of their controls — every line used to be its own bordered card with its own labels, which cost about 120px a line and made four merchant IDs taller than the rest of the step. The box around it belongs to the screen: only the header row and the lines are the pattern. Ported from multi-mid's Merchant IDs, where the shape was designed and picked apart — Upload file sits at title level because at 150 merchant IDs the file is the real path and typing is the exception, so it is not a peer of the typing controls.
Merchant IDs
Yuno picks the merchant ID from the payment's country and currency. Routing stays one rule.
Add a few by hand, or upload the file Amex sent you.
const COLUMNS = [
{ key: "merchantId", label: "Merchant ID", span: 3 },
{ key: "country", label: "Country", span: 3 },
{ key: "currency", label: "Currency", span: 3 },
{ key: "terminal", label: "Terminal", span: 3 },
];
<FieldBar columns={COLUMNS} renderControl={(c) => control(c)} />Stacked lines
A merchant adding three or four records should not have to add, retype, add. Extra lines stack on the same 12-column grid under the one header row, and one button commits all of them. The removal control appears from the second line on — you cannot remove the only line there is, and a lone X on an empty box is noise. The two buttons stop being peers by placement rather than wording: Add more belongs to the BOX and lives inside it, under the lines it makes, while the primary belongs to the STEP and sits under the box, in the slot the single-line version puts its Add.
Merchant IDs
Yuno picks the merchant ID from the payment's country and currency. Routing stays one rule.
<FieldBar
columns={COLUMNS}
lines={drafts.length}
renderControl={(c, line) => control(c, drafts[line])}
// From the second line on: you cannot remove the only line there is.
onRemoveLine={drafts.length > 1 ? removeLine : undefined}
/>The rows answer to width, not to the window
Every FieldRow and every FieldBar is its own container, so the layout answers to the width the form actually got. There is no sheet prop to pass and no second component to maintain. The numbers are honest about what that buys here: the kit's Sheet is 768px wide, 719px of usable body, which is as wide as a wizard step (766px) — so a form in a Sheet keeps its two columns and the bar keeps its line. The fold is for containers that are genuinely narrow: a row drops to one column under 576px, and the bar folds to two lines of two under 672px, inside the same card, never one field per line and never spilling out of the container.
<SheetContent side="right">
<SheetHeader>
<SheetTitle>Add merchant ID</SheetTitle>
</SheetHeader>
<SheetBody>
<FieldStack>
<FieldRow>
<Field label="Merchant name" placeholder="Enter merchant name" />
<Field label="Merchant URL" placeholder="www.acmeco.com" />
</FieldRow>
</FieldStack>
</SheetBody>
</SheetContent>Import
Import the pieces you compose. Field covers the two control shorthands, and anything else goes in as children.
import {
Field,
FieldRow,
FieldBlock,
FieldStack,
FieldBar,
type FieldBarColumn,
FieldGroup,
CheckboxReveal,
RepeatableGroup,
} from "@/components/patterns/form-field";Props
Everything else is forwarded to the underlying elements via ...props.
Field
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | The label above the control. |
| info | boolean | ReactNode | — | true renders the Info icon on its own. Copy turns it into a Tooltip trigger, which is what you want whenever there is something to say. |
| optional | boolean | false | Right-aligned (optional) marker, at the far edge of the field so it lands in the same place on every row. |
| description | string | — | Helper text under the control. Hidden while error is showing. |
| error | string | — | Error text under the control. Also tints the control border. |
| options | FieldOption[] | — | Turns the shorthand into a Select. Plain strings, or { value, label } when they differ. |
| placeholder | string | — | Input placeholder, or the Select's empty value. |
| defaultValue | string | — | Uncontrolled initial value for both shorthands. |
| type | string | — | Native input type for the Input shorthand. |
| disabled | boolean | false | Disables the control. |
| children | ReactNode | — | Bring your own control. It replaces both shorthands and gets the generated id, so the label still points at it. |
FieldRow
| Prop | Type | Default | Description |
|---|---|---|---|
| full | boolean | false | Opts the row out of the 2-column grid. Reserved for a primary field alone in its own block, not a way to widen a leftover. |
| children | ReactNode | — | One or two Fields. Two columns when the form is wide, one when it is narrow. |
FieldBlock / FieldStack
| Prop | Type | Default | Description |
|---|---|---|---|
| FieldBlock.title | string | — | Block heading. Omit for an untitled group of rows. |
| FieldBlock.children | ReactNode | — | The rows, 24px apart. Rules between blocks are the screen's call, via Separator. |
| FieldStack.children | ReactNode | — | The form root. Owns the rhythm between blocks: 40px on a page, 24px once the form is narrow. |
CheckboxReveal
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | The checkbox label. |
| optional | boolean | false | Appends a muted (optional) inline, after the label. |
| defaultChecked | boolean | false | Uncontrolled initial state. |
| checked | boolean | — | Controlled state. Pass with onCheckedChange. |
| onCheckedChange | (checked: boolean) => void | — | Fires on toggle. |
| children | ReactNode | — | The fieldset the checkbox governs. Only rendered while checked. |
RepeatableGroup
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | — | Group title, in the container's header. |
| addLabel | string | — | Copy on the add control, e.g. Add acquirer BIN. |
| renderItem | (index: number) => ReactNode | — | Renders one item's fields. Receives its 0-based position. |
| defaultCount | number | 1 | How many items to start with. |
| min | number | 1 | Below this count the delete control disappears. |
| max | number | — | Hides the add control once reached. |
FieldBar / FieldBarItem
| Prop | Type | Default | Description |
|---|---|---|---|
| FieldBar.hideLabels | boolean | false | Hides the labels on this line. Set it on every line after the first when the bar stacks. Ignored once the bar folds to 2x2, where every line keeps its labels. |
| FieldBar.trailing | ReactNode | — | Trailing control at the end of the line, e.g. the remove X on stacked lines. |
| FieldBarItem.label | string | — | The field label. |
| FieldBarItem.span | number | 3 | Relative width on one line, in the bar's 40px step. Ignored once folded, where every field takes half the card. |
| FieldBarItem.optional | boolean | false | Appends the muted (optional) marker inline. |
| FieldBarItem.children | ReactNode | — | The control. Gets the generated id so the label points at it. |
Related
Cross-links to the atoms this molecule composes and sibling patterns.
- FormThe react-hook-form binding: control, name and validation state. Different job. That one wires a field to a schema, this one lays it out.
- InputThe default control behind Field, and the source of the error border triplet.
- SelectThe control Field renders when you pass options.
- File uploaderThe field that takes a file. Drop it into a Field's children slot.
- Base connectionsThe provider setup wizard this vocabulary was extracted from.