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
eun-textareaeun-selectGuidance
- 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
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 | 'No value set' | Text displayed in place of the value when readonly and empty |
| confirm-label | string | 'Save' | The label of the confirm button in edit mode |
| cancel-label | string | 'Cancel' | The label of the cancel button in edit mode |
| aria-label-clear | string | 'Clear field' | The accessible label of the clear button |
| aria-label-show-password | string | 'Show password' | The accessible label of the show password button |
| aria-label-hide-password | string | 'Hide password' | The accessible label of the hide password button |
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.). | |
| variant | 'outline' | 'fill' | 'underline' | 'outline' | The visual variant to apply to the field |
| label | string | — | The title label of the field, rendered through the label component |
| instructions | string | — | Instructions displayed below the label |
| 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 |
| 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 |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --field-background | Sets the background color of the field |
| --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-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 |
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" />
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"
/>
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"
/>
Variants
variant selects the visual style: outline (default, bordered box),
fill (filled background, no border), or underline (bottom border only).
<eun-input label="Fill" variant="fill"></eun-input>
<eun-input label="Underline" variant="underline"></eun-input>
<eun-input label="Fill" variant="fill" />
<eun-input label="Underline" variant="underline" />
Part of the shared vocabulary covered in
With a leading icon
<eun-input
label="Email address"
type="email"
icon="email"
placeholder="jane.doe@email.com"
></eun-input>
<eun-input
label="Email address"
type="email"
icon="email"
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" />
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" />
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, with no event fired.
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.
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="email"
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="email"
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="email"
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="email"
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));
}
}
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",
}}
/>
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>, natively (unaffected by eun-input itself) |
On the read-only click-to-edit display (readonly-available, not editing):
| Key | Action |
|---|---|
Enter |
Enters edit mode |
Space |
Enters edit mode |
The clear button and password-visibility toggle are rendered tabindex="-1"
on purpose: they're pointer-optimized shortcuts for actions a keyboard user
can already do faster natively (select-all and type over the value, while
the show/hide toggle has no keyboard-only equivalent need since the value
being typed is already visible to its author at the OS input level). They
remain reachable and operable by mouse, touch, and switch-access, and both
still expose a proper accessible name and (for the toggle) aria-pressed,
so screen-reader users navigating by their virtual cursor can find and
activate them, and are simply excluded from the sequential Tab order.
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 variant 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.
Aria attributes
aria-invalid="true"|"false": kept in sync withvalidaria-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.
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 focus in the field and the
error message shown, rather than silently discarding the edit or
committing bad data.
Reference links
WAI-ARIA Authoring Practices: Forms patterns WAI Web Accessibility Tutorials: Labeling Controls WHATWG HTML: The input element