# Field

> Frame component that wires the label, field, help text and error text together for you.

A form field is accessible when the help text and the error text are wired to the field itself. LO-TEK gives you that wiring in two ways, and **you use one of them, not both**.

| Can the code that renders the markup call a builder function? | Use |
| - | - |
| **Yes.** React, Astro, a Node server, an app with no server at all, or a Kotlin or Java server | [`lt.field()`](#as-a-function), or [`Lt.field()`](/kotlin.md). Then you do not need the web component |
| **No.** A Go template, a PHP file, a Razor view, a JTE or Thymeleaf template, a CMS or hand-written HTML | [`<lt-field>`](#as-an-element) |

Both use the same function underneath, so the contract cannot drift apart. The only difference is whether the code that produces the HTML can call a builder function, in TypeScript, Kotlin or Java.

`<lt-field>` creates no markup itself and does not use Shadow DOM, so `FormData`, form libraries and test tools find the elements as before.

> **Other components are not either-or**
>
> `<lt-field>` can be replaced entirely by the builder function, because the wiring is markup and not behaviour. The same goes for [Dialog](/components/dialog.md), when the app calls `showModal()` itself. [Tabs](/components/tabs.md), [Popover](/components/popover.md), [Suggestion](/components/suggestion.md) and [Error Summary](/components/error-summary.md) do something a function cannot, like arrow keys and focus, so there you use the builder function **and** the component. See [Markup and behaviour](/markup-and-behaviour.md#builder-function-or-web-component).

## How to use it

`defineLtField()` registers the web component in the browser, and is called once at startup. More calls are harmless. The stylesheet must be imported because the children are elements you write, with the classes `lt-label`, `lt-input` and so on, and those classes must be on the page. `field.css` collects them.

**Plain HTML**

```html
<!-- The stylesheets the page needs. Without a build step it is `<link>`,
     not `import`: a browser does not resolve a package name. -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/tokens/tokens.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/frame/field/field.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/css/button/button.css">
<!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/dist/lotek.css -->
```

```html
<!-- novalidate: otherwise the browser stops an empty required field before
     the submit event, and the error text below is never shown. -->
<form id="order-form" action="/jobs" method="post" novalidate>
  <lt-field>
    <label class="lt-label" for="codename" data-required="symbol">Codename</label>
    <input class="lt-input" id="codename" type="text" name="codename" required />
    <p class="lt-error-text" hidden>Enter a codename.</p>
  </lt-field>


  <button class="lt-button" type="submit">Order the job</button>
</form>


<!-- Once for the whole app, in the page template. Not once per component. -->
<script type="module">
  import { defineLtField } from
    "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/dist/components/frame/field/lt-field.js"


  defineLtField()
</script>


<script type="module">
  const form = document.getElementById("order-form")
  const codenameInput = document.getElementById("codename")
  const error = codenameInput.closest("lt-field").querySelector(".lt-error-text")


  // Stops the submission only when the codename is empty. The server
  // checks it again, since a script can always be switched off.
  form.addEventListener("submit", (event) => {
    const empty = codenameInput.value.trim() === ""
    error.hidden = !empty


    if (empty) {
      event.preventDefault()
      codenameInput.focus()
    }
  })
</script>
```

You write `for` and `id` yourself, as HTML requires for the field to have a name before the script has run. The field is invalid when the error text is visible: the script toggles `hidden`, and the component wires the error text to the field and sets `aria-invalid`. `new FormData(form)` picks up the field by itself. That is the whole point of the component staying in plain DOM.

**React**

In React you call `lt.field()`. Then the wiring is in the markup React renders itself, on the server as in the browser, and you do not need to register anything.

```tsx
// main.tsx, runs once when the app starts
import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/field.css"
import "@lo-tek/interfaces/button.css"
```

```tsx title="OrderForm.tsx"
import { lt } from "@lo-tek/interfaces/react"
import { useId, useState } from "react"


export function OrderForm() {
  const [codename, setCodename] = useState("")
  const [touched, setTouched] = useState(false)


  const invalid = touched && codename.trim() === ""
  const field = lt.field({
    id: useId(),
    required: "symbol",
    error: true,
    invalid,
  })


  return (
    <form
      noValidate
      onSubmit={(e) => {
        e.preventDefault()
        setTouched(true)
      }}
    >
      <label {...field.label}>Codename</label>
      <input
        {...lt.input({ type: "text" })}
        {...field.control}
        name="codename"
        required
        value={codename}
        onChange={(e) => setCodename(e.target.value)}
        onBlur={() => setTouched(true)}
      />
      <p {...lt.errorText()} {...field.error}>Enter a codename.</p>


      <button {...lt.button()} type="submit">Order the job</button>
    </form>
  )
}
```

`required: "symbol"` gives the asterisk on the label. That the field must be filled in is said by `required` on the field itself.

> **In React the id comes from useId()**
>
> An id that is generated on its own is not the same on the server and in the browser, and React then reports a hydration mismatch on the label, the field and the error text. The warning itself says the mismatch will not be patched up. `createFieldId()` will not do in a component body either: it gives a new id on every render.

> **The web component gives hydration errors in React with server rendering**
>
> The component writes `aria-describedby`, ids and the class on the elements when it is registered, before React hydrates. React then finds attributes it did not render, and the React 19 development build reports “A tree hydrated but some attributes of the server rendered HTML didn’t match the client properties”. The field works, but the error is in the console on every page with a field. Use `lt.field()`.
>
> If the app renders only in the browser, without a server, `<lt-field>` also works in React. Invalid is then controlled with `hidden={!invalid}` on the error text, a plain HTML attribute React writes the same way in all versions.

**Astro**

If the field only needs to be shown in one state, `lt.field()` does the wiring at build time. Then no JavaScript is sent for the field at all:

```astro
---
import { lt } from "@lo-tek/interfaces"
import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/field.css"


const codename = lt.field({ id: "codename", required: "symbol", error: true })
---


<label {...codename.label}>Codename</label>
<input {...lt.input({ type: "text" })} {...codename.control} name="codename" required />
<p {...lt.errorText()} {...codename.error}>Enter a codename.</p>
```

If the field should be able to become invalid while the user is in it, use `<lt-field>` and register it in a `<script>` tag. Astro bundles it and runs it on the client:

```astro
---
import { lt } from "@lo-tek/interfaces"


import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/field.css"


const codename = lt.field({ id: "codename", required: "symbol", error: true })
---


<lt-field>
  <label {...codename.label}>Codename</label>
  <input {...lt.input({ type: "text" })} {...codename.control} name="codename" required />
  <p {...lt.errorText()} {...codename.error}>Enter a codename.</p>
</lt-field>


<script>
  import { defineLtField } from "@lo-tek/interfaces/field"
  defineLtField()
</script>
```

**Datastar**

[Datastar](https://data-star.dev) adds reactivity to plain HTML with `data-*` attributes. One script, no build step.

```html
<script
  type="module"
  src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar.js"
></script>
```

```html
<!-- The stylesheets the page needs. Without a build step it is `<link>`,
     not `import`: a browser does not resolve a package name. -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/tokens/tokens.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/frame/field/field.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/css/input/input.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/css/error-text/error-text.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/css/button/button.css">
<!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/dist/lotek.css -->
```

```html
<form
  novalidate
  data-signals="{codename: '', touched: false}"
  data-computed:invalid="$touched && $codename.trim() === ''"
  data-on:submit__prevent="$touched = true; !$invalid && @post('/jobs')"
>
  <lt-field>
    <label class="lt-label" for="codename" data-required="symbol">Codename</label>
    <input
      class="lt-input"
      id="codename"
      type="text"
      name="codename"
      required
      data-bind:codename
      data-on:blur="$touched = true"
    />
    <p class="lt-error-text" hidden data-attr:hidden="!$invalid">Enter a codename.</p>
  </lt-field>


  <button class="lt-button" type="submit">Order the job</button>
</form>


<!-- Once for the whole app, in the page template. Not once per component. -->
<script type="module">
  import { defineLtField } from
    "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/dist/components/frame/field/lt-field.js"


  defineLtField()
</script>
```

`data-attr:hidden` hides the error text when the field is valid and shows it when it is invalid. `<lt-field>` sees that the error text is visible, and sets `aria-invalid` and `aria-describedby` on the field. The field has no id the component created, so a patch from the server recognises it, and what the user has typed stays.

**kotlinx.html**

On a Kotlin server you call `Lt.field()`, here with kotlinx.html and `lo-tek-kotlinx-html`. The server writes the whole wiring every time it renders the form, and the error text is shown when it renders the field as invalid, so the field needs no `<lt-field>` and no script, as in the React tab. The handler for `POST /jobs` renders the form again with what was typed and `invalid = codename.isBlank()`, in the same shape as the round trip in [With Ktor](/kotlin.md#with-ktor). The invalid field gets `autofocus`, so it takes focus when the page arrives, as the HTML tab focuses it in its script. Only the first invalid field in a form should have it, and none when the form has an [error summary](/components/error-summary.md): then the summary takes focus, and its links lead to the fields.

```kotlin
import kotlinx.html.*
import no.lotek.*
import no.lotek.InputType
import no.lotek.html.lt


fun FlowContent.orderForm(codename: String = "", invalid: Boolean = false) {
    val field = Lt.field(id = "codename", required = RequiredMarker.SYMBOL, error = true, invalid = invalid)
    form(action = "/jobs", method = FormMethod.post) {
        id = "order-form"
        attributes["novalidate"] = ""
        label { lt(field.label); +"Codename" }
        input {
            lt(Lt.input(type = InputType.TEXT), field.control)
            name = "codename"
            required = true
            // What the user typed comes back, so a failed submission loses nothing.
            if (codename.isNotEmpty()) value = codename
            if (invalid) autoFocus = true
        }
        p { lt(Lt.errorText(), field.error); +"Enter a codename." }
        button { lt(Lt.button()); type = ButtonType.submit; +"Order the job" }
    }
}
```

```kotlin
// Once for the whole app, in the page template. Not once per component.
head { link(rel = "stylesheet", href = LotekWebJar.CSS) }
body {
    orderForm()
    script(type = "module") { unsafe { +"import { defineLt } from \"${LotekWebJar.REGISTER}\"; defineLt()" } }
}
```

**`lt.input()` should only get `type`.** The state is in `field.control`, and the two deliberately do not overlap. If you passed `state` to both, `aria-invalid` would be written out twice in templates that render the attributes literally, like Astro and plain HTML. `field.state` still exists if you want to drive something else with it.

### The required marker

`data-required` on the label chooses how a required field is marked, `symbol` or `text`, and is in the markup, so the marker is visible also before the script has run. `optional` on `<lt-field>` turns it around: mark the optional field instead of the required one, when almost everything in the form must be filled in. `optional` has no place in the recipe above, since it shows one field and not a form.

```html
<lt-field>
  <label class="lt-label" for="fixer" data-required="text">Fixer's address</label>
  <input class="lt-input" id="fixer" type="email" name="fixer" required />
</lt-field>


<lt-field optional>
  <label for="note">Note for the fixer</label>
  <textarea class="lt-textarea" id="note" rows="3"></textarea>
</lt-field>
```

## Why the wiring is hard

A field with help text and an error text needs five connections that must all be right at once:

```html
<label for="fixer">Fixer's address</label>
<input id="fixer" aria-invalid="true" aria-describedby="fixer-help fixer-error" />
<p id="fixer-help">The board writes to the fixer, never to the crew.</p>
<p id="fixer-error">Enter the fixer's address, for example fixer@relay.example.</p>
```

Every `id` must be unique, `aria-describedby` must point to both texts, and `aria-invalid` must follow the validation. Five places to get it wrong per field, multiplied by the number of fields. And nothing shows on the screen when one of them is missing.

## As a function

`lt.field()` computes the attributes where you render, whether at build time in Astro, on every render in React, or on the server. On a Kotlin or Java server it is `Lt.field()`, with the same options, as the kotlinx.html tab above shows. The browser needs no registered component, and the wiring is ready in the HTML before any script has run:

```tsx
const field = lt.field({ id: "fixer", required: "symbol", help: true, error: true, invalid })


<label {...field.label}>Fixer's address</label>
<input {...lt.input({ type: "email" })} {...field.control} />
<p {...lt.helpText()} {...field.help}>The board writes to the fixer, never to the crew.</p>
<p {...lt.errorText()} {...field.error}>Enter the fixer's address, for example fixer@relay.example.</p>
```

If you use React, import from `@lo-tek/interfaces/react`, which gives `className` and `htmlFor` instead of the HTML names.

**`id` is required.** In React it comes from `useId()`. Otherwise the field’s own name is usually the obvious choice. If you know for certain that the markup is rendered once, as on an Astro page, you can call `createFieldId()`, which you get from both `@lo-tek/interfaces` and `@lo-tek/interfaces/react`.

The reason is that an id generated on its own is random: two runs give two different ones. If the same field is rendered on a server and then in the browser, `for` and `aria-describedby` point to something other than what is there, and React reports a hydration mismatch.

If you write JavaScript without types, you see no type. Then the function creates an id anyway, and warns in the console, so the field works while you fix it.

## As an element

If the code that produces the HTML cannot call a builder function, `<lt-field>` does the same wiring in the browser. You write your elements as usual, with `for` on the label and `id` on the field, or with the label around the field. That is what HTML requires for the field to have a name before the script has run, and for voice control to find it. The component sets `aria-describedby` and `aria-invalid` on the field and gives the help text and the error text ids.

The component never writes an id on the field. Datastar and idiomorph recognise an element by its id, and a field with an id the server did not send would be replaced on the next update, along with focus and what the user has typed. Without an id from the component the field stays.

If the server sends the same region again while the user is working, as in a Datastar app, the morph tears the wiring away: none of it was in the HTML the server sent. The component sees that and puts it back, so your template needs no `data-preserve-attr`. The reasoning is in [Markup and behaviour](/markup-and-behaviour.md#when-the-markup-is-not-made-with-a-builder-function).

In a loop you make the id from a key in the data, like `note-{{.Code}}`, never from the index or a counter. An id that follows the position moves what the user typed to the wrong row when the server inserts a new one. If the template makes the id from something that changes between renders, the component warns in the console when an update replaced the field for that reason.

Send the field and its error text in the same patch. If the server clears the error by sending only the `<input>`, the old error text stays visible, and the component reads that as invalid and writes `aria-invalid` back.

If the component cannot find its parts, it warns in the console. An `<lt-field>` without a control gives a warning naming what is missing, instead of a field that silently has no wiring. The same goes for a field without a label, but only when it has no name in any other way either: a `<label for>` outside the element, `aria-label` or `aria-labelledby` on the control counts, as in a search field with only an icon. And a field with more than one control gives a warning, since only the first gets the wiring.

## Automatic wiring

Every time the content or the attributes change, it finds the `<label>` and the first control among its own parts and:

1. Sets `label[for]` to the control’s `id` when the control has one, and the label is not around the control. The control never gets an id from the component.
2. Gives the help text and the error text an `id` when needed.
3. Sets `aria-describedby` on the control: the help text always, and the error text as well when it is visible. A hidden error text should not be announced.
4. Sets `aria-invalid="true"` on the control when the error text is visible, meaning it has text and is without `hidden` and without `display: none`. `data-show` in Datastar and `data-text` in a container that is empty therefore also work.
5. Sets `data-optional` on the label when `<lt-field>` has `optional`.

The error text can be in the markup all the time with `hidden`, or be rendered by the template only when the field is invalid. The component never touches `hidden` on it.

The component requires two class names from you: `lt-help-text` on the help text and `lt-error-text` on the error text. It finds the control by tag name, `<input>`, `<textarea>` or `<select>`, and a `type="hidden"` does not count as a control. It finds the label as a `<label>` inside the element, or as a `<label for>` outside that points to the control’s `id`. If the control has no `id`, and the label is not around it, the component warns: the field then has no name without JavaScript. If the control has an `id`, the component writes `for` from it, and warns in the console when the label pointed to a different id: without JavaScript the field would then have had no label. Write `for` in the template too; that is what gives the field a name before the script runs.

The parts are only those that belong to this field. If one `<lt-field>` is inside another, like a search field in a popover inside the field, the outer field sees only the control, label, help text and error text that belong to it, and the inner field likewise.

## Attributes

| Attribute | Type | What it does |
| - | - | - |
| `disabled` | boolean | Sets `disabled` on the control and dims the label |
| `optional` | boolean | Sets `data-optional` on the label |
| `described-by` | string | Adds extra `id`s to `aria-describedby`, in addition to help and error |

There is no `invalid` attribute. The field is invalid when the error text is visible, or when the server has written `aria-invalid` on the control. The required marker is `data-required` on the label, and is only visual: `required` must be on the `<input>` itself.

## Accessibility

What a screen reader is given when the recipe above is used, step by step. It is checked through AT-SPI, the interface Orca reads, in Chromium and Firefox, on every change, by `check-accessibility-tree.ts`, and every change the check lists as a mutation must make it fail.

1. Focus `#codename`: focus on the `textbox` "Codename \*", required, not invalid.
2. Press `Enter`: focus on the `textbox` "Codename \*", described as "Enter a codename.", required, invalid; the change to invalid sent as an event; the new description sent as an event.

Where a browser does not do what the specification says, which the check requires until the browser changes:

- **Firefox**: Firefox 150 (Playwright firefox-1522), 2026-10-29: an empty required input has STATE\_INVALID\_ENTRY before anyone has touched it, also in a form with novalidate. HTML-AAM, the required attribute: "If an element is required, user agents MUST NOT expose the element with an initial invalid state". A screen reader user in Firefox hears "invalid entry" on a field they have not filled in yet.
- **Firefox**: Firefox 150, 2026-10-29: follows from the deviation at field:focus-field. The field was already invalid-entry before the submit, so there is no change to send.
- **Firefox**: Firefox 150, 2026-10-29: when the component wires the error text with aria-describedby, Firefox has the new description when asked, but sends no description change event, which Core-AAM requires for aria-describedby. With the focus staying in the field, Orca says nothing about the error text until the focus moves.

* The component does the wiring, not the content. You still have to write the label and the error message well.
* Write `for` on the label and `id` on the field, or put the label around the field. Without that the field has no name before the script has run. Without an id the field does not get focus back after an `innerHTML` update with htmx, which finds it again by id, and voice control on Windows has not been checked with the label around the field.
* Set `required` on the `<input>` in addition to `data-required` on the label.
* Show the error message when the user has left the field, not on every keystroke. The message is announced every time it appears.
* One control per `<lt-field>`. The component wires itself to the first one it finds. Groups belong in a `<fieldset>` with `<legend>`.
