Input
Input is a form field for a single line of text, covering plain text,
email addresses, numbers, phone numbers, URLs, search terms, and
passwords. It wraps a native text field internally, so it gets built-in
validation, keyboard editing, and autofill for free, while adding a
leading icon, a clear button, a password-visibility toggle, and an
optional click-to-edit display. For multi-line text, see
Dependencies
When to use
Use input for any single line of typed text: a name, an email, a
password, a search box. For text that can run to several lines (a
comment, a message), reach for
Install & usage
Pick a framework in the toolbar above and these snippets adapt.
npm install @eunomia/elements
import "@eunomia/elements/input.js";
import "@eunomia/elements/label.js";
<eun-input label="Email address" placeholder="jane.doe@email.com"></eun-input>
Importing the file registers <eun-input> as a custom element, with no
further setup needed. It works with any framework, or none, since it's a
standard web component.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/input.js";
</script>
<eun-input label="Email address"></eun-input>
npm install @eunomia/elements
import "@eunomia/elements/input.js";
function EmailField() {
return <eun-input label="Email address" name="email" />;
}
npm install @eunomia/elements
"use client";
import "@eunomia/elements/input.js";
export function EmailField() {
return <eun-input label="Email address" name="email" />;
}
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/input.js";
</script>
<template>
<eun-input label="Email address" name="email"></eun-input>
</template>
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/input.js";
@Component({
selector: "app-email-field",
template: `<eun-input label="Email address" name="email"></eun-input>`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class EmailFieldComponent {}
Alternatives
| You want… | Prefers |
|---|---|
| Text that can run to several lines, such as a comment or a message | eun-textarea |
| To pick one value from a closed list you already know, faster to fill and impossible to mistype | eun-select |
Guidance
- Always set
label(or slot one): see the Accessibility tab - Prefer native constraint attributes (
type,required,minlength,maxlength,pattern,min,max) over customvalidatorswhen a native equivalent exists - Use
hintfor format guidance ("Include your country code"), not instructions repeated from the placeholder - Reserve
readonly-availablefor genuinely inline-editable data (a profile field), not every field in a long form
- Using the placeholder as a replacement for
label: it disappears the moment the user types - Pairing
clearablewithtype="password": it's disabled there on purpose (see Examples) - Overriding colors with inline styles instead of the
--field-*CSS variables - Assuming a typed value survives every
form.reset(): React 19's<form action={fn}>triggers one on every submit, seeForms & validation
Live testing
Properties
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| readonly-available | boolean | false | Whether the field can switch between a static display and an editable one |
| type | 'text' | 'email' | 'number' | 'tel' | 'url' | 'search' | 'password' | 'text' | The native input type to apply |
| default-value | string | — | The default value applied when the field connects, and restored on form reset |
| placeholder | string | — | The placeholder shown when the field is empty |
| icon | EunomiaIconName | — | An optional leading icon |
| clearable | boolean | false | Whether a clear button is shown when the field has a value |
| autocomplete | string | 'off' | The native autocomplete attribute forwarded to the field |
| minlength | number | — | The native minimum length constraint forwarded to the field |
| maxlength | number | — | The native maximum length constraint forwarded to the field |
| pattern | string | — | The native pattern constraint forwarded to the field |
| min | string | — | The native minimum constraint forwarded to the field |
| max | string | — | The native maximum constraint forwarded to the field |
| step | string | — | The native step constraint forwarded to the field |
| empty-value-label | string | — | Text displayed in place of the value when readonly and empty. Defaults to the localized string for the current locale (English: "No value set") |
| confirm-label | string | — | The label of the confirm button in edit mode. Defaults to the localized string for the current locale (English: "Save") |
| cancel-label | string | — | The label of the cancel button in edit mode. Defaults to the localized string for the current locale (English: "Cancel") |
| clear-label | string | — | The accessible label of the clear button. Defaults to the localized string for the current locale (English: "Clear field") |
| show-password-label | string | — | The accessible label of the show password button. Defaults to the localized string for the current locale (English: "Show password") |
| hide-password-label | string | — | The accessible label of the hide password button. Defaults to the localized string for the current locale (English: "Hide password") |
Import the exact TypeScript type behind any property above, see
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| checkLocalValidators | — | Reads native HTML constraint validation from the underlying field element (`required`, `minlength`, `maxlength`, `pattern`, `min`, `max`, `type="email"`, etc.). | |
| appearance | 'outline' | 'flat' | 'underline' | 'outline' | How the field's surface is drawn: a bordered box, a muted background with no border, or a bottom border only |
| rounded | boolean | false | Whether the field is fully rounded, as a pill. A custom border radius still wins over it |
| size | 'xs' | 's' | 'm' | 'l' | 'xl' | 'm' | The field size. A field and a button of the same size share the same height |
| label | string | — | The title label of the field, rendered through the label component |
| instructions | string | — | Instructions displayed below the label |
| hideLabel | boolean | false | Whether the label is visually hidden. It still gives the field its accessible name, and the instructions stay visible |
| name | string | — | The field name |
| value | string | — | The field value |
| disabled | boolean | false | Whether the field is disabled |
| readonly | boolean | false | Whether the field is read only |
| required | boolean | false | Whether the field is required |
| hideError | boolean | false | Whether the errors are hidden. The hint stays visible |
| invalid | boolean | false | Shows the field as invalid without any message, for an error displayed elsewhere |
| error | string | — | An error computed outside the field, by the form or by the server. While set, the field is invalid and shows this message right away |
| validators | Array<Validators<string>> | — | The list of validation rules applied to the field value |
| readonlyAvailable | boolean | false | Whether the field can switch between a static display and an editable one |
Slots
| Name | Description |
|---|---|
| icon | Replaces the leading icon entirely with any content, not just an icon element. Falls back to icon when empty |
Events
| Name | Type | Description |
|---|---|---|
| eunchange | ChangeEvent | Fired on every value change |
| euncommit | CommitEvent | Fired when an edit is confirmed in readonlyAvailable mode |
| euneditcancel | EditCancelEvent | Fired when an edit is cancelled in readonlyAvailable mode, after the previous value is restored |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --field-background | Sets the background color of the field, the surface color by default for the outline appearance |
| --field-border-color | Sets the border color of the field |
| --field-border-color-hover | Sets the border color on hover |
| --field-border-color-focus | Sets the border color on focus |
| --field-border-radius | Sets the corner radius of the field |
| --field-padding | Sets the padding of the field |
| --field-padding-block | Sets the vertical padding, as one length applied to both sides. Ignored when --field-padding is set |
| --field-padding-inline | Sets the horizontal padding, as one length applied to both sides. Ignored when --field-padding is set |
| --field-text-color | Sets the text color of the field |
| --field-placeholder-color | Sets the color of the placeholder |
| --field-icon-color | Sets the color of the leading icon and action buttons |
| --field-error-color | Sets the color of the error message |
| --field-hint-color | Sets the color of the hint message |
| --field-focus-outline-color | Sets the color of the focus outline |
| --field-edit-confirm-color | Sets the color of the confirm button in readonly-available edit mode |
| --field-edit-cancel-color | Sets the color of the cancel button in readonly-available edit mode |
| --field-font-size | Sets the font size of the field's value and placeholder |
| --field-line-height | Sets the line height of the field's value and placeholder |
| --field-label-font-size | Sets the font size of the label |
| --field-message-font-size | Sets the font size of the hint, error messages and instructions |
Field types
Types
type selects the native input type: text (default), email, number,
tel, url, search, or password.
<eun-input
label="Email"
type="email"
placeholder="jane.doe@email.com"
></eun-input>
<eun-input label="Email" type="email" placeholder="jane.doe@email.com" />
Number constraints
type="number" accepts the same native min, max, and step constraint
attributes as a plain <input>, so out-of-range or off-step values are
rejected by checkLocalValidators, exactly like required/pattern/etc.
<eun-input
label="Quantity"
type="number"
min="1"
max="10"
step="1"
hint="Between 1 and 10"
></eun-input>
<eun-input
label="Quantity"
type="number"
min="1"
max="10"
step="1"
hint="Between 1 and 10"
/>
Password
Renders a show/hide toggle that flips the underlying native type between
password and text.
<eun-input label="Password" type="password"></eun-input>
<eun-input label="Password" type="password" />
Labels and instructions
Instructions
instructions is separate from hint: it renders below the label
itself (via the internal eun-label), stays visible no matter what's
typed, and is never replaced by an error message. Reach for it for
context about the field itself, why it's asked or how the value is
used, and reserve hint for guidance about the value being typed. See
<eun-input
label="Email address"
type="email"
placeholder="jane.doe@email.com"
instructions="We'll never share this with third parties"
></eun-input>
<eun-input
label="Email address"
type="email"
placeholder="jane.doe@email.com"
instructions="We'll never share this with third parties"
/>
Hidden label
hide-label hides the label visually while keeping it as the field's
accessible name, so screen readers still announce what the field is for.
Use it when the context already makes the field's meaning obvious, such
as an editable page title. The instructions, when set, stay visible.
<eun-input
label="Collection title"
value="Spring collection"
readonly-available
hide-label
></eun-input>
<eun-input
label="Collection title"
value="Spring collection"
readonly-available
hide-label
/>
Display
Appearance
appearance selects the visual style: outline (default, bordered box),
flat (muted background, no border), or underline (bottom border only).
<eun-input label="Flat" appearance="flat"></eun-input>
<eun-input label="Underline" appearance="underline"></eun-input>
<eun-input label="Flat" appearance="flat" />
<eun-input label="Underline" appearance="underline" />
Part of the
Sizes
size goes from xs to xl, with m by default, like a button. A field
and a button of the same size have the same height, so a search field next
to an action button lines up without any adjustment.
<eun-input size="l" placeholder="Large"></eun-input>
<eun-button size="l">Add</eun-button>
The same size is available on every text field: search, select, input
phone, date picker, time picker and color picker.
Rounded
Set rounded for a pill shape, like a button, without touching
--field-border-radius. The inline padding grows so the text stays clear
of the curve. Every single-line field and the toggle group accept it too.
<eun-input rounded icon="search" placeholder="Search…"></eun-input>
<eun-button rounded>Search</eun-button>
With a leading icon
<eun-input
label="Email address"
type="email"
icon="mail"
placeholder="jane.doe@email.com"
></eun-input>
<eun-input
label="Email address"
type="email"
icon="mail"
placeholder="jane.doe@email.com"
/>
Need something other than a eun-icon, or a different icon library
entirely? Slot your own content into icon instead. It replaces the
prop-driven icon entirely.
<eun-input label="Email address" type="email" placeholder="jane.doe@email.com">
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M4 4h16v16H4z"></path>
<path d="m4 4 8 8 8-8"></path>
</svg>
</eun-input>
<eun-input label="Email address" type="email" placeholder="jane.doe@email.com">
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M4 4h16v16H4z" />
<path d="m4 4 8 8 8-8" />
</svg>
</eun-input>
Clearable
Shows a clear button once the field has a value. Not available on
type="password": a clear button there would defeat masking the value
without offering anything a Backspace/Delete selection doesn't already.
<eun-input label="Search" type="search" clearable value="eunomia"></eun-input>
<eun-input label="Search" type="search" clearable value="eunomia" />
States and validation
States
<eun-input label="Disabled" disabled value="Can't edit"></eun-input>
<eun-input
label="Readonly"
readonly
value="Can't edit, still submits"
></eun-input>
<eun-input label="Required" required hint="This field is mandatory"></eun-input>
<eun-input label="Disabled" disabled value="Can't edit" />
<eun-input label="Readonly" readonly value="Can't edit, still submits" />
<eun-input label="Required" required hint="This field is mandatory" />
readonly and disabled are not interchangeable: a readonly field still submits its value and stays focusable/selectable, while a disabled one does neither. Try tabbing to (or clicking into) the "Readonly" field above: it takes focus exactly like the others. See the Accessibility tab.
Error and validation state
Native constraints (required, minlength, pattern, …) are checked
automatically. Custom rules go through validators, an array of
{ isValid, message } (or { isInvalid, message }, or a
Standard Schema validator like Zod/Valibot).
Type fewer than 4 characters below to see the hint switch to an error, and
clear the field to see it switch back.
<eun-input
id="username"
label="Username"
hint="At least 4 characters"
></eun-input>
const field = document.querySelector("#username");
field.validators = [
{
isValid: (value) => !value || value.length > 3,
message: "Please enter at least 4 characters",
},
];
field.addEventListener("eunchange", () => field.checkValidity(field.value));
import { useEffect, useRef } from "react";
const usernameValidators = [
{
isValid: (value) => !value || value.length > 3,
message: "Please enter at least 4 characters",
},
];
function UsernameField() {
const fieldRef = useRef(null);
useEffect(() => {
const field = fieldRef.current;
field.validators = usernameValidators;
const onChange = () => field.checkValidity(field.value);
field.addEventListener("eunchange", onChange);
return () => field.removeEventListener("eunchange", onChange);
}, []);
return (
<eun-input ref={fieldRef} label="Username" hint="At least 4 characters" />
);
}
<script setup>
import { ref, onMounted } from "vue";
const field = ref(null);
onMounted(() => {
field.value.validators = [
{
isValid: (value) => !value || value.length > 3,
message: "Please enter at least 4 characters",
},
];
});
const onChange = (event) => event.target.checkValidity(event.target.value);
</script>
<template>
<eun-input
ref="field"
label="Username"
hint="At least 4 characters"
@eunchange="onChange"
></eun-input>
</template>
import {
AfterViewInit,
Component,
CUSTOM_ELEMENTS_SCHEMA,
ElementRef,
ViewChild,
} from "@angular/core";
@Component({
selector: "app-username-field",
template: `
<eun-input
#field
label="Username"
hint="At least 4 characters"
(eunchange)="onChange()"
></eun-input>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class UsernameFieldComponent implements AfterViewInit {
@ViewChild("field") field!: ElementRef<any>;
ngAfterViewInit() {
this.field.nativeElement.validators = [
{
isValid: (value: string) => !value || value.length > 3,
message: "Please enter at least 4 characters",
},
];
}
onChange() {
const field = this.field.nativeElement;
field.checkValidity(field.value);
}
}
Click to edit
Set readonly-available to let a field switch between a static,
clickable display of its current value and a real editable input,
instead of always being one or the other. Clicking the display (or
activating it with Enter/Space) switches into edit mode, showing
Save and Cancel buttons (confirmLabel/cancelLabel) alongside the
field. Confirming dispatches euncommit with the new value and switches
back to the static display, while canceling reverts to the previous
value instead and dispatches euneditcancel, so you can drop anything
derived from the abandoned value, such as an error message.
clearable still applies normally once editing starts: the static
display never shows a clear button itself (there's nothing to clear from
plain text), so click into the field below first.
<eun-input
readonly-available
label="Email address"
value="jane.doe@email.com"
clearable
></eun-input>
document
.querySelector("eun-input[readonly-available]")
.addEventListener("euncommit", (event) => save(event.target.value));
import { useEffect, useRef } from "react";
function EmailField() {
const fieldRef = useRef(null);
useEffect(() => {
const field = fieldRef.current;
const onCommit = (event) => save(event.target.value);
field.addEventListener("euncommit", onCommit);
return () => field.removeEventListener("euncommit", onCommit);
}, []);
return (
<eun-input
ref={fieldRef}
readonly-available
clearable
label="Email address"
value="jane.doe@email.com"
/>
);
}
<script setup>
const onCommit = (event) => save(event.target.value);
</script>
<template>
<eun-input
readonly-available
clearable
label="Email address"
value="jane.doe@email.com"
@euncommit="onCommit"
></eun-input>
</template>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
@Component({
selector: "app-email-field",
template: `
<eun-input
readonly-available
clearable
label="Email address"
value="jane.doe@email.com"
(euncommit)="onCommit($event)"
></eun-input>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class EmailFieldComponent {
onCommit(event: any) {
save(event.target.value);
}
}
The static display is clickable and keyboard-activatable (Enter/Space) to enter edit mode. Confirming re-runs validation first: an invalid value stays in edit mode instead of committing. See the Accessibility tab for the full keyboard flow.
Integration
Complete form
A <form> of eun-input fields behaves like any native form: eun-button type="submit" submits it, type="reset" reverts every field to its
initial value (including clearing fields that had none), and each field's
name becomes a key in the submitted FormData. The JSON panel below
updates live on every keystroke (eunchange) and on submit/reset, so you
can see exactly what a server (or a JS submit handler) would receive.
For the deeper patterns this example is built from (composing labels,
choosing native constraints vs. custom validators, hint/error wording),
see the
{}
<form id="my-form">
<eun-input name="firstName" label="First name" required></eun-input>
<eun-input
name="email"
label="Email address"
type="email"
icon="mail"
clearable
required
></eun-input>
<eun-input name="age" label="Age" type="number" min="0" max="120"></eun-input>
<eun-button type="submit" rounded>Submit</eun-button>
<eun-button type="reset" appearance="flat" rounded>Reset</eun-button>
</form>
const form = document.querySelector("#my-form");
const renderOutput = () => console.log(Object.fromEntries(new FormData(form)));
form.addEventListener("eunchange", renderOutput);
form.addEventListener("submit", (event) => {
event.preventDefault(); // replace with a real submission
renderOutput();
});
form.addEventListener("reset", () => setTimeout(renderOutput));
import { useEffect, useRef } from "react";
function MyForm() {
const formRef = useRef(null);
useEffect(() => {
const form = formRef.current;
const renderOutput = () =>
console.log(Object.fromEntries(new FormData(form)));
const onSubmit = (event) => {
event.preventDefault(); // replace with a real submission
renderOutput();
};
const onReset = () => setTimeout(renderOutput);
form.addEventListener("eunchange", renderOutput);
form.addEventListener("submit", onSubmit);
form.addEventListener("reset", onReset);
return () => {
form.removeEventListener("eunchange", renderOutput);
form.removeEventListener("submit", onSubmit);
form.removeEventListener("reset", onReset);
};
}, []);
return (
<form ref={formRef}>
<eun-input name="firstName" label="First name" required />
<eun-input
name="email"
label="Email address"
type="email"
icon="mail"
clearable
required
/>
<eun-input name="age" label="Age" type="number" min="0" max="120" />
<eun-button type="submit" rounded>
Submit
</eun-button>
<eun-button type="reset" appearance="flat" rounded>
Reset
</eun-button>
</form>
);
}
<script setup>
import { ref } from "vue";
const form = ref(null);
const renderOutput = () =>
console.log(Object.fromEntries(new FormData(form.value)));
const onSubmit = (event) => {
event.preventDefault(); // replace with a real submission
renderOutput();
};
const onReset = () => setTimeout(renderOutput);
</script>
<template>
<form
ref="form"
@eunchange="renderOutput"
@submit="onSubmit"
@reset="onReset"
>
<eun-input name="firstName" label="First name" required></eun-input>
<eun-input
name="email"
label="Email address"
type="email"
icon="mail"
clearable
required
></eun-input>
<eun-input
name="age"
label="Age"
type="number"
min="0"
max="120"
></eun-input>
<eun-button type="submit" rounded>Submit</eun-button>
<eun-button type="reset" appearance="flat" rounded>Reset</eun-button>
</form>
</template>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
@Component({
selector: "app-my-form",
template: `
<form
#form
(eunchange)="renderOutput(form)"
(submit)="onSubmit($event, form)"
(reset)="onReset(form)"
>
<eun-input name="firstName" label="First name" required></eun-input>
<eun-input
name="email"
label="Email address"
type="email"
icon="mail"
clearable
required
></eun-input>
<eun-input
name="age"
label="Age"
type="number"
min="0"
max="120"
></eun-input>
<eun-button type="submit" rounded>Submit</eun-button>
<eun-button type="reset" appearance="flat" rounded>Reset</eun-button>
</form>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class MyFormComponent {
renderOutput(form: HTMLFormElement) {
console.log(Object.fromEntries(new FormData(form)));
}
onSubmit(event: Event, form: HTMLFormElement) {
event.preventDefault(); // replace with a real submission
this.renderOutput(form);
}
onReset(form: HTMLFormElement) {
setTimeout(() => this.renderOutput(form));
}
}
Server-side rendering
The label, the instructions and the field are drawn inside the input's
shadow root, so a server-rendered input takes no space until the element
upgrades, then pushes the content below it. Link field-ssr.css in the
page's <head> to draw them from the first paint:
<link
rel="stylesheet"
href="node_modules/@eunomia/elements/dist/ssr/field-ssr.css"
/>
A bundler that resolves package exports can reach it as
@eunomia/elements/field-ssr.css. The same stylesheet covers eun-input,
eun-search, eun-textarea, eun-select, eun-counter,
eun-date-picker, eun-time-picker and eun-color-picker. It reads label, required,
instructions, placeholder, value, icon, appearance, disabled,
readonly and the --field-* custom properties, written in the
server-rendered markup. The label and the instructions take their final
height, and the field its final box. A password's value is never written out. The required
asterisk, the messages and the clear and password buttons show with the
upgrade.
Theming
Colors come from the active theme's --eun-* tokens (see
--field-* custom properties instead of the theme tokens
directly (full list in the API tab), since these are shared across every
text-like field component (eun-input, eun-textarea, and eun-select),
not just this one.
<eun-input
label="Custom"
style="
--field-border-color: #b45309;
--field-border-color-hover: #92400e;
--field-border-color-focus: #92400e;
--field-border-radius: 2px;
"
></eun-input>
<eun-input
label="Custom"
style={{
"--field-border-color": "#b45309",
"--field-border-color-hover": "#92400e",
"--field-border-color-focus": "#92400e",
"--field-border-radius": "2px",
}}
/>
The custom properties that change colors, borders, shadows and corners, at a glance. The API tab lists every property of the component.
| Variable | Sets |
|---|---|
--field-background |
Sets the background color of the field, the surface color by default for the outline appearance |
--field-border-color |
Sets the border color of the field |
--field-border-color-hover |
Sets the border color on hover |
--field-border-color-focus |
Sets the border color on focus |
--field-border-radius |
Sets the corner radius of the field |
--field-text-color |
Sets the text color of the field |
--field-placeholder-color |
Sets the color of the placeholder |
--field-icon-color |
Sets the color of the leading icon and action buttons |
--field-error-color |
Sets the color of the error message |
--field-hint-color |
Sets the color of the hint message |
--field-focus-outline-color |
Sets the color of the focus outline |
--field-edit-confirm-color |
Sets the color of the confirm button in readonly-available edit mode |
--field-edit-cancel-color |
Sets the color of the cancel button in readonly-available edit mode |
Keyboard and focus
Keyboard interactions
When focus is on the field:
| Key | Action |
|---|---|
Tab / Shift+Tab |
Moves focus in/out of the field, natively (single tab stop) |
| Any character key | Types into the field, natively |
Enter |
Submits the closest ancestor <form>, as a native text field does |
On the password-visibility toggle (type="password"), the next tab stop after the field:
| Key | Action |
|---|---|
Enter / Space |
Shows or hides the value as plain text |
On the read-only click-to-edit display (readonly-available, not editing):
| Key | Action |
|---|---|
Enter |
Enters edit mode |
Space |
Enters edit mode |
While editing, Enter in the field confirms the edit, like the save button, validation included.
The clear button is rendered tabindex="-1" on purpose: it's a
pointer-optimized shortcut for an action a keyboard user can already do
faster natively, by selecting the whole value and typing over it. It
remains reachable and operable by mouse, touch, and switch-access, and
still exposes a proper accessible name, so screen-reader users navigating
by their virtual cursor can find and activate it. The password-visibility
toggle stays in the Tab order instead, since nothing else lets a keyboard
user check what they typed.
Focus indicator
The native field renders a :focus-visible outline (--field-focus-outline-color,
falling back to --eun-color-primary-500) that switches to
--field-error-color while invalid, so the color itself communicates
validity state to sighted keyboard users, not just the border. The
underline appearance suppresses this outline (the border-color change is
kept as the sole focus indicator there) since an outline would visually
clash with the bottom-only border.
Semantics and screen readers
Aria attributes
aria-invalid="true"|"false": true while the error is shown, or whileinvalidis setaria-required: set natively by the underlyingrequiredattributearia-label: set from thelabelproperty directly on the native field. Notaria-labelledbypointing at the rendered<eun-label>:eun-labelrenders its text inside its own shadow root, a different shadow tree than this field's<input>, and ID references (aria-labelledby,for) don't cross shadow-root boundaries in current browsers. Computing a plain-textaria-labelfrom the samelabelproperty sidesteps that entirely and works everywhere today. If you only slot custom label content (nolabelproperty) instead of using the property, set your ownaria-labeloneun-inputto match, since the automatic one only derives from the property.aria-describedby="description": set on the field whenever a hint or error message is actually rendered below it (and only then, to avoid a dangling ID reference when nothing is shown), so the message is announced together with the field, not just visually adjacent to itaria-pressed="true"|"false": on the password-visibility toggle, reflecting whether the value is currently shown as plain text- On the
readonly-availablestatic display,role="button"plus the samearia-labelcomputed fromlabel, so its accessible name identifies the field even though its visible text is the current value (or the empty-value placeholder)
Label accessibility
Always set label (or slot equivalent content into the label slot via
eun-label's API): an input with no accessible name is one of the most
common, and most disruptive, screen-reader failures. placeholder is never
a substitute: it vanishes as soon as a value is typed, so anyone who tabs
past a filled field, or who never saw the placeholder due to autofill, has
no way to recover what the field is for.
When the context already names the field, keep label and add
hide-label rather than dropping the label.
Field states
Disabled vs. readonly
disabled removes the field from the tab order (tabindex="-1",
aria-disabled="true") and excludes its value from form submission
entirely. readonly (native readonly attribute, reflected as
aria-readonly="true") keeps the field focusable and its value selectable
and submitted, communicating "you can't change this right now", not
"this doesn't apply". Prefer readonly over disabled whenever the value
is still meaningful context for the user (e.g. a computed total), and
reserve disabled for fields that are genuinely not part of the current
interaction at all.
Error / validation state
Error messages replace the hint text in the same #description region
(never shown alongside it, so screen-reader users aren't read stale
guidance and an active error at once) and are linked via
aria-describedby as described above. Prefer native constraint attributes
(required, pattern, minlength, …) over custom validators where an
equivalent exists: they're validated by the browser itself, so their
semantics are already understood by assistive technology and autofill,
independent of anything this component does.
Readonly-available / click-to-edit flow
The static display exposes role="button" and full keyboard access
(Tab to reach it, Enter/Space to activate it), so it's operable
without a pointer despite looking like plain text. Confirming an edit
re-validates first: an invalid value keeps the field in edit mode, shows
its error and moves the focus back into it, rather than silently
discarding the edit or committing bad data. Once the edit is confirmed or
cancelled, the focus moves to the static display, so it isn't lost along
with the edit buttons.
Reference links
WAI-ARIA Authoring Practices: Forms patterns WAI Web Accessibility Tutorials: Labeling Controls WHATWG HTML: The input element