Primitives Forms
Field
A layout for one control with its label, a description and an error message. The relationships are the platform’s: label for and aria-describedby with author ids. The package adds a stylesheet and a contract. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - 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
On this page
Example
The receipt is sent to this address.
Letters, digits and hyphens.
This username is taken.
<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>
<div data-slean="field">
<label for="field-username" data-slean-part="label">Username</label>
<input id="field-username" name="username" autocomplete="username" value="ada"
aria-describedby="field-username-description field-username-error"
aria-invalid="true" data-slean="input" />
<p id="field-username-description" data-slean-part="description">
Letters, digits and hyphens.
</p>
<p id="field-username-error" data-slean-part="error">This username is taken.</p>
</div><script lang="ts">
import '@svelte-lean/styles/input.css';
import '@svelte-lean/styles/field.css';
let { error }: { error?: string } = $props();
</script>
<div data-slean="field">
<label for="field-username" data-slean-part="label">Username</label>
<input id="field-username" name="username" autocomplete="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>Why this implementation exists
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
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
- documentation
Usage
The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.
npm install @svelte-lean/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesimport '@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.
data-slean-orientation="horizontal" puts the label beside the control.
Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <div data-slean="field"> | – | yes | Option: data-slean-orientation="horizontal" puts the label beside the control. |
| 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. |
Runtime profile
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.
Accessibility contract
<label for>names the control; a click on the label focuses it.aria-describedbylists 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 reportsrequired,typeandpatternfailures itself on submit.- A group control is a fieldset named by its legend, and
aria-describedbygoes on the fieldset. - The error is text, not a color alone, so it survives forced colors and color blindness.
Keyboard
| Key | When | Result |
|---|---|---|
| Tab | on the page | Reaches the control; the label, description and error are not focusable (native) |
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 |
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
Everything is markup. A server-side validation answer renders the same field with the error part and aria-invalid.
Before hydration
Nothing is attached. An error rendered on the server is in the document before hydration and stays until the application removes it.
Styling
field.css stacks the parts or places them in two columns, sets the type and color of the label, description and error, and mutes the label of a disabled control through :has(> :disabled). The control’s colors come from its own stylesheet. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-2 | 0.5rem | 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-danger | oklch(55% 0.2 25) | color |
--slean-space-4 | 1rem | gap |
State selectors the stylesheet targets, all from the platform or ARIA: :disabled.
Variant attributes: data-slean-orientation (horizontal).
Controlled integration
With a SvelteKit form action, the server returns the error and the submitted value, and the page renders the error part and aria-invalid from them. The same page works with JavaScript disabled.
<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>Compatibility notes
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.
Examples
Horizontal
data-slean-orientation="horizontal" puts the label in a first column of up to 10rem,
aligned with the first line of the control; the description and the error follow the control in the
second column.
Shown on your posts.
<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>A group control
A segmented control, a rating or a radio group is a fieldset named by its own legend. The field
has no label part then, and aria-describedby goes on the fieldset.
Used for distances and weights.
<div data-slean="field">
<fieldset data-slean="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>Server validation
The action returns the error with the submitted values; the page in Controlled integration
renders the error part and aria-invalid from it.
// +page.server.ts: the server validates and returns the errors with the values.
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const username = String(data.get('username') ?? '');
if (await isTaken(username)) {
return fail(400, { username, errors: { username: 'This username is taken.' } });
}
}
};Testing
apps/playground/tests/primitives/forms.spec.tsPlaywright: keyboard, form values, :user-invalid, names and descriptions, axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)fixtures/native-onlyconsumer build asserting that no behavior runtime ships (invariant A)
Source
packages/primitives/src/field/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/field/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/field.cssthe optional stylesheet