Zero dependencies, fully accessible, form-native input components built with Lit. Just drop them in and they work.
npm install inputs.jsOr use directly from CDN:
<script type="module" src="https://unpkg.com/inputs.js"></script><script type="module">
import 'inputs.js/components/input-text.js';
</script>
<input-text
name="username"
label="Username"
placeholder="johndoe"
required>
</input-text>That's it. You now have a fully accessible, form-integrated input component.
- 🎯 Progressive Enhancement - Enhances native inputs, doesn't replace them
- ♿ Accessibility First - WCAG AA compliant out of the box
- 📝 Form Native - Works with
<form>,FormData,form.reset()- no wrappers needed - 🔧 Zero Dependencies - Just Lit. Nothing else.
- 🎨 Style It Your Way - Uses semantic classes, no forced styling
- ⚡ Instant Validation - Schema-based validation with async support
All components share the same API. Once you learn one, you know them all.
<input-text
name="bio"
label="About you"
min="10"
max="200"
validate-on="input">
</input-text><input-email
name="email"
label="Email address"
validate-on="blur"
required>
</input-email><input-password
name="secret"
label="Password"
action-button="hide"
min="8"
required>
</input-password><input-number
name="age"
label="Age"
min="18"
max="120"
required>
</input-number><input-phone
name="mobile"
label="Mobile number"
validate-on="blur">
</input-phone><input-url
name="website"
label="Your website"
placeholder="https://example.com">
</input-url><input-search
name="query"
label="Search"
placeholder="Type to search..."
action-button="clear">
</input-search><input-date
name="birthday"
label="Birthday"
validate-on="change">
</input-date><input-color
name="theme"
label="Pick your color"
value="#3b82f6">
</input-color>Validation is declarative. Just add attributes:
<input-text
name="username"
label="Username"
min="3"
min-message="Too short! Need at least 3 chars"
max="20"
max-message="Whoa there! Max 20 chars"
regex="^[a-zA-Z0-9]+$"
regex-message="Only letters and numbers allowed"
validate-on="input|blur"
required>
</input-text>String validators:
min,max- Length limitsemail,url- Format validationregex- Custom patternsstarts-with,ends-with- Prefix/suffix rulesuuid,base64,hex- Common formats
Number validators:
gt,gte,lt,lte- Value rangespositive,int- Type constraintsmin,max- Numeric limits
Listen to what happens:
const input = document.querySelector('input-email');
// Events
input.addEventListener('input:success', () => {
console.log('✅ Valid email!');
});
input.addEventListener('input:error', (e) => {
console.log('❌', e.detail.error);
});
// Or hooks (simpler)
input.onValidate = ({ valid, error }) => {
if (valid) {
// Do something cool
}
};Available events:
input:init- Component readyinput:input- User typinginput:change- Value changedinput:validate- Validation raninput:success- Valid inputinput:error- Invalid input
No forced styles. Use semantic classes:
.input-wrapper {
margin-bottom: 1rem;
}
.input-label {
font-weight: 600;
color: #374151;
}
.input-input {
border: 2px solid #e5e7eb;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
}
.input-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.input-error {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.input-error-visible {
display: block;
}Works with native forms out of the box:
<form id="my-form">
<input-text name="name" label="Name" required></input-text>
<input-email name="email" label="Email" required></input-email>
<button type="submit">Submit</button>
</form>
<script>
const form = document.getElementById('my-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
// Validate all inputs
const inputs = form.querySelectorAll('input-text, input-email');
let allValid = true;
for (const input of inputs) {
const { valid } = await input.validate();
if (!valid) allValid = false;
}
if (allValid) {
const data = new FormData(form);
console.log('Form data:', Object.fromEntries(data));
}
});
</script>Add copy, clear, or show/hide actions:
<input-text
action-button="copy"
label="API Key">
</input-text>
<input-password
action-button="hide"
label="Password">
</input-password><input-text
prefix="$"
label="Amount">
</input-text>
<input-text
prefix-icon="🔗"
label="Website">
</input-text><input-text
shadow
label="Encapsulated input">
</input-text>- Debounced Validation - Validation auto-debounces at 300ms
- Async Validation - Perfect for checking username availability
- Keyboard Navigation - Full keyboard support out of the box
- ARIA Compliant - Screen readers just work
- Form Reset -
form.reset()works perfectly
All components expose a clean API:
const input = document.querySelector('input-text');
// Properties
console.log(input.value); // Current value
console.log(input.valid); // true/false
console.log(input.error); // Error message or null
// Methods
await input.validate(); // Run validation
input.reset(); // Clear value and errors
input.focus(); // Focus the inputThat's Inputs.js. Simple, powerful, and accessible. Start building better forms today.
Need more? Check out each component's specific docs for advanced options.
*Built with ❤️ using Lit. No dependencies, just pure web component goodness All components: [6][1]
- Use
ElementInternals - Participate in native forms
- Support
form.reset() - Support custom + native validation
Every Inputs.js component must render the same DOM structure: [6]
<div class="input-wrapper">
<label class="input-label"></label>
<input class="input-input" />
<p class="input-description"></p>
<p class="input-error"></p>
</div>Structure rules: [6]
.input-wrapperwraps all internal elements..input-labelis linked to the input viafor+id..input-inputis the core<input>element..input-descriptionis only visually shown when adescriptionis provided..input-erroris only visibly shown when an error exists.
Additional state classes used by components:
.input-error-visible– toggled when there is an error [2].input-input-error– toggled on the input when invalid [2]
These classes are intended for your own CSS.
Implementation detail: some base classes (e.g.
input-text-base,input-number-base) internally use helper markup like.i-field,.i-wrapper, etc. Public components must still conform to the DOM contract above. [3][5][6]
Inputs.js is class-based only; there is no built-in design system or utility framework. You style everything via the standard class names:
input-wrapperinput-labelinput-inputinput-descriptioninput-errorinput-error-visibleinput-input-error
You can keep components in light DOM (default) for full global CSS control, or enable Shadow DOM per-instance via the shadow attribute (see below). Shadow/root creation is delegated to the component’s createRenderRoot() method, which respects the shadow attribute. [1]
The spec describes structure and behavior but does not prescribe a specific packaging format (npm name, build system). This section reflects a typical setup using ES modules and bare imports, based on standard Web Component practice.
Assuming Inputs.js is published as an ES module package:
npm install inputs.jsOr include via <script type="module"> pointing to the distributed files.
<script type="module">
import 'inputs.js/components/input-text.js';
</script>
<form id="signup">
<input-text
name="email"
label="Email"
placeholder="you@example.com"
description="We’ll never share your email."
validate-on="input"
email
required
></input-text>
<button type="submit">Submit</button>
</form>Values will be part of the form’s FormData (e.g. new FormData(form).get('email')) thanks to ElementInternals#setFormValue. [1][6]
All Inputs.js components must support these attributes:
name– form field name (participates inFormData) [1][3][5]value– string value (also available as a property) [1]label– text for<label class="input-label">[6]placeholder– forwards to the internal<input>placeholder [1][2][3][5]description– text for.input-description[1][2][6]required– standard HTML required flag [1][2][3][5]disabled– disables the control [1][2][3][5]readonly– read-only value [1][2][3][5]shadow– when present, component uses Shadow DOM; otherwise light DOM [1]inline– layout hint; used by components for styling/markup variations [1]error– manually set or override the current error message (also mirrored as property) [1]validate-on– controls when validation runs:"input" | "change" | "blur"; can be combined using comma, space, or|separated values (e.g.validate-on="input|blur"). [1]
Action / decoration attributes:
action-button="copy|hide"– optional inline action:"copy"– copy current value to clipboard"hide"– toggle visibility for password-style inputs
Supported by text-like and number-like bases via_renderAction()helpers. [3][5]
prefix-icon– visual icon (e.g. as text or styled element) displayed near the input.prefix/prefix-value– text prefix or logical value prefix for text inputs (e.g. currency symbol, URL scheme). [5]
Inputs.js uses zod‑mini for schema-based validation.
All zod-mini rules are configurable via HTML attributes; for example:
- String-related:
min/max– length boundaries (z.string().min(...),z.string().max(...))regex– RegExp for the valuestarts-with/ends-withemail,url– semantic validators
- Number-related (on number components): [3]
gt,gte,lt,ltemin(alias forgte),max(alias forlte)int,positive,nonnegative
Each rule has an associated -message variant to customize error messages, e.g.:
min="5"andmin-message="Minimum length is 5 characters"emailandemail-message="Please enter a valid email address"
The component’s validate() method builds a zod schema from the active attributes and uses it for synchronous + async validation. [3][5]
Validation is centralized in base classes and handled consistently across components:
-
Attributes are read and converted into a zod-mini schema (string or number specific). [3][5]
-
Validation can be debounced and async, with an internal
AbortControllerto cancel previous requests while a new validation run is scheduled. [1] -
The public
validate()method returns:{ valid: boolean; error: string | null; }
-
When validation runs:
this.validandthis.errorare updated.ElementInternals#setValidity()is called with appropriate flags. [1][2]- The component fires:
input:validate(always)input:errorif invalidinput:successif valid [1]
-
Triggers are controlled by
validate-on:"input"– validate while typing"change"– validate onchange"blur"– validate when leaving the field- Can be combined (e.g.
validate-on="input blur"). [1]
The spec defines common default messages: [6]
"This field is required""Invalid email address""Enter a valid value"
These can be overridden by your own *-message attributes or by setting error directly.
All Inputs.js components dispatch the same set of custom events, always with { bubbles: true, composed: true }. [1][6]
input:init– fired when the component initializes (connected to DOM). [1]input:input– fired on each input (@input) change.input:change– fired on nativechange.input:validate– fired when validation runs.input:error– fired when validation fails or when an internal error is set.input:success– fired when validation succeeds (no errors).
You can handle them via:
el.addEventListener('input:validate', (event) => {
console.log(event.detail); // component-specific payload
});To make components fully hookable, Inputs.js defines a set of lifecycle hooks that are invoked internally and can be attached from the outside. [6][1]
Hooks:
onInitonBeforeRenderonAfterRenderonInputonChangeonValidateonErroronSuccess
Supported integration styles:
-
HTML attributes
(Exact wiring mechanism—e.g. global function name vs. inline JS—is implementation-dependent. The spec only mandates availability via attributes. [6]) -
JS properties
const email = document.querySelector('input-email'); email.onValidate = (detail) => { console.log('validated', detail); };
-
Custom events
Use the event names listed above; hooks and events are designed to align.
Internally, base components call a structure like _callHook('onInit'), etc., on lifecycle transitions (connect, willUpdate, updated, input handlers, and validation). [1]
All Inputs.js components are form-associated custom elements:
static formAssociated = true;- Call
this.internals = this.attachInternals();in the constructor. [1][4][6]
This enables:
- Participation in
FormDataviainternals.setFormValue(this.value ?? ''). [1] - Native form reset support via
formResetCallback()→this.reset(). [1][2][4] - Restoring form state (e.g. navigation / BF cache) via
formStateRestoreCallback(state). [1][2][4] - Native validation integration via
internals.setValidity(...)for error state and message anchoring. [1][2]
Example callbacks (simplified from implementations): [1][2][4]
formResetCallback() {
this.reset();
}
formStateRestoreCallback(state) {
if (state !== undefined) this.value = state;
}Each Inputs.js component exposes the same core API:
value: string– current value. Reflected as an attribute where appropriate. [1]valid: boolean– validity status, reflected as attribute for styling. [1]error: string | null– current error message (or null if valid). [1]
Additional properties for specific components (e.g. country on <input-phone>, inputType, autocomplete on text inputs) are documented per component below. [3][4][5]
-
validate(): Promise<{ valid: boolean; error: string | null }>
Run validation immediately (canceling any pending debounced validations), update state and internals, and dispatch validation-related events. [1] -
reset(): void
Reset the input to its initial state:- Clear value and error
- Mark the field as valid
- Call
internals.setFormValue('')and clear validity - Often used from
formResetCallback(). [1][2][4]
-
focus(): void
Focus the inner.input-inputelement. [2][4]
Error handling is defensive: components must never throw; internal errors are surfaced via input:error and through the error property. [System spec]
Components update CSS classes based on validity:
.input-errortext updated to the current error message..input-error-visibletoggled based onvalid+error. [2].input-input-errortoggled on the internal<input>. [2]
aria-invalid="true"set on the input when invalid, removed when valid. [2][3][5]aria-required="true"whenrequiredis present. [2]aria-describedbyreferences description and error elements, using generated IDs when necessary. [1][2][3][5][6]
Example (from email input update flow): [2]
- If there is a description →
id="${inputId}-desc"assigned to.input-description. - If there is an error →
id="${inputId}-error"assigned to.input-error. aria-describedbyis computed as"descId errorId"where present, or removed if none. [2]
This ensures assistive technologies correctly announce both help text and validation messages.
Inputs.js uses base components to centralize behavior.
Minimal foundation used by higher-level bases: [1]
Responsibilities:
- Form association (
formAssociated,attachInternals) [1] - Core attributes:
name,value,label,placeholder,description,required,disabled,readonly,shadow,inline,validate-on,valid,error[1] - Render root selection (
createRenderRootrespectsshadow) [1] - Lifecycle:
connectedCallback,willUpdate,updated(with hooks and events) [1] - Standard event dispatch helper (
input:init,input:input,input:change,input:validate,input:error,input:success) [1] - Debounced async validation with
AbortControllerfor cancellation [1] - Accessibility IDs generation (
this.ids = this._generateIds()) [1] - Public API wiring:
value,valid,error,validate(),reset(),focus()[1]
There is no validation logic inside input-base; validation is delegated to text/number/etc. bases. [1]
Foundation for text-like inputs (email, password, URL, generic text): [5]
- Inherits from
InputBase. [5] - Adds text-specific properties:
actionButton(copy/hide) [5]prefix/prefix-value[5]inputType(e.g."text","email","password") [5]autocomplete[5]unstyled(optional styling toggle) [5]
- Renders:
- label
- optional prefix (
_renderPrefix()→.i-prefix) [5] - input (
.input-inputmapped appropriately) - optional action button (
_renderAction()→copy/hide) [5]
- Builds zod string schemas based on attributes (
min,max,regex,email,url, etc.). [5]
Foundation for number-like inputs (amounts, integers, etc.): [3]
- Inherits from
InputBase. [3] - Adds number-specific validators as reflected attributes:
gt,gte,lt,lte,min,max,int,positive,nonnegative[3]
- Renders a number
<input>with type resolved via_getInputType()and optional prefix/action controls. [3] - Builds zod number schemas from provided attributes. [3]
Below are example concrete components inferred from the spec and snippets.
General-purpose text input built on <input-text-base>:
- Attributes:
- All core attributes
- Text validators (length, regex, starts/ends-with, etc.)
action-button,prefix,prefix-value,autocomplete
- Use cases: username, generic text fields, search inputs, etc.
Example:
<input-text
name="username"
label="Username"
description="3–20 characters, letters and numbers only."
placeholder="johndoe"
validate-on="blur"
min="3"
max="20"
regex="^[a-zA-Z0-9]+$"
regex-message="Only letters and numbers are allowed."
required
></input-text>Email-specific input, built on the text base with email semantics. Snippets for email show: [2]
- Shadow or light DOM depending on
shadow. - Syncs standard attributes (
name,placeholder,required,disabled,readonly,pattern,minlength,maxlength, etc.) to the internal<input type="email">. [2] - Manages ARIA and error state via
updateErrorState(). [2]
Usage:
<input-email
name="email"
label="Email"
description="We’ll send a confirmation link."
placeholder="you@example.com"
validate-on="input"
email
required
></input-email>Number input built on <input-number-base>: [3]
- Supports numeric zod-mini attributes:
gt,gte,lt,lte,min,max,int,positive,nonnegative[3]
- Can display prefixes like currency (
prefix="$") and optional action buttons. [3]
Usage:
<input-number
name="amount"
label="Amount"
description="Enter your amount"
placeholder="10"
validate-on="input"
gt="5"
gt-message="Minimum value is 5"
lt="100"
lt-message="Maximum value is 100"
positive
positive-message="Value must be positive"
required
></input-number>Phone input with country code selection. The snippet shows: [4]
- Extends
LitElement,formAssociated = true, uses zod for validation. [4] - Renders:
<label class="input-label">- A country
<select>with flag, name, dial code [4] - The main
<input type="tel" class="input-input">bound toformattedValue[4] - Optional
prefixIconnear the input [4] - Optional
actionButton="copy"button [4]
- ARIA:
aria-labelledby→ label IDaria-describedby→ description + error IDsaria-invalidfromvalid[4]
Example:
<input-phone
name="phone"
label="Phone number"
description="Include country code."
placeholder="123 456 789"
validate-on="blur"
required
></input-phone>By default, components may use light DOM for easier global styling, or switch to Shadow DOM when the shadow attribute is present. InputBase implements createRenderRoot() accordingly: [1]
createRenderRoot() {
return this.shadow ? this.attachShadow({ mode: 'open' }) : this;
}- Use
shadowwhen you want encapsulated styles. - Omit
shadowwhen you prefer to style with global CSS.
The spec gives a canonical file layout: [6]
inputs/
index.js
components/
email.js
styles/
email.css (optional, minimal)
docs/
specs/
inputs-spec-v0.1.md
In practice, you will have additional components (input-text.js, input-number.js, input-phone.js, their base classes, etc.) under components/.
<form id="billing">
<input-text
name="fullName"
label="Full name"
placeholder="Jane Doe"
description="As shown on your card."
required
min="3"
validate-on="blur"
></input-text>
<input-email
name="email"
label="Email"
placeholder="you@example.com"
description="We’ll send a receipt."
email
required
validate-on="input|blur"
></input-email>
<input-number
name="amount"
label="Donation amount"
prefix="$"
placeholder="50"
positive
min="1"
max="1000"
validate-on="change"
required
></input-number>
<input-phone
name="phone"
label="Phone (optional)"
description="For SMS updates about your donation."
validate-on="blur"
></input-phone>
<button type="submit">Donate</button>
</form>
<script type="module">
import 'inputs.js/components/input-text.js';
import 'inputs.js/components/input-email.js';
import 'inputs.js/components/input-number.js';
import 'inputs.js/components/input-phone.js';
const form = document.getElementById('billing');
form.addEventListener('submit', async (event) => {
event.preventDefault();
// Manually validate all Inputs.js fields
const fields = form.querySelectorAll(
'input-text, input-email, input-number, input-phone'
);
let allValid = true;
for (const field of fields) {
const { valid } = await field.validate();
if (!valid) allValid = false;
}
if (!allValid) return;
const data = new FormData(form);
console.log(Object.fromEntries(data.entries()));
});
</script>If you need a new specialized input:
- Extend the appropriate base (e.g.
InputTextBase,InputNumberBase, orInputBasedirectly). [1][3][5] - Implement
render()to match the required DOM structure (input-wrapper, etc.). [6] - Declare supported attributes in
static properties. - Implement any custom validation in
validate()by augmenting the zod-mini schema. - Respect:
validate-onsemantics- Hook calls (
onInit,onValidate, etc.) - Event dispatch (
input:*) [1][6] formResetCallbackandformStateRestoreCallback[1]