Field

A form field's layout, and the one place its state is written down.

import { Field } from "@delacour/native-ui/field";
All four
All four
import { Field } from "@delacour/native-ui/field";import { Input } from "@delacour/native-ui/input";import type { ReactElement } from "react";export function Demo(): ReactElement {	return (		<Field isInvalid>			<Field.Label>Password</Field.Label>			<Input defaultValue="hunter2" secureTextEntry />			<Field.Description>At least twelve characters.</Field.Description>			<Field.Error>That password is too short.</Field.Error>		</Field>	);}
<Field>
  <Field.Label>Email</Field.Label>
  <Input value={email} onChangeText={setEmail} />
  <Field.Description>We only use this to send receipts.</Field.Description>
</Field>

The state cascade

One flag, three things
One flag, three things
import { Field } from "@delacour/native-ui/field";import { Input } from "@delacour/native-ui/input";import type { ReactElement } from "react";export function Demo(): ReactElement {	return (		<Field isInvalid>			<Field.Label>Username</Field.Label>			<Input defaultValue="ada" />			<Field.Description>This is how other people will find you.</Field.Description>			<Field.Error>That username is taken.</Field.Error>		</Field>	);}

isInvalid and isDisabled live on the Field and reach the control inside it.

<Field isInvalid>
  <Field.Label>Email</Field.Label>
  <Input value={email} onChangeText={setEmail} />
  <Field.Error>{error}</Field.Error>
</Field>

The label turns danger, and so does the Input — with nothing said on the input itself. A control opts out explicitly: <Input isInvalid={false} />.

Why this is a context and not a selector

On the web, shadcn does this with group-data-[invalid=true]/field: — a parent-scoped selector. Uniwind has no equivalent: its compiler reads data-* off a single flat selector, and its runtime matches them against props on the component carrying the class. So no class on a Field can reach the Input inside it. There is no group-*, no peer-*, no :has().

A data-attribute class would also leave bun test behind. Even for a part styling itself, data-invalid:text-danger moves the decision into Uniwind's runtime matcher where no unit test can see it. The parts style themselves from tv() booleans; the context is only for crossing a component boundary.

The whole row drives the control

Once a control inside offers a press, the field becomes a Pressable with feedback="none" and the whole row calls it.

<Field orientation="horizontal">
  <Field.Content>
    <Field.Label>Accept the terms</Field.Label>
    <Field.Description>You can withdraw consent at any time.</Field.Description>
  </Field.Content>
  <Checkbox checked={accepted} onCheckedChange={setAccepted} />
</Field>

Tapping the label — or the description under it — ticks the checkbox. A checkbox in a form is a small square next to a sentence, and the sentence is what people aim at.

A field of static text registers nothing and stays a View. Mounting a gesture detector regardless would put one under every label in a form. The row is accessible={false} so the control stays the element a screen reader sees, and the inner detector claims a tap on the box itself rather than firing both.

Orientation

Horizontal, with a description
Horizontal, with a description
import { Checkbox } from "@delacour/native-ui/checkbox";import { Field } from "@delacour/native-ui/field";import type { ReactElement } from "react";import { View } from "react-native";export function Demo(): ReactElement {	return (		<View className="gap-4">			<Field orientation="horizontal">				<Field.Content>					<Field.Label>Sync across devices</Field.Label>					<Field.Description>Your drafts follow you to every device you sign in on.</Field.Description>				</Field.Content>				<Checkbox color="primary" defaultChecked testID="sync" />			</Field>			<Field orientation="horizontal">				<Field.Content>					<Field.Label>Delete after 30 days</Field.Label>					<Field.Description>Applies to items in the archive only.</Field.Description>				</Field.Content>				<Checkbox color="primary" testID="delete-after-30-days" />			</Field>		</View>	);}
<Field orientation="vertical">…</Field>
<Field orientation="horizontal">…</Field>

vertical stacks the label over the control. horizontal puts them side by side — use Field.Content to group the label with its description as one block.

Sets and groups

Set and legend
Set and legend
import { Field } from "@delacour/native-ui/field";import { Input } from "@delacour/native-ui/input";import type { ReactElement } from "react";export function Demo(): ReactElement {	return (		<Field.Set>			<Field.Legend>Billing address</Field.Legend>			<Field.Description>Where the invoice is sent, if it differs from the delivery address.</Field.Description>			<Field.Group>				<Field>					<Field.Label>Street</Field.Label>					<Input placeholder="12 Cuba Street" />				</Field>				<Field>					<Field.Label>City</Field.Label>					<Input placeholder="Wellington" />				</Field>			</Field.Group>		</Field.Set>	);}
<Field.Set>
  <Field.Legend>Notifications</Field.Legend>
  <Field.Description>Choose what reaches you, and how.</Field.Description>

  <Field.Group>
    <Field orientation="horizontal">
      <Field.Label>Email</Field.Label>
      <Checkbox checked={email} onCheckedChange={setEmail} />
    </Field>
    <Field orientation="horizontal">
      <Field.Label>Push</Field.Label>
      <Checkbox checked={push} onCheckedChange={setPush} />
    </Field>
  </Field.Group>
</Field.Set>

Field.Legend takes variant="label" for a nested set, so it matches the fields under it rather than titling them.

The gap ladder is the component

content 0.5 → root 1.5 → set 4 → group 5. A label attaches to the control beneath it rather than the one above purely because the gap inside a field is tighter than the gap between two, and nothing else is doing that work. The test pins the ordering, not the numbers.

Field.Group inserts no dividers, unlike ListGroup. A list of rows without lines is a wall of text; fields are already held apart by whitespace, and a rule between every one is noise.

A set holds no state. isInvalid and isDisabled live on each Field, because a whole section turning danger says less than the one field that is actually wrong.

Separators

Separator
Separator
import { Field } from "@delacour/native-ui/field";import { Input } from "@delacour/native-ui/input";import type { ReactElement } from "react";export function Demo(): ReactElement {	return (		<Field.Group>			<Field>				<Field.Label>Work email</Field.Label>				<Input inputMode="email" placeholder="ada@work.example" />			</Field>			<Field.Separator />			<Field>				<Field.Label>Personal email</Field.Label>				<Input inputMode="email" placeholder="ada@home.example" />			</Field>			<Field.Separator>Or continue with</Field.Separator>			<Field>				<Field.Label>Recovery code</Field.Label>				<Input autoCapitalize="characters" placeholder="XXXX-XXXX" />			</Field>		</Field.Group>	);}
<Field.Separator />
<Field.Separator>or</Field.Separator>

A labelled separator draws two rules with the label between them, not one rule with the label on top. The web version absolutely-positions a single rule and punches a hole in it with an opaque bg-background label — invisible only while the separator sits on exactly that colour. On a card or a sheet the hole shows as a block of the wrong shade. Two rules assume nothing about what is behind them.

Errors

<Field.Error>{error}</Field.Error>

Field.Error renders nothing when it has no children, so the line removes itself once the value is fixed.

It is deliberately not gated on isInvalid: a part that swallowed children you actually wrote, because of a prop on a sibling, would be a part whose absence is unexplainable from the call site.

Only the label fades when disabled. The control dims itself, and a dimmed description stacked on a dimmed control reads as two problems rather than one state. The description stays muted when invalid too, so an appearing Field.Error is the one line that changed.

Reading the field's state

import { useField, useFieldContext } from "@delacour/native-ui/field";

const { isInvalid, isDisabled, orientation } = useField();

useField() throws outside a <Field>. Use useFieldContext() where the field is optional — as every control does.

Anatomy

Prop

Type

API

FieldProps

React Native's ViewProps, plus:

Prop

Type

FieldTextProps

TextProps with variant omitted — each part already is one, and naming a second would let a caller turn a description into a heading and lose the type scale the component exists to keep.

FieldLegendProps

FieldTextProps, plus:

Prop

Type

useField()

Prop

Type

What it deliberately does not have

No Field.Title. On the web it exists because a <div> is not a <label> — label-styled text with nothing to point htmlFor at. React Native has neither element nor association, so it and Field.Label would render the same Text.

No errors array prop. shadcn's exists to accept react-hook-form and Standard Schema shapes, and this package takes no form dependency.

No type scale in the slots. The text parts render the Text presets and pass a colour, never a scale. A text-sm font-medium written into a slot here would be a second definition of Text.Label that could drift from it — the same reason Input ships no label part at all. A test asserts the text slots carry no size, weight or colour.

Exported constants

import {
  FIELD_ORIENTATIONS,
  FIELD_LEGEND_VARIANTS,
  FIELD_TEXT_PARTS,
  fieldVariants,
  resolveFieldTextColor,
  resolveFieldInteractive,
} from "@delacour/native-ui/field";

On this page