# Field

> Label, control, description and error message laid out and related with author ids; the application renders the error with aria-invalid.

Page: https://svelte-lean.vercel.app/primitives/field · Markdown: https://svelte-lean.vercel.app/primitives/field.md · Index: https://svelte-lean.vercel.app/llms.txt

A label, a control, a hint and an error message laid out together and related through the platform’s label and aria-describedby.

```svelte
<script lang="ts">
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';
</script>

<div data-slean="field">
	<label for="field-email" data-slean-part="label">Email</label>
	<input
		id="field-email"
		type="email"
		name="email"
		autocomplete="email"
		aria-describedby="field-email-description"
		data-slean="input"
	/>
	<p id="field-email-description" data-slean-part="description">
		The receipt is sent to this address.
	</p>
</div>
```

Click the label to focus the input; the hint is read as its description.

## Examples

### Validation

```svelte
<script lang="ts">
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';

	const taken = ['ada', 'grace', 'alan'];
	let username = $state('ada');
	const error = $derived(
		!/^[a-z0-9-]*$/i.test(username)
			? 'Use letters, digits and hyphens only.'
			: taken.includes(username.toLowerCase())
				? 'This username is taken.'
				: ''
	);
</script>

<!-- The error part and aria-invalid exist only while there is an error. -->
<div data-slean="field">
	<label for="field-username" data-slean-part="label">Username</label>
	<input
		id="field-username"
		name="username"
		autocomplete="username"
		bind:value={username}
		aria-describedby={error
			? 'field-username-description field-username-error'
			: 'field-username-description'}
		aria-invalid={error ? 'true' : undefined}
		data-slean="input"
	/>
	<p id="field-username-description" data-slean-part="description">
		Letters, digits and hyphens.
	</p>
	{#if error}
		<p id="field-username-error" data-slean-part="error">{error}</p>
	{/if}
</div>
```

Type a free username to remove the error. The accessible description is the hint followed by the error.

### Validation messages from the browser

```svelte
<script lang="ts">
	// data-slean-validate makes @svelte-lean/vite add the field/validation option module.
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';
</script>

<!-- The browser decides and words the message; the field shows it under the control. -->
<form class="signup" onsubmit={(event) => event.preventDefault()}>
	<div data-slean="field" data-slean-validate>
		<label for="field-signup-email" data-slean-part="label">Email</label>
		<input
			id="field-signup-email"
			type="email"
			name="email"
			required
			aria-describedby="field-signup-email-description"
			data-slean="input"
		/>
		<p id="field-signup-email-description" data-slean-part="description">
			The receipt is sent here.
		</p>
	</div>
	<div data-slean="field" data-slean-validate>
		<label for="field-signup-handle" data-slean-part="label">Handle</label>
		<input
			id="field-signup-handle"
			name="handle"
			required
			minlength="3"
			pattern="[a-z0-9-]+"
			data-slean="input"
		/>
	</div>
	<button type="submit" data-slean="button">Create account</button>
</form>

<style>
	.signup {
		display: grid;
		justify-items: start;
		gap: 1rem;
		max-inline-size: 22rem;
	}

	.signup > [data-slean='field'] {
		justify-self: stretch;
	}
</style>
```

Submit empty, or type a short handle and leave it: the browser's own message shows under the field, the first invalid field takes focus, and a valid value clears it.

### Horizontal

```svelte
<script lang="ts">
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';
</script>

<div class="stack">
	<div data-slean="field" data-slean-orientation="horizontal">
		<label for="field-name" data-slean-part="label">Display name</label>
		<input
			id="field-name"
			name="name"
			autocomplete="nickname"
			aria-describedby="field-name-description"
			data-slean="input"
		/>
		<p id="field-name-description" data-slean-part="description">Shown on your posts.</p>
	</div>

	<div data-slean="field" data-slean-orientation="horizontal">
		<label for="field-website" data-slean-part="label">Website</label>
		<input id="field-website" type="url" name="website" autocomplete="url" data-slean="input" />
	</div>
</div>

<style>
	.stack {
		display: grid;
		gap: 1.25rem;
		max-inline-size: 32rem;
	}
</style>
```

The label column is up to 10rem wide and aligned with the first line of the control.

### Group control

```svelte
<script lang="ts">
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/radio-group.css';
</script>

<!-- A fieldset is named by its legend: no label part, and aria-describedby on the fieldset. -->
<div data-slean="field">
	<fieldset data-slean="radio-group" data-variant="segmented" aria-describedby="field-units-description">
		<legend>Units</legend>
		<label><input type="radio" name="field-units" value="metric" checked /> Metric</label>
		<label><input type="radio" name="field-units" value="imperial" /> Imperial</label>
	</fieldset>
	<p id="field-units-description" data-slean-part="description">
		Used for distances and weights.
	</p>
</div>
```

### Character count

```svelte
<script lang="ts">
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';

	const max = 160;
	let bio = $state('Maps tides for the harbour office.');
</script>

<div data-slean="field">
	<label for="field-bio" data-slean-part="label">Bio</label>
	<textarea
		id="field-bio"
		name="bio"
		maxlength={max}
		bind:value={bio}
		aria-describedby="field-bio-description"
		data-slean="input"
	></textarea>
	<p id="field-bio-description" data-slean-part="description">
		{max - bio.length} characters left.
	</p>
</div>
```

The application writes the count into the description; maxlength stops the typing.

### Disabled and read-only

```svelte
<script lang="ts">
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';
</script>

<!-- A disabled control mutes its label; a read-only one stays focusable and is submitted. -->
<div class="stack">
	<div data-slean="field">
		<label for="field-plan" data-slean-part="label">Plan</label>
		<input id="field-plan" name="plan" value="Team" disabled data-slean="input" />
	</div>

	<div data-slean="field">
		<label for="field-workspace" data-slean-part="label">Workspace ID</label>
		<input
			id="field-workspace"
			name="workspace"
			value="ws-4512"
			readonly
			aria-describedby="field-workspace-description"
			data-slean="input"
		/>
		<p id="field-workspace-description" data-slean-part="description">Set when it was created.</p>
	</div>
</div>

<style>
	.stack {
		display: grid;
		gap: 1.25rem;
		max-inline-size: 32rem;
	}
</style>
```

## Helper or plain markup

Two ways to write the same markup: with `fieldParts()` or by hand. Both render the same HTML, which a test of this site compares; use either.

`With fieldParts()`

```svelte
<script lang="ts">
	import { fieldParts } from '@svelte-lean/primitives/field';
	const f = fieldParts('username', {
		description: true,
		error: true
	});
</script>

<div data-slean="field">
	<label {...f.label}>Username</label>
	<input {...f.control} name="username" data-slean="input" />
	<p {...f.description}>Letters, digits and hyphens.</p>
	<p {...f.error}>This username is taken.</p>
</div>
```

`Plain markup`

```html
<div data-slean="field">
	<label for="username" data-slean-part="label">Username</label>
	<input
		id="username"
		aria-describedby="username-description username-error"
		aria-invalid="true"
		name="username"
		data-slean="input"
	/>
	<p id="username-description" data-slean-part="description">
		Letters, digits and hyphens.
	</p>
	<p id="username-error" data-slean-part="error">
		This username is taken.
	</p>
</div>
```

### What the helper does

- One id in, every reference out: `fieldParts('username', { description: true, error: true })` writes `username-description` and `username-error` and lists both, in that order, in the control’s `aria-describedby`.
- Strings only: no handler, no state, no instance. The server and the browser render the same attributes, so hydration has nothing to patch.
- `error: true` adds `aria-invalid="true"` with the reference, so the state and the message come and go together; `<id>-error` is also the id the validation module uses.

### Points to watch

- The id is the control’s own id, not a root id: spread `f.control` on the `<input>` or `<select>`, `f.label` on the `<label>`.
- It registers nothing and imports no stylesheet: the root's `data-slean="field"` stay in your markup, where `@svelte-lean/vite` finds it.
- With `data-slean-validate`, leave `error` out: the validation module writes `aria-invalid` and the error part when the browser reports an error.
- Ids must be unique on the page. Hand-written markup works the same; the ids and the references between them are then yours to keep in step.

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

```bash
npm install @svelte-lean/styles
```

`stylesheets`

```ts
import '@svelte-lean/styles/input.css';
import '@svelte-lean/styles/field.css';
```

Wrap the label, the control and the messages in `<div data-slean="field">` and mark them with `data-slean-part`: `label`, `description`, `error`. Give the description and the error ids, and list them in the control’s `aria-describedby`.

Render the error part and `aria-invalid="true"` only while there is an error; the control’s own stylesheet colors it.

For the browser’s own messages (`required`, `type`, `pattern`, `min`, `minlength`, `setCustomValidity()`) under the field instead of its bubble, add `data-slean-validate` to the field: the option module writes the message into the error part (creating it when missing), sets `aria-invalid`, focuses the first invalid control on submit and clears the message when the value is valid. The module ships only on pages that use the attribute.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<div data-slean="field">` | – | yes | Options: data-slean-orientation="horizontal" puts the label beside the control; data-slean-validate shows the browser’s validation messages (option module). |
| label | `<label for="…">` | `label` | yes | Names the control. Omitted when the control is a fieldset with its own legend. |
| control | `<input>, <select>, <textarea>, a fieldset, …` | – | yes | Its own primitive and stylesheet; aria-describedby lists the description and error ids. |
| description | `<p id="…">` | `description` | no | A hint, read as part of the control’s description. |
| error | `<p id="…">` | `error` | no | Rendered by the application with aria-invalid="true" on the control, and removed with it; with data-slean-validate, the browser’s message written by the module (the part is created when missing). |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab` | on the page | Reaches the control; the label, description and error are not focusable (native) |

## Styling

`field.css` stacks the parts or places them in two columns, gives the description and the error one helper-text look (the error in the error color, an empty error taking no space), turns the label to the error color while the control is invalid, and mutes the label and description of a disabled control through `:has()`. The control’s colors come from its own stylesheet. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-text-sm` | `0.875rem` | font-size |
| `--slean-font-weight-medium` | `500` | font-weight |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | margin-block-start |
| `--slean-error-main` | `oklch(52% 0.19 25)` | color |
| `--slean-space-4` | `calc(var(--slean-spacing) * 4)` | gap |

State selectors the stylesheet targets, all from the platform or ARIA: `:disabled`, `:empty`, `:user-invalid`, `[aria-invalid="true"]`, `[type="color"]`, `[type="date"]`, `[type="datetime-local"]`, `[type="month"]`, `[type="number"]`, `[type="time"]`, `[type="week"]`.

Variant attributes: `data-slean-orientation` (`horizontal`).

## Controlled integration

With a SvelteKit form action, the server returns the error with the submitted value (`fail(400, { username, errors })`), and the page renders the error part and `aria-invalid` from them. The same page works with JavaScript disabled.

`+page.svelte`

```svelte
<script lang="ts">
	// +page.svelte: the error part and aria-invalid exist only when there is an error.
	let { form } = $props();
	const error = $derived(form?.errors?.username);
</script>

<form method="POST">
	<div data-slean="field">
		<label for="username" data-slean-part="label">Username</label>
		<input id="username" name="username" value={form?.username ?? ''}
			aria-describedby={error ? 'username-error' : undefined}
			aria-invalid={error ? 'true' : undefined} data-slean="input" />
		{#if error}
			<p id="username-error" data-slean-part="error">{error}</p>
		{/if}
	</div>
	<button data-slean="button">Save</button>
</form>
```

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<label for>`, `aria-describedby`, `aria-invalid`, `:has()`
- **Shared listeners**: none
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<label for>, a control, aria-describedby; constraint validation`

Tier 0: the field has no behavior module, the Vite plugin maps `field` to no module, and the native-only fixture proves the path ships no Svelte Lean JavaScript. With `data-slean-validate` the plugin adds the `field/validation` option module (Tier 1: shared `invalid`, `focusout`, `input`, `change` and `reset` listeners, no state); the fields-owned fixture asserts it ships only there.

Why this implementation

A label, a hint and an error message are three elements and two attributes. `<label for>` gives the control its name, `aria-describedby` gives it a description made of the listed elements in the listed order, and `aria-invalid` exposes the error state. A field component that generates the ids and wires them spends script and state on what is markup.

What the platform does not decide is where an error comes from. The contract’s answer is the application: the server, or the application’s own validation, renders the message and `aria-invalid` together and removes both when the value is fixed. The stylesheet never reveals a hidden message on `:user-invalid`, because a hidden element listed in `aria-describedby` is still read as part of the description, and one that is not listed is never read.

### The browser owns

- the accessible name from \<label for\>, and focus on a label click
- the accessible description from aria-describedby, in the listed order
- the error state from aria-invalid and from constraint validation
- the platform’s own message on submit for a constraint the value breaks, and its text (validationMessage, setCustomValidity())

### Svelte Lean owns

- field.css: the stacked and the horizontal layout, label, description and error type, the muted label of a disabled control
- the contract: which ids go where, and why an error is rendered rather than revealed
- with data-slean-validate (an option module): the browser’s message in the error part, aria-invalid, focus on the first invalid control, cleared when valid

Accessibility

- `<label for>` names the control; a click on the label focuses it.
- `aria-describedby` lists the description and then the error; screen readers read them after the name and the role.
- `aria-invalid="true"` with a visible message marks an error the application found. The browser reports `required`, `type` and `pattern` failures itself on submit.
- A group control is a fieldset named by its legend, and `aria-describedby` goes on the fieldset.
- The error is text, not a color alone, so it survives forced colors and color blindness.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `<label for>, aria-describedby, aria-invalid` | Widely available | Not applicable |
| `:has()` | Baseline 2023 (Chrome 105, Safari 15.4, Firefox 121) | The label of a disabled control keeps its color |
| `Constraint validation, validationMessage, the invalid event` | Widely available | Not applicable |

Without JavaScript

Fully functional: a form that posts and renders the returned errors works without JavaScript, and the browser blocks a submit that breaks a constraint.

Server rendering and hydration

Everything is markup. A server-side validation answer renders the same field with the error part and `aria-invalid`.

Nothing is attached. An error rendered on the server is in the document before hydration and stays until the application removes it.

Compatibility

Labels, `aria-describedby` and `aria-invalid` are widely available. `:has()` (Baseline 2023) mutes the label of a disabled control; where it is missing the label keeps its color.

Tests and source

- `packages/primitives/tests/field-validation.test.ts` Vitest: the validation option module, messages, aria-invalid, focus, reset, helpers
- `apps/playground/tests/primitives/forms.spec.ts` Playwright: keyboard, form values, :user-invalid, names and descriptions, axe
- `apps/playground/tests/primitives/fields.spec.ts` Playwright, with page JavaScript enabled and disabled, and axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)
- `fixtures/native-only` consumer build asserting that no behavior runtime ships (invariant A)

- `packages/primitives/src/field/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/field/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/field.css` the optional stylesheet
