nori-ui

Field

Layout and accessibility envelope that wires a label, description, error, and a form control together without managing form state.

4.4 kBgzipped

<Field> is a layout and accessibility envelope. It generates stable IDs, wires aria-labelledby, aria-describedby, aria-invalid, and aria-required onto the wrapped control, and provides visual slots for a label, hint text, and an error message. It does not own form state — you bring your own state management and pass error down from wherever validation lives.

Field has two API modes: shorthand (the common case) and compound (an escape hatch for custom layout). The shorthand API takes label, description, and error as props directly on <Field>, so the child is just the control. The compound API uses explicit sub-components (<Field.Label>, <Field.Description>, <Field.Control>, <Field.Error>) for cases that need a non-standard slot layout.

Field is form-framework agnostic. It works equally well with React Hook Form, Zod + manual state, or no library at all. The RHF pattern using <Controller> is shown in the react-hook-form section.

Basic usage

Pass label and place the control as the child.

Direction:
import { Field, TextInput } from '@nori-ui/core';
 
export const Example = () => (
    <Field label="Email">
        <TextInput placeholder="you@example.com" />
    </Field>
);

Field injects id, aria-labelledby, and related ARIA attributes onto the child control automatically. The child does not need to set them manually.

With description and error

Pass description and error as props:

Direction:
import { Field, TextInput } from '@nori-ui/core';
import { useState } from 'react';
 
export default function UsernameField() {
    const [value, setValue] = useState('');
    const [error, setError] = useState<string | null>(null);
 
    return (
        <Field
            label="Username"
            description="3 to 20 characters. Letters, numbers, and underscores only."
            error={error}
        >
            <TextInput value={value} onChangeText={setValue} placeholder="your_username" />
        </Field>
    );
}

When error is set, Field renders the error message below the control and sets aria-invalid on the child. When both description and error are present, aria-describedby contains both IDs — assistive technology reads both.

Required

Pass required:

import { Field, TextInput } from '@nori-ui/core';
 
export const Example = () => (
    <Field label="Email" required>
        <TextInput placeholder="you@example.com" />
    </Field>
);

aria-required is forwarded to the control. Field renders a visual indicator (a red asterisk by default) next to the label text, with a screen-reader-only accessible label. Both strings are configurable via the NoriProvider dictionary:

KeyDefaultPurpose
field.requiredIndicator*Visual character appended to the label
field.requiredLabelrequiredAccessible label on the indicator span
import { NoriProvider } from '@nori-ui/core/client';
 
export const App = () => (
    <NoriProvider dictionary={{ field: { requiredIndicator: '(required)', requiredLabel: 'required' } }}>
        {/* ... */}
    </NoriProvider>
);

Disabled

Pass disabled. The flag propagates to the child control and dims the label:

import { Field, TextInput } from '@nori-ui/core';
 
export const Example = () => (
    <Field label="Email" disabled>
        <TextInput defaultValue="locked@example.com" />
    </Field>
);

The child component's own disabled prop is OR-ed with the field-level value, so you can also disable the control directly without going through the field.

Horizontal orientation

orientation="horizontal" renders the label and control side-by-side:

import { Field, TextInput } from '@nori-ui/core';
 
export const Example = () => (
    <Field label="Name" orientation="horizontal">
        <TextInput placeholder="Your name" />
    </Field>
);

The default is "vertical".

Validating state

While an async validation is in flight, pass validating to show a spinner and set aria-busy:

import { Field, TextInput } from '@nori-ui/core';
 
export const Example = () => (
    <Field label="Username" description="Checking availability…" validating>
        <TextInput defaultValue="claude" />
    </Field>
);

validating and error can coexist — the spinner renders alongside the error message until the new result arrives.

Field.Group

Field.Group is a fieldset-style envelope for grouped controls such as radio sets, checkbox groups, or multi-switch rows. It renders a role="group" container labelled by its own label — equivalent to <fieldset> + <legend> semantics, but using <View> so layout is not constrained by browser fieldset quirks.

Direction:
import { Field, Radio } from '@nori-ui/core';
import { useState } from 'react';
 
export default function PlanPicker() {
    const [value, setValue] = useState<string | undefined>(undefined);
    const error = value === undefined ? 'Please select a plan.' : null;
    return (
        <Field.Group label="Plan" description="Pick the tier that fits your team." required error={error}>
            <Radio.Group value={value} onChange={setValue} name="plan">
                <Radio value="hobby" label="Hobby" />
                <Radio value="pro" label="Pro" />
                <Radio value="enterprise" label="Enterprise" />
            </Radio.Group>
        </Field.Group>
    );
}

Field.Group accepts the same shorthand props as Field (label, description, error, required, disabled, orientation) except name. Pass name directly to the inner input group.

Use <Controller> to connect Field to React Hook Form. Pass field.value, field.onChange, and fieldState.error?.message through:

import { Field, TextInput } from '@nori-ui/core';
import { useForm, Controller } from 'react-hook-form';
 
type FormValues = {
    email: string;
};
 
export default function SignUpForm() {
    const { control, handleSubmit } = useForm<FormValues>({
        defaultValues: { email: '' },
    });
 
    const onSubmit = (data: FormValues) => {
        console.log(data);
    };
 
    return (
        <form onSubmit={handleSubmit(onSubmit)}>
            <Controller
                control={control}
                name="email"
                rules={{ required: 'Email is required.', pattern: { value: /\S+@\S+/, message: 'Not a valid email.' } }}
                render={({ field, fieldState }) => (
                    <Field
                        label="Email"
                        required
                        error={fieldState.error?.message ?? null}
                    >
                        <TextInput
                            value={field.value}
                            onChangeText={field.onChange}
                            placeholder="you@example.com"
                        />
                    </Field>
                )}
            />
            <button type="submit">Sign up</button>
        </form>
    );
}

To inject a server-side error after submission, call setError:

import { useForm } from 'react-hook-form';
 
const { setError } = useForm<FormValues>();
 
// After a failed API call:
setError('email', { message: 'That email is already registered.' });

react-hook-form is not a peer dependency of @nori-ui/core. Install it separately:

yarn add react-hook-form

Manual / framework-agnostic

Field works without any form library. Use useState for local state and pass a derived error string:

import { Field, TextInput } from '@nori-ui/core';
import { useState } from 'react';
 
export default function EmailField() {
    const [value, setValue] = useState('');
    const [touched, setTouched] = useState(false);
 
    const error =
        touched && value.length === 0
            ? 'Email is required.'
            : touched && !value.includes('@')
              ? 'Not a valid email address.'
              : null;
 
    return (
        <Field label="Email" required error={error}>
            <TextInput
                value={value}
                onChangeText={(v) => { setValue(v); setTouched(true); }}
                placeholder="you@example.com"
            />
        </Field>
    );
}

Custom layout (compound API)

When you need full control over slot placement — a control + button row, animated error transitions, or a field with multiple controls — use the compound sub-components directly:

import { Field, TextInput } from '@nori-ui/core';
import { View } from 'react-native';
 
export const Example = () => (
    <Field>
        <Field.Label>Invite code</Field.Label>
        <Field.Description>Check your welcome email for your code.</Field.Description>
        <View style={{ flexDirection: 'row', gap: 8 }}>
            <Field.Control>
                <TextInput placeholder="XXXX-XXXX" style={{ flex: 1 }} />
            </Field.Control>
            <button type="button">Paste</button>
        </View>
        <Field.Error />
    </Field>
);

When to reach for the compound API:

  • The control sits inside a row alongside other elements (e.g., a button).
  • You need to animate the error message in/out independently.
  • You have multiple controls sharing one label (e.g., first + last name in a single row) and need to manage the id wiring yourself.

Field.Control clones its single child and injects id, aria-labelledby, and related ARIA attributes. Pass the error prop on <Field> even in compound mode — Field.Control reads it to set aria-invalid, and Field.Error reads it to render the message.

Standalone Label

For cases where <Field> is too much — standalone toggle rows or settings entries — use <Label> directly. It renders the same visual style and required indicator as Field.Label, wired by an explicit htmlFor instead of auto-generated IDs.

API reference

<Field>

PropTypeDefaultDescription
classNamestring
descriptionReactNode
disabledbooleanfalse
errorReactNodenull
idstring
labelReactNode
namestring
orientationenumvertical
requiredbooleanfalse
testIDstring
validatingbooleanfalse

<Field.Label>

Renders a label text node wired to the field's generated ID. Clicking it moves focus to the control. Used only in the compound API.

PropTypeDefaultDescription
childrenReactNodeLabel text.

<Field.Description>

Renders hint copy below the label. Its ID is wired to the control's aria-describedby. Used only in the compound API.

PropTypeDefaultDescription
childrenReactNodeHint text.

<Field.Error>

Renders the error message in the danger tone. Returns null when there is no error and no children. Has role="alert" on web. Used only in the compound API — in shorthand mode the error slot is rendered automatically when error is set.

PropTypeDefaultDescription
childrenReactNodeOverride the message from the error prop on <Field>.

<Field.Control>

Clones its single child element and injects accessibility props. Used only in the compound API.

PropTypeDefaultDescription
childrenReactElementA single form control element.

Injected props (your control receives these automatically):

PropSet when
idAlways (uses Field's generated ID)
aria-labelledbyAlways
aria-describedbyField.Description or Field.Error is present
aria-invaliderror is truthy
aria-requiredrequired is true
disableddisabled is true on Field
namename prop on Field is set and child has no own name

<Field.Group>

Fieldset-style group container for sets of related controls (radio buttons, checkboxes, multi-switch rows). Accepts the same shorthand props as <Field>:

PropTypeDefaultDescription
labelstringVisible group heading (rendered as <legend> equivalent).
descriptionstringnullHint text below the label.
errorstring | nullnullValidation error message. Sets aria-invalid on inner controls.
requiredbooleanfalseMarks the group required. Appends visual indicator and sets aria-required.
disabledbooleanfalseDisables all inner controls.
orientation"vertical" | "horizontal""vertical"Stack direction for label and content.
childrenReactNodeThe grouped controls (e.g., Radio.Group, checkbox list).
Preview theme