---
title: Field
description: A labelled control with one line under it - the error is announced by being pointed at, not by being red.
source: https://adamjurek.com/components/field
---

## Home


Field pairs a label with a control and one line underneath it, showing either
a hint or, once one exists, an error - never both. Reach for it around any
form control that needs a label and a place to say what good input looks
like, or what went wrong.

<!-- ::start:showcase demo="field" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

The control nests inside the `<label>` itself rather than being linked to it
by an id, which is the association that survives a refactor untouched. An
error does not just turn the note red: `aria-describedby` wires the note to
the control so a screen reader announces it as part of describing the
control, and `data-invalid` on the label is what actually carries the colour
- removing the colour and keeping the wiring still works for someone using
assistive tech, and the reverse does not.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/field.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/field.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | content · Forms |
| Files | `field.tsx` |
| Dependencies | None |
| Tags | form, label, a11y, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { Field, Input } from "@sushindustries/ui";

export function Example() {
	return (
		<Field label="Email" hint="Only for the reply.">
			<Input type="email" placeholder="you@example.com" />
		</Field>
	);
}
```

## What you should see

A label above a control, with a small line of hint text underneath. Click
the label text itself, not just the input - it focuses the control, because
the control is nested inside the `<label>` rather than connected to it by an
id.

## If nothing happens

Clicking the label without focusing the control usually means the control
passed as `children` is not a real focusable form element - a `<div>`
standing in for an input has nothing for the native label association to
focus. Setting `error` replaces the hint text with the error message; the two
never show together.


## Guides


## The error replaces the hint, it does not join it

```tsx
<Field label="Handle" hint="Letters and numbers only." error={errors.handle}>
	<Input value={handle} onChange={onChange} />
</Field>
```

`error ?? hint` is the whole rule: whichever one is present is what shows,
never both, and a present `error` always wins. That is deliberate - a hint
explaining the rule and an error saying the rule was broken are the same
sentence said twice once something has actually gone wrong.

## Why the error is not just red text

`aria-describedby` points the control at the note - hint or error - so a
screen reader announces it as part of describing the control, not as
separate text that happens to sit nearby. `data-invalid` on the `<label>`
carries the colour. Removing the colour and keeping the wiring would still
work for someone using assistive tech; removing the wiring and keeping the
colour would not.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `label` | `string` | - |  |
| `children` | `ReactNode` | - | The control. Rendered inside the label, so clicking the text focuses it. |
| `hint?` | `string` | - | One line under the control: what good input looks like. |
| `error?` | `string` | - | The validation message. Its presence is the error state. |

<!-- /generated:api -->

## Notes

`hint` and `error` are one slot, not two: `error` wins whenever both are
set, and the loser is not rendered at all rather than hidden with CSS. There
is no prop for a persistent hint that survives alongside an error - if that
combination is needed, put both sentences in whichever one you pass.

`children` has to be a single element for the nesting association to work;
`Field` does not clone it or attach anything to it beyond wrapping it in a
`<label>`.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="field" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

```tsx
import { useState } from "react";
import { Button, Field, Input } from "@sushindustries/ui";

export function NewsletterForm() {
	const [email, setEmail] = useState("");
	const [error, setError] = useState<string>();

	return (
		<form
			className="flex col gap-4"
			onSubmit={(e) => {
				e.preventDefault();
				if (!email.includes("@")) {
					setError("That does not look like an email.");
					return;
				}
				setError(undefined);
			}}
		>
			<Field label="Email" hint="Once a month, at most." error={error}>
				<Input
					type="email"
					value={email}
					onChange={(e) => setEmail(e.target.value)}
				/>
			</Field>
			<Button type="submit">Subscribe</Button>
		</form>
	);
}
```

## What this example is not

Not the validation. `Field` only ever displays whatever string it is handed
as `error` - deciding when that string exists, and what it says, is the
form's job entirely.
