Field is a compound layout primitive for building a single form field. It groups Field.Label, a control (Input, Select, Checkbox, etc.), helper / hint text (Field.Description), and validation errors (Field.Errors, Field.ErrorList, Field.ErrorItem). Stack multiple fields together with Field.Group, or — for radios and related checkboxes specifically — wrap them in a Field.Set + Field.Legend for a properly-named semantic group.
Field.Label wraps the mantle Label for ergonomic field composition. It keeps the same click-to-focus, disabled, and typography behavior as Label; see the Label docs for the full surface.
Label-to-control association is automatic inside a Field.Item. Field.Item generates a stable control id, Field.Control splats that id onto its focusable child, and Field.Label consumes the same id as the default htmlFor. The required name on Field.Item is the single source of truth for the control's form name, so you can write <Field.Label>Email</Field.Label> next to <Field.Control><Input /></Field.Control> without threading a matching htmlFor / id pair (or a separate useId() call) at the call site. Pass an explicit htmlFor on Field.Label to opt out — for example when the focusable element is rendered outside of Field.Control and the auto-generated id never lands on it.
Wrap the focusable control in Field.Control so Field.Item can associate descriptions and rendered errors via aria-describedby / aria-errormessage. A rendered Field.Errors or Field.ErrorList infers error state; pass validation on Field.Item only when you need to override that state.
Field.Item owns the full control contract. When a focusable control is wrapped in Field.Control, the surrounding Field.Item owns its id, name, aria-describedby, aria-errormessage, aria-invalid, and validation — anything supplied on the child element (or on Field.Control) is overwritten. There's one place to override: Field.Item itself. To opt out of the contract entirely, render the control without Field.Control.
Pick something memorable — you can change it later.
1import { Field } from "@ngrok/mantle/field";2import { Input } from "@ngrok/mantle/input";3 4<Field.Item name="username" className="max-w-80">5 <Field.Label>Username</Field.Label>6 <Field.Control>7 <Input placeholder="ada-lovelace" />8 </Field.Control>9 <Field.Description>Pick something memorable — you can change it later.</Field.Description>10</Field.Item>;Use Field.Errors below the control when validation fails. It accepts string messages, trims them, filters empty values, removes duplicates, and renders a semantic <ul> with <li> items so screen readers announce the errors as a list. When the list renders, Field.Item infers error state so the visual state and ARIA invalid state stay in sync.
For custom list markup or rich error content, compose the lower-level Field.ErrorList and Field.ErrorItem parts directly. A single error is still just a list of one.
When a Field.Description follows directly after Field.Errors or Field.ErrorList, Field.Description automatically collapses the parent's gap-1.5 with a matching negative top margin so the errors + helper read as one tight block — matching the figma. Pass any margin utility on Field.Description (e.g. className="mt-1", className="mt-0") to override.
We'll never share your email.
1<Field.Item name="email">2 <Field.Label>Email</Field.Label>3 <Field.Control>4 <Input type="email" />5 </Field.Control>6 <Field.Errors messages={["Enter a valid email address."]} />7 <Field.Description>We'll never share your email.</Field.Description>8</Field.Item>Pass multiple messages to Field.Errors when a validator produces more than one message at once (e.g. minLength + pattern + custom rule). Items stack tightly so the whole error block reads as one unit.
Use a password manager to generate one.
1<Field.Item name="password">2 <Field.Label>Password</Field.Label>3 <Field.Control>4 <PasswordInput />5 </Field.Control>6 <Field.Errors7 messages={[8 "Must be at least 12 characters.",9 "Must include a number.",10 "Must include a symbol.",11 ]}12 />13 <Field.Description>Use a password manager to generate one.</Field.Description>14</Field.Item>When errors arrive from a validator as an array (e.g. field.state.meta.errors from TanStack Form), map them to messages and pass them to Field.Errors. Empty, blank, missing, or duplicate messages render nothing and do not add an error message reference. Reach for toErrorMessages to handle the mixed string / { message } / nullish shapes that TanStack Form yields — see With TanStack Form below.
Prefer Field.Errors when you already have plain string messages. Compose Field.ErrorList and Field.ErrorItem directly when you need custom list contents, richer message markup, or a polymorphic list element. Use one or the other in a Field.Item — Field.Item owns a single errors slot ID, and Field.Errors is itself a Field.ErrorList under the hood, so rendering both would duplicate the id on the page.
Pick something memorable.
1<Field.Item name="username">2 <Field.Label>Username</Field.Label>3 <Field.Control>4 <Input />5 </Field.Control>6 <Field.ErrorList>7 <Field.ErrorItem>Must be at least 3 characters.</Field.ErrorItem>8 <Field.ErrorItem>Use letters, numbers, hyphens, or underscores.</Field.ErrorItem>9 </Field.ErrorList>10 <Field.Description>Pick something memorable.</Field.Description>11</Field.Item>Use Field.Optional inside a <Field.Label> to mark a field as optional. It renders a muted "(Optional)" suffix at text-sm regular weight so it reads as secondary metadata without competing with the bolder label text. Defaults to the literal "(Optional)" — pass children to translate or replace.
Visible on your public profile.
1<Field.Item name="nickname">2 <Field.Label className="flex items-baseline gap-1">3 Nickname <Field.Optional />4 </Field.Label>5 <Field.Control>6 <Input placeholder="ada" />7 </Field.Control>8 <Field.Description>Visible on your public profile.</Field.Description>9</Field.Item>Place Field.Optional inside the <Field.Label> so screen readers announce it as part of the accessible name (e.g. "Nickname, Optional, edit text"). The label's flex items-baseline gap-1 keeps the suffix on the same baseline as the bolder label text.
When the label needs a sibling affordance that can't live inside the <Field.Label> itself — most commonly an interactive help-icon button — wrap the label and its decorations in Field.LabelRow. It's a horizontal flex container with items-center and gap-1 so the label, optional suffix, and help icon all sit on a shared center line. (Center alignment, not baseline — SVG icon buttons have no text baseline, so items-baseline would push them above the label text.)
Putting an interactive button inside <label> is a footgun: clicks on a <label> forward focus to the associated control, so a help button nested in a <Field.Label> would steal that behavior. Field.LabelRow is the way out.
Reach for the bundled Field.Help / Field.HelpTrigger / Field.HelpContent rather than wiring Popover + IconButton + an icon by hand — it's the same composition, three thin wrappers, with sensible visual defaults (ghost IconButton, xs size, default Phosphor QuestionIcon, text-body color). Field.HelpTrigger still requires a contextual accessible label so repeated help icons do not all announce the same thing. Popover under the hood — not Tooltip — so the affordance is reachable on touch devices.
You can find this in the ngrok dashboard.
1<Field.Item name="apiKey">2 <Field.LabelRow>3 <Field.Label className="flex items-baseline gap-1">4 API key <Field.Optional />5 </Field.Label>6 <Field.Help>7 <Field.HelpTrigger label="What is an API key?" />8 <Field.HelpContent>Copy this from the ngrok dashboard.</Field.HelpContent>9 </Field.Help>10 </Field.LabelRow>11 <Field.Control>12 <Input />13 </Field.Control>14 <Field.Description>You can find this in the ngrok dashboard.</Field.Description>15</Field.Item>Field.HelpTrigger accepts every IconButton prop, so swap the icon or change the appearance without dropping down to raw Popover:
1<Field.Help>2 <Field.HelpTrigger icon={<InfoIcon />} label="What is a webhook secret?" />3 <Field.HelpContent>4 Used to sign outbound webhook payloads so your endpoint can verify the request.5 </Field.HelpContent>6</Field.Help>Wrap multiple Field.Items in a Field.Group to stack them with consistent gap-4 spacing. This is the default for any form with more than one field — pure layout, no <fieldset> semantics.
At least 12 characters.
1<Field.Group>2 <Field.Item name="email">3 <Field.Label>Email</Field.Label>4 <Field.Control>5 <Input type="email" />6 </Field.Control>7 </Field.Item>8 <Field.Item name="password">9 <Field.Label>Password</Field.Label>10 <Field.Control>11 <PasswordInput />12 </Field.Control>13 <Field.Description>At least 12 characters.</Field.Description>14 </Field.Item>15</Field.Group>Field.Set renders a real <fieldset> — paired with Field.Legend, it gives a related set of controls a single accessible name (so a screen reader announces "Notification frequency, group, Daily"). Reach for it specifically when the grouping is itself the question the user is answering: most commonly a RadioGroup (one question, many possible answers), or a set of related checkboxes (preference toggles, permission flags, opt-ins).
When not to use it. Stacking unrelated fields under a shared header is not a semantic group — Field.Group (above) is the right call. A <fieldset> around plain inputs reads as noise to assistive tech and looks visually heavy. If you find yourself reaching for Field.Set to add a section title to a form, render a regular heading instead.
Keep the Field.Legend self-contained: it is the accessible name for the group. Field.Set does not auto-associate Field.Description; if a group needs extra instructions announced by assistive technology, wire your own description with aria-describedby.
1<Field.Set>2 <Field.Legend>Notification frequency</Field.Legend>3 <RadioGroup.Root name="frequency" defaultValue="daily">4 <RadioGroup.Item value="daily" id="freq-daily">5 <RadioGroup.Indicator />6 <RadioGroup.ItemContent asChild>7 <label htmlFor="freq-daily">Daily</label>8 </RadioGroup.ItemContent>9 </RadioGroup.Item>10 <RadioGroup.Item value="weekly" id="freq-weekly">11 <RadioGroup.Indicator />12 <RadioGroup.ItemContent asChild>13 <label htmlFor="freq-weekly">Weekly</label>14 </RadioGroup.ItemContent>15 </RadioGroup.Item>16 <RadioGroup.Item value="never" id="freq-never">17 <RadioGroup.Indicator />18 <RadioGroup.ItemContent asChild>19 <label htmlFor="freq-never">Never</label>20 </RadioGroup.ItemContent>21 </RadioGroup.Item>22 </RadioGroup.Root>23</Field.Set>Field is designed to slot directly into TanStack Form. Pass field.name as the name prop on Field.Item — it splats onto the focusable control inside Field.Control and generates a stable id that wires Field.Label ↔ control association automatically. Wrap the input in Field.Control so descriptions and rendered errors are associated with the control.
field.state.meta.errors is a mixed array — TanStack Form yields plain strings, { message } issue objects (Zod / Standard Schema), thrown Error instances, and falsy slots all in one place. Pass it through toErrorMessages to get a clean string[] for Field.Errors. The list renders nothing when the result is empty, and infers error state only when it has messages, so it's safe to leave mounted unconditionally:
1import { Field, toErrorMessages } from "@ngrok/mantle/field";2import { Input, PasswordInput } from "@ngrok/mantle/input";3import { Button } from "@ngrok/mantle/button";4import { useForm } from "@tanstack/react-form";5import { z } from "zod";6 7const schema = z.object({8 email: z.email("Enter a valid email."),9 password: z.string().min(12, "Must be at least 12 characters."),10});11 12export function AccountForm() {13 const form = useForm({14 defaultValues: { email: "", password: "" },15 validators: { onChange: schema },16 onSubmit: ({ value }) => {17 window.alert(JSON.stringify(value, null, 2));18 },19 });20 21 return (22 <form23 className="flex flex-col gap-4"24 onSubmit={(event) => {25 event.preventDefault();26 event.stopPropagation();27 void form.handleSubmit();28 }}29 >30 <Field.Group>31 <form.Field name="email">32 {(field) => (33 <Field.Item name={field.name}>34 <Field.Label>Email</Field.Label>35 <Field.Control>36 <Input37 type="email"38 value={field.state.value}39 onBlur={field.handleBlur}40 onChange={(event) => field.handleChange(event.target.value)}41 />42 </Field.Control>43 <Field.Errors messages={toErrorMessages(field.state.meta.errors)} />44 <Field.Description>We'll never share your email.</Field.Description>45 </Field.Item>46 )}47 </form.Field>48 <form.Field name="password">49 {(field) => (50 <Field.Item name={field.name}>51 <Field.Label>Password</Field.Label>52 <Field.Control>53 <PasswordInput54 value={field.state.value}55 onBlur={field.handleBlur}56 onChange={(event) => field.handleChange(event.target.value)}57 />58 </Field.Control>59 <Field.Errors messages={toErrorMessages(field.state.meta.errors)} />60 <Field.Description>At least 12 characters.</Field.Description>61 </Field.Item>62 )}63 </form.Field>64 </Field.Group>65 <Button type="submit" appearance="filled" priority="neutral">66 Submit67 </Button>68 </form>69 );70}toErrorMessagestoErrorMessages is a convenience — Field.Errors itself only needs a string[]. If you already know your validator's error shape (or you want to filter / re-format messages first), map the array inline and skip the helper. The example below mirrors the TanStack Form example above, but pulls .message off each entry by hand:
1<form.Field name="email">2 {(field) => (3 <Field.Item name={field.name}>4 <Field.Label>Email</Field.Label>5 <Field.Control>6 <Input7 type="email"8 value={field.state.value}9 onBlur={field.handleBlur}10 onChange={(event) => field.handleChange(event.target.value)}11 />12 </Field.Control>13 <Field.Errors14 messages={field.state.meta.errors.map((error) =>15 typeof error === "string" ? error : error?.message,16 )}17 />18 </Field.Item>19 )}20</form.Field>Field.Errors already trims each string, filters empty / nullish / false entries, and renders nothing when no messages remain — so passing undefined / empty strings through the .map is safe and behaves the same as filtering them out. Use the inline form when the helper's mixed-shape handling isn't pulling its weight (e.g. a validator that always emits { message: string }, where a bare error?.message map is enough), and reach for toErrorMessages when you want a single call that handles the full TanStack Form error array shape.
Most forms only need a Field.Group of Field.Items — that's the default tree. Each Field.Item may render at most one Field.Description and one Field.Errors or one Field.ErrorList (not both) — they share a single slot ID owned by the item.
Field.Group└── Field.Item ├── Field.LabelRow │ ├── Field.Label │ │ └── Field.Optional │ └── Field.Help │ ├── Field.HelpTrigger │ └── Field.HelpContent ├── Field.Control │ └── (control) ├── Field.Errors (or) ├── Field.ErrorList │ └── Field.ErrorItem └── Field.DescriptionFor radios or related checkboxes — where the grouping is itself the question being answered — wrap the controls in a semantic Field.Set + Field.Legend instead:
Field.Set├── Field.Legend└── (RadioGroup / Checkbox group)Skip Field.Set for unrelated fields stacked together — <fieldset> semantics around plain inputs read as noise to assistive tech.
Layout parts plus Field.Description, Field.Optional, and Field.ErrorList accept an asChild prop. When true, the part renders its single child instead of its default element, forwarding all class names, data-* attributes, and the ref onto that child. Field.Set, Field.Legend, and Field.ErrorItem always render <fieldset>, <legend>, and <li> respectively, because those elements are the semantics those parts exist to provide.
1<Field.ErrorList asChild>2 <ol>3 <Field.ErrorItem>Fix this first.</Field.ErrorItem>4 <Field.ErrorItem>Then fix this.</Field.ErrorItem>5 </ol>6</Field.ErrorList>Field.Label wraps Label and defaults htmlFor from the surrounding Field.Item. Use it inside Field.Item to give the control an accessible name and click-to-focus behavior.
See the Label docs for association patterns, disabled state, and typography behavior.
All props from Label.
A single form field. Renders a plain <div> with flex flex-col gap-1.5 so Field.Label, control, and helper / error siblings stack tightly together. No implicit ARIA role — the <label htmlFor> ↔ control association already provides the right semantics for a single field. Provides description/error IDs and validation state to Field.Control; rendered errors infer "error" unless validation is provided.
Single-slot constraint. A Field.Item owns one description ID and one errors ID, so render at most one Field.Description and one Field.Errors or Field.ErrorList (not both) per item. A second instance would duplicate the slot id in the DOM — pass multiple messages to one Field.Errors, or multiple Field.ErrorItem children to one Field.ErrorList, instead.
When to use: for every individual field — text inputs, selects, single checkboxes, switches, password inputs, etc.
All props from div, plus:
Applies Field.Item description, error, identity, and validation state to a single control. It always behaves like an asChild slot: pass one child element to receive the generated props, or use a function child to place those props manually. Mantle compound controls that consume FieldControlContext can be wrapped at the root when their docs show that composition; otherwise use the render-prop form to place the generated props on the actual focusable element. Override validation on the surrounding Field.Item.
1<Field.Item name="email">2 <Field.Label>Email</Field.Label>3 <Field.Control>4 <Input />5 </Field.Control>6</Field.Item>7 8<Field.Item name="plan">9 <Field.Label>Plan</Field.Label>10 <Select.Root>11 <Field.Control>12 <Select.Trigger>13 <Select.Value placeholder="Select a plan" />14 </Select.Trigger>15 </Field.Control>16 <Select.Content>{/* ... */}</Select.Content>17 </Select.Root>18</Field.Item>Use a function child when the focusable element is nested inside a wrapper that Slot cannot reach — for example a <label>-wrapped native checkbox where the <input> is the focusable target but the outer <label> is the rendered element. The function receives the generated ARIA props and the caller spreads them onto the inner control.
1<Field.Item name="termsAccepted">2 <Field.Control>3 {(controlProps) => (4 <label className="flex items-center gap-2">5 <input type="checkbox" {...controlProps} />6 Accept the terms of service7 </label>8 )}9 </Field.Control>10 <Field.Errors messages={["You must accept the terms to continue."]} />11</Field.Item>Layout container that stacks multiple Field.Items vertically with gap-4. Renders a plain <div> — pure layout, no semantics.
When to use: any time a form has more than one field. This is the default way to compose multiple Field.Items. Reach for Field.Set + Field.Legend instead only when the grouping itself carries semantic weight (radios, related checkboxes).
All props from div, plus:
Renders a semantic <fieldset> with default browser styling reset (no border, no padding, min-width: 0). Pair with Field.Legend to give the surrounding controls a single accessible name.
When to use: specifically for radios (one question, many possible answers) or sets of related checkboxes (preference toggles, permission flags). Skip it for stacking unrelated fields — Field.Group is the right call there. A <fieldset> around plain inputs reads as noise to assistive tech.
Field.Set does not automatically associate Field.Description. If the group needs additional announced instructions beyond the legend, pass aria-describedby to Field.Set and point it at your own text element.
All props from fieldset.
Caption for a Field.Set. Renders a <legend> styled to match the mantle Label typography. Always render a Field.Legend inside a Field.Set — it's what gives the surrounding fieldset its accessible name.
Carries a default mb-1.5 so the legend sits 6px above the next sibling. The margin is on the legend (not the parent Field.Set's flex gap) because <legend> has special browser rendering inside a <fieldset> that ignores the parent's flex gap. Override with any mb-* utility passed on Field.Legend.
All props from legend.
Horizontal layout container for the label area of a field — a flex row with items-center and gap-1. Use when the label needs a sibling affordance that can't live inside the <Field.Label> itself, like a help-icon Field.HelpTrigger. Center-aligned (not baseline) so SVG icon buttons sit visually centered next to the label text.
All props from div, plus:
Popover.Root wrapper for the help-affordance pattern. Composes with Field.HelpTrigger and Field.HelpContent. Forwards all Popover.Root props (open, defaultOpen, onOpenChange, modal, etc.).
Popover.Trigger wired to a ghost-appearance IconButton with a default Phosphor QuestionIcon. Requires a contextual accessible label. Pre-styled with text-body so the icon reads as subtle metadata at rest; IconButton's ghost hover:text-strong brightens it on interaction. Carries a default -my-0.5 so the 24px xs button keeps a full click target while contributing only 20px to the Field.LabelRow flex line — matching the label's text line-height so the label is not pushed off-center.
Accepts every IconButton prop, with sensible visual defaults. label remains required:
Body of a Field.Help popover. Forwards every Popover.Content prop — see the Popover docs for the full surface (side, align, preferredWidth, etc.).
All props from div, plus:
Inline "(Optional)" suffix to mark a field as optional. Renders a <span> in text-muted at text-sm / font-normal. Default content is the literal string "(Optional)" — pass children to translate or replace.
All props from span, plus:
Helper / hint text. Renders a <p> in the muted body color. Use inside Field.Item, below the control, to clarify expected format or constraints for that single field. Render at most one per Field.Item — Field.Item owns a single description slot ID and applies it via context.
When rendered inside Field.Item, Field.Description receives an automatic generated ID that Field.Control merges into the control's aria-describedby. The ID is owned by Field.Item and is not consumer-overridable. When Field.Description is rendered immediately after Field.Errors or Field.ErrorList, it automatically collapses the parent's gap-1.5 via a -mt-1.5 so errors + helper read as a single tight block. The rule's specificity is flattened to (0,1,0) so any margin utility you pass on Field.Description (e.g. mt-1, mt-0) overrides cleanly.
All props from p except id, plus:
Convenience renderer for validation messages. Accepts an array of strings, trims each message, filters empty, nullish, and false values, then renders a Field.ErrorList with one Field.ErrorItem per remaining message. When rendered inside Field.Item, a non-empty list receives an automatic generated ID and infers "error" validation unless Field.Item overrides it. Field.Control merges that ID into the control's aria-describedby and aria-errormessage when the field is invalid. The ID is owned by Field.Item and is not consumer-overridable.
Render at most one Field.Errors per Field.Item, and do not combine it with Field.ErrorList in the same item — both render a single shared errors slot. Pass multiple messages to one Field.Errors for the multi-error case.
Field.Errors owns its generated list items and does not accept children or asChild. Use Field.ErrorList / Field.ErrorItem directly when you need custom list contents or polymorphic list markup.
All props from ul except children and id, plus:
A single error message for a field. Renders an <li> in text-danger-600, so it must be nested inside a Field.ErrorList. Empty or blank children render nothing. A single error is just a list of one.
All props from li.
Wraps one or more Field.ErrorItem children in a semantic <ul> with role="list" so a list of validation errors is announced as a list by screen readers, including Safari/VoiceOver combinations that drop list semantics when list styling is removed. Renders nothing when given no renderable children. When rendered inside Field.Item, a non-empty list receives an automatic generated ID and infers "error" validation unless Field.Item overrides it. Field.Control merges that ID into the control's aria-describedby and aria-errormessage when the field is invalid. The ID is owned by Field.Item and is not consumer-overridable.
Render at most one Field.ErrorList per Field.Item, and do not combine it with Field.Errors in the same item — both render a single shared errors slot. Put multiple Field.ErrorItem children inside one list for the multi-error case.
Strips the default browser <ul> styling (no bullets, no padding, no margin) and stacks items as a flex column with no gap so consecutive errors read as a single tight block. Pass asChild with an <ol> if the list is meaningfully ordered.
All props from ul except id, plus:
Normalize a TanStack React Form field's meta.errors array (or any mixed array of string / { message } / nullish / false entries) into a clean string[] ready to pass to Field.Errors. Trims whitespace, drops empty / whitespace-only entries, and removes duplicates so callers don't have to filter again.
Use this for TanStack Form with Zod, Standard Schema, or thrown Error validators — field.state.meta.errors is a union of all of those shapes, and this helper folds them down to plain strings without coupling Mantle to a specific form library. For other validators where the error shape is already a string[], skip the helper and pass the array directly.
1import { Field, toErrorMessages } from "@ngrok/mantle/field";2 3<Field.Errors messages={toErrorMessages(field.state.meta.errors)} />;Returns string[]. FieldError is { message?: string } \| string \| null \| undefined \| false.