# Input

> Every native input type and the textarea in one field look (text, number, date and time, color, file, one-time code), and a field shell with adornments, a clear button, a password reveal and steppers.

Page: https://svelte-lean.vercel.app/primitives/input · Markdown: https://svelte-lean.vercel.app/primitives/input.md · Index: https://svelte-lean.vercel.app/llms.txt

Every native input type and the textarea in one field look, and a field shell for adornments, a clear button, a password reveal and steppers.

```svelte
<script lang="ts">
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';

	let email = $state('');
</script>

<div data-slean="field">
	<label for="input-email" data-slean-part="label">Email</label>
	<input
		id="input-email"
		type="email"
		name="email"
		autocomplete="email"
		placeholder="name@example.com"
		bind:value={email}
		data-slean="input"
	/>
</div>
```

Type a partial address and leave the field: the red border appears only after an edit.

## Examples

### Types

```svelte
<script lang="ts">
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';
</script>

<!-- The type picks the on-screen keyboard; autocomplete lets the browser fill the value. -->
<div class="grid">
	<div data-slean="field">
		<label for="input-password" data-slean-part="label">Password</label>
		<input id="input-password" type="password" autocomplete="current-password" data-slean="input" />
	</div>
	<div data-slean="field">
		<label for="input-search" data-slean-part="label">Search</label>
		<input id="input-search" type="search" placeholder="Projects, people…" data-slean="input" />
	</div>
	<div data-slean="field">
		<label for="input-url" data-slean-part="label">Website</label>
		<input id="input-url" type="url" autocomplete="url" placeholder="https://" data-slean="input" />
	</div>
	<div data-slean="field">
		<label for="input-tel" data-slean-part="label">Phone</label>
		<input id="input-tel" type="tel" autocomplete="tel" data-slean="input" />
	</div>
</div>

<style>
	.grid {
		display: grid;
		grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
		gap: 1.25rem;
	}
</style>
```

The type picks the on-screen keyboard; autocomplete lets the browser fill the value.

### Number, date, time, color and file

```svelte
<script lang="ts">
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';
</script>

<!-- The type is the mode: the browser draws the segments, pickers and chooser. -->
<div class="grid">
	<div data-slean="field">
		<label for="input-guests" data-slean-part="label">Guests</label>
		<input id="input-guests" type="number" min="1" max="12" value="2" data-slean="input" />
	</div>
	<div data-slean="field">
		<label for="input-arrival" data-slean-part="label">Arrival</label>
		<input id="input-arrival" type="date" value="2026-10-14" data-slean="input" />
	</div>
	<div data-slean="field">
		<label for="input-start" data-slean-part="label">Start time</label>
		<input id="input-start" type="time" value="09:30" step="900" data-slean="input" />
	</div>
	<div data-slean="field">
		<label for="input-period" data-slean-part="label">Billing month</label>
		<input id="input-period" type="month" value="2026-10" data-slean="input" />
	</div>
	<div data-slean="field">
		<label for="input-brand" data-slean-part="label">Brand color</label>
		<input id="input-brand" type="color" value="#2f6fde" data-slean="input" />
	</div>
	<div data-slean="field">
		<label for="input-logo" data-slean-part="label">Logo</label>
		<input id="input-logo" type="file" accept="image/png,image/svg+xml" data-slean="input" />
	</div>
</div>

<style>
	.grid {
		display: grid;
		grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
		gap: 1.25rem;
	}
</style>
```

The type is the mode: the browser draws the segments, pickers and chooser in the field look.

### One-time code

```svelte
<script lang="ts">
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';

	let code = $state('');
</script>

<!-- One input: a pasted or autofilled code lands whole; the cells are drawn. -->
<div data-slean="field">
	<label for="input-code" data-slean-part="label">Verification code</label>
	<input
		id="input-code"
		name="code"
		inputmode="numeric"
		autocomplete="one-time-code"
		maxlength="6"
		pattern={'\\d{6}'}
		required
		bind:value={code}
		aria-describedby="input-code-description"
		data-slean="input"
	/>
	<p id="input-code-description" data-slean-part="description">Six digits, sent to your phone.</p>
</div>
<div data-slean="field">
	<label for="input-pin" data-slean-part="label">PIN</label>
	<input
		id="input-pin"
		inputmode="numeric"
		autocomplete="one-time-code"
		maxlength="4"
		pattern={'\\d{4}'}
		data-slean-length="4"
		data-slean="input"
	/>
</div>
```

autocomplete="one-time-code" draws one cell per character; data-slean-length sets four or eight.

### Prefix and suffix

```svelte
<script lang="ts">
	// A <div data-slean="input"> is the field shell: @svelte-lean/vite registers its behavior.
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';
</script>

<div class="grid">
	<div data-slean="field">
		<label for="input-amount" data-slean-part="label">Amount (USD)</label>
		<div data-slean="input">
			<span data-slean-part="prefix">$</span>
			<input id="input-amount" type="number" min="0" step="0.01" value="49.00" />
			<span data-slean-part="suffix">USD</span>
		</div>
	</div>
	<div data-slean="field">
		<label for="input-site" data-slean-part="label">Website</label>
		<div data-slean="input">
			<span data-slean-part="prefix">https://</span>
			<input id="input-site" type="text" inputmode="url" placeholder="example" />
			<span data-slean-part="suffix">.com</span>
		</div>
	</div>
	<div data-slean="field">
		<label for="input-find" data-slean-part="label">Find a customer</label>
		<div data-slean="input">
			<span data-slean-part="prefix" aria-hidden="true">
				<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
					<circle cx="7" cy="7" r="4.5" />
					<path d="m10.5 10.5 3 3" />
				</svg>
			</span>
			<input id="input-find" type="search" placeholder="Name or email" />
		</div>
	</div>
</div>

<style>
	.grid {
		display: grid;
		grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
		gap: 1.25rem;
	}
</style>
```

A div around the input is the field shell; a press on an adornment focuses the input.

### Clear button

```svelte
<script lang="ts">
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';

	let query = $state('overdue invoices');
</script>

<!-- The clear button is created the first time the pointer or focus finds a value. -->
<div data-slean="field">
	<label for="input-query" data-slean-part="label">Search</label>
	<div data-slean="input" data-slean-clearable>
		<span data-slean-part="prefix" aria-hidden="true">
			<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
				<circle cx="7" cy="7" r="4.5" />
				<path d="m10.5 10.5 3 3" />
			</svg>
		</span>
		<input id="input-query" type="search" placeholder="Search orders" bind:value={query} />
	</div>
	<p data-slean-part="description">Query: “{query}”</p>
</div>
```

data-slean-clearable creates the clear button the first time the pointer or focus finds a value; it dispatches input, so the binding follows.

### Password reveal

```svelte
<script lang="ts">
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';
</script>

<!-- The reveal button is a toggle button: a fixed name and aria-pressed. -->
<div data-slean="field">
	<label for="input-new-password" data-slean-part="label">New password</label>
	<div data-slean="input">
		<input id="input-new-password" type="password" autocomplete="new-password" minlength="12" />
		<button type="button" aria-pressed="false" aria-label="Show password" data-slean-part="reveal">
			<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
				<path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8Z" />
				<circle cx="8" cy="8" r="2" />
			</svg>
		</button>
	</div>
	<p data-slean-part="description">At least 12 characters.</p>
</div>
```

The reveal button switches the field between password and text and writes aria-pressed; focus stays in the field.

### Number with steppers

```svelte
<script lang="ts">
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';

	const price = 24;
	const money = new Intl.NumberFormat('en', { style: 'currency', currency: 'EUR' });
	let quantity = $state(1);
</script>

<!-- The buttons call stepDown() and stepUp(): min, max and step are the input's. -->
<div data-slean="field">
	<label for="input-quantity" data-slean-part="label">Quantity</label>
	<div data-slean="input">
		<button type="button" tabindex="-1" aria-label="Decrease" data-slean-part="decrement"></button>
		<input id="input-quantity" type="number" min="1" max="10" bind:value={quantity} />
		<button type="button" tabindex="-1" aria-label="Increase" data-slean-part="increment"></button>
	</div>
	<p data-slean-part="description">Total {money.format(price * (quantity || 0))}</p>
</div>
```

A press dispatches input and change, so bind:value and the total follow the buttons; without JavaScript the native spin buttons stay.

### Looks

```svelte
<script lang="ts">
	import '@svelte-lean/styles/input.css';

	const variants = ['outlined', 'filled', 'standard'];
	const sizes = ['small', 'medium', 'large'];
</script>

<!-- data-variant is the shape (outlined is the default), data-size the height; a variant is never a color. -->
{#each variants as variant}
	<div class="row">
		{#each sizes as size}
			<input data-slean="input" data-variant={variant} data-size={size} placeholder={size} aria-label="{variant}, {size}" />
		{/each}
		<input data-slean="input" data-variant={variant} aria-invalid="true" value="Invalid" aria-label="{variant}, invalid" />
		<div data-slean="input" data-variant={variant} data-color="success">
			<span data-slean-part="prefix">@</span>
			<input placeholder="handle" aria-label="{variant} shell" />
		</div>
	</div>
{/each}
```

outlined (the default), filled and standard, on bare inputs and on a shell; aria-invalid draws the error line, data-color the focus color. Under a coarse pointer medium takes the large height.

### Textarea

```svelte
<script lang="ts">
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';

	let message = $state('');
</script>

<div data-slean="field">
	<label for="input-message" data-slean-part="label">Message</label>
	<textarea
		id="input-message"
		name="message"
		placeholder="Write a few lines…"
		bind:value={message}
		data-slean="input"
	></textarea>
</div>
```

Three lines at least; it grows with its text where field-sizing is supported.

### States

```svelte
<script lang="ts">
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';
</script>

<div class="grid">
	<div data-slean="field">
		<label for="input-plan" data-slean-part="label">Read-only</label>
		<input id="input-plan" value="Team" readonly data-slean="input" />
	</div>
	<div data-slean="field">
		<label for="input-seats" data-slean-part="label">Disabled</label>
		<input id="input-seats" value="12" disabled data-slean="input" />
	</div>
	<div data-slean="field">
		<label for="input-handle" data-slean-part="label">Error</label>
		<input
			id="input-handle"
			value="ada lovelace"
			aria-invalid="true"
			aria-describedby="input-handle-error"
			data-slean="input"
		/>
		<p id="input-handle-error" data-slean-part="error">Use letters, digits and hyphens only.</p>
	</div>
	<div data-slean="field">
		<label for="input-domain" data-slean-part="label">Warning</label>
		<input
			id="input-domain"
			value="ada@gmial.com"
			data-status="warning"
			aria-describedby="input-domain-description"
			data-slean="input"
		/>
		<p id="input-domain-description" data-slean-part="description">Did you mean gmail.com?</p>
	</div>
</div>

<style>
	.grid {
		display: grid;
		grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
		gap: 1.25rem;
	}
</style>
```

aria-invalid marks an error the application found; data-status marks a warning.

### With a copy button

```svelte
<script lang="ts">
	// data-slean-copy is a Tier 1 attribute: @svelte-lean/vite registers the copy behavior.
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/copy.css';
	import '@svelte-lean/styles/input.css';

	const link = 'https://example.com/invite/8f2a1c';
</script>

<div class="invite">
	<input value={link} readonly aria-label="Invite link" data-slean="input" />
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-slean-copy={link}>
		<span data-slean-part="icon">
			<svg
				viewBox="0 0 24 24"
				width="16"
				height="16"
				fill="none"
				stroke="currentColor"
				stroke-width="2"
				aria-hidden="true"
			>
				<rect width="14" height="14" x="8" y="8" rx="2" />
				<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
			</svg>
		</span>
		Copy
	</button>
</div>

<style>
	.invite {
		display: flex;
		gap: 0.5rem;
	}

	.invite > input {
		flex: 1;
	}
</style>
```

## Usage

Install `@svelte-lean/primitives` and `@svelte-lean/styles`. With `@svelte-lean/vite` the behavior registers itself for every `data-slean="input"`; without it, import the register module once.

```bash
npm install @svelte-lean/primitives
```

`+layout.svelte or any client module`

```ts
import '@svelte-lean/primitives/input/register';
```

`stylesheets`

```ts
import '@svelte-lean/styles/input.css';
```

Put `data-slean="input"` on an `<input>` of any type, or on a `<textarea>`, with a label and the `autocomplete` token that matches the value. Bind the value with `bind:value`. A bare input loads no JavaScript.

For adornments, a clear button, a password reveal or steppers, wrap the input in `<div data-slean="input">` and leave `data-slean` off the input. Parts: `prefix`, `suffix`, `clear` (or `data-slean-clearable` on the shell), `reveal`, `decrement`, `increment`. A clearable field needs a `placeholder` (`placeholder=" "` when none should show).

For a description and an error message under the input, put it in a Field. For a calendar popup in every engine use Date picker; for two thumbs, Slider; for drag and drop, File drop.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<input data-slean="input"> of any type, or <textarea data-slean="input">` | – | yes | Tier 0. The type is the mode. A label through \<label for\>; autocomplete with the right token; data-variant, data-size, data-color, data-full-width. |
| root (shell) | `<div data-slean="input">` | – | no | Tier 1. The visible field around one \<input\> or \<textarea\> child (no data-slean on it). data-slean-clearable generates the clear button. |
| prefix | `any element` | `prefix` | no | Start adornment: text or an icon (aria-hidden when it only decorates). |
| suffix | `any element` | `suffix` | no | End adornment: a unit, a domain, a shortcut hint. |
| clear | `<button type="button" tabindex="-1" aria-label="Clear">` | `clear` | no | Generated with data-slean-clearable; shown on hover or focus while the field has a value. The field needs a placeholder (" " when none shows). |
| reveal | `<button type="button" aria-pressed="false" aria-label="Show password">` | `reveal` | no | In the tab order; holds your icon. Switches the password field to text and back. |
| decrement | `<button type="button" tabindex="-1" aria-label="Decrease">` | `decrement` | no | stepDown() on a number, date or time field. Out of the tab order: ArrowDown does the same. |
| increment | `<button type="button" tabindex="-1" aria-label="Increase">` | `increment` | no | stepUp(). Out of the tab order: ArrowUp does the same. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `characters` | focus in the field | Types (native) |
| `ArrowLeft`/`ArrowRight`/`Home`/`End` | focus in the field | Moves the caret; Shift extends the selection (native) |
| `ArrowUp`/`ArrowDown` | focus in a number, date or time field | Steps the value or the focused segment (native) |
| `Enter` | focus in an \<input\> | Submits the form (implicit submission, native) |
| `Enter` | focus in a \<textarea\> | Inserts a line break (native) |
| `Escape` | focus in type="search" | Clears the value in Chrome and Safari (native) |
| `Space`/`Enter` | focus on a reveal button | Shows or hides the password |
| `Tab` | in the page | Reaches the field, then a reveal button; the clear and step buttons are skipped |

## Styling

`input.css` draws the field for every type: the height from the control-height tokens, the border, background and text colors, `outlined`, `filled` and `standard`, the error border of `:user-invalid` and `aria-invalid`, the warning border of `data-status="warning"`, the muted surface of `:disabled`, the swatch of a color input, the selector button of a file input, the cells of a one-time code and a three-line minimum for the textarea. The shell draws the same states with `:has()` and its parts as 1rem icons. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-control-height-md` | `2.25rem` | min-block-size, inline-size, block-size |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | padding-inline, padding-inline-end, margin-inline |
| `--slean-control-border` | `var(--slean-border-strong)` | border, border-block-end-color, background-image |
| `--slean-field-radius` | `var(--slean-radius-md)` | border-radius, border-start-start-radius, border-start-end-radius |
| `--slean-control-bg` | `var(--slean-surface)` | background |
| `--slean-fg` | `var(--slean-neutral-12)` | color, border-block-end-color |
| `--slean-text-sm` | `0.875rem` | font-size |
| `--slean-leading` | `1.5` | line-height |
| `--slean-duration-fast` | `100ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | padding-block, gap, padding, min-block-size, border-radius |
| `--slean-color-main` | `var(--slean-primary-main)` | caret-color, border-color, box-shadow, border-block-end-color, color, background-image |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap, padding-inline, min-block-size, padding-block, margin-inline-start, block-size, background-position |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color |
| `--slean-control-height-sm` | `2rem` | min-block-size, inline-size, block-size |
| `--slean-radius-sm` | `calc(var(--slean-radius) * 0.6)` | border-radius |
| `--slean-control-height-lg` | `2.75rem` | min-block-size, inline-size, block-size |
| `--slean-space-4` | `calc(var(--slean-spacing) * 4)` | padding-inline |
| `--slean-text-md` | `1rem` | font-size |
| `--slean-control-border-hover` | `initial` | border-color |
| `--slean-warning-main` | `oklch(52% 0.125 55)` | border-color, border-block-end-color, box-shadow |
| `--slean-control-ring-warning` | `0 0 0 3px color-mix(in oklch, var(--slean-warning-main) 28%, transparent)` | box-shadow |
| `--slean-error-main` | `oklch(52% 0.19 25)` | border-color, border-block-end-color, box-shadow |
| `--slean-control-ring-error` | `0 0 0 3px color-mix(in oklch, var(--slean-error-main) 22%, transparent)` | box-shadow |
| `--slean-control-border-disabled` | `var(--slean-border)` | border-color, border-block-end-color |
| `--slean-control-bg-disabled` | `var(--slean-muted)` | background |
| `--slean-control-filled-bg` | `var(--slean-muted)` | background-color |
| `--slean-control-filled-bg-hover` | `var(--slean-muted-hover)` | background-color |
| `--slean-control-icon` | `var(--slean-fg-muted)` | color |
| `--slean-icon-clear` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cdefs%3E%3Cmask id='m'%3E%3Crect width='16' height='16' fill='white'/%3E%3Cpath d='M6 6l4 4m0-4l-4 4' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3C/mask%3E%3C/defs%3E%3Ccircle cx='8' cy='8' r='6.5' mask='url(%23m)'/%3E%3C/svg%3E")` | mask |
| `--slean-icon-minus` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8h9' stroke='black' stroke-width='1.5' stroke-linecap='round' fill='none'/%3E%3C/svg%3E")` | mask-image |
| `--slean-icon-plus` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 3.5v9M3.5 8h9' stroke='black' stroke-width='1.5' stroke-linecap='round' fill='none'/%3E%3C/svg%3E")` | mask-image |
| `--slean-muted` | `var(--slean-neutral-3)` | background |
| `--slean-icon-eye` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cg stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'%3E%3Cpath d='M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z'/%3E%3Ccircle cx='8' cy='8' r='2'/%3E%3C/g%3E%3C/svg%3E")` | mask |
| `--slean-icon-eye-off` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cg stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'%3E%3Cpath d='M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z'/%3E%3Ccircle cx='8' cy='8' r='2'/%3E%3Cpath d='M2.5 2.5l11 11'/%3E%3C/g%3E%3C/svg%3E")` | mask-image |
| `--slean-border` | `var(--slean-neutral-6)` | border |
| `--slean-font-weight-medium` | `500` | font-weight |
| `--slean-muted-hover` | `var(--slean-neutral-4)` | background |
| `--slean-font-mono` | `ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace` | font-family |
| `--slean-text-lg` | `1.125rem` | font-size |

State selectors the stylesheet targets, all from the platform or ARIA: `::-moz-color-swatch`, `::-webkit-calendar-picker-indicator`, `::-webkit-color-swatch`, `::-webkit-color-swatch-wrapper`, `::-webkit-datetime-edit-fields-wrapper`, `::-webkit-inner-spin-button`, `::-webkit-search-cancel-button`, `::file-selector-button`, `::placeholder`, `:disabled`, `:empty`, `:focus-visible`, `:focus-within`, `:hover`, `:placeholder-shown`, `:read-only`, `:user-invalid`, `[aria-invalid="true"]`, `[aria-pressed="true"]`, `[type="color"]`, `[type="date"]`, `[type="datetime-local"]`, `[type="file"]`, `[type="month"]`, `[type="number"]`, `[type="time"]`, `[type="week"]`.

Variant attributes: `data-size` (`small`, `large`); `data-status` (`warning`); `data-full-width`; `data-variant` (`filled`, `standard`); `data-slean-clearable`; `data-slean-length` (`4`, `8`).

## Under the hood

Runtime and size

- **Tier**: 1 · Delegated micro-behavior
- **Behavior JS**: 1808 B brotli · 2046 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<input> of every type`, `<textarea>`, `stepUp() / stepDown()`, `:user-invalid`, `:placeholder-shown`, `::file-selector-button`, `field-sizing: content`
- **Shared listeners**: click, pointerdown, pointerover, focusin
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<input> of any type, <textarea>; stepUp()/stepDown(), type="password"↔"text"`

A bare input is Tier 0: the Vite plugin records no marker for `<input data-slean="input">` or `<textarea data-slean="input">`, and the native-only fixture proves the path ships no Svelte Lean JavaScript. A `<div data-slean="input">` is the field shell, Tier 1: shared `click`, `pointerdown`, `pointerover` and `focusin` listeners, no per-instance state.

Why this implementation

A field is the control every form has, and the platform implements nearly all of it: editing, autofill and password managers keyed by `autocomplete`, the on-screen keyboard chosen by `type`, constraint validation and `:user-invalid`, and for the other types the number keys, the date and time segments and pickers, the color picker and the file chooser. A bare `<input data-slean="input">` keeps all of that and gets the field look; the `type` is the mode, so one primitive covers what other libraries split into a number field, a date field, a color field, a file field and a one-time code.

What the platform lacks is the decoration around the value: a unit or an icon inside the box, a clear button, a password reveal, step buttons that look like the field. The field shell (MUI’s adornments, Ant’s `prefix`, `suffix`, `allowClear` and `Input.Password`) puts them in a `<div data-slean="input">` around the same native input, which keeps the value, the keys and the form value. One shared listener per event type serves every shell on the page; the clear button is created only when the pointer or focus first finds a value.

### The browser owns

- editing, selection, autofill and spell checking
- the mode of each type: number keys and spin buttons, date and time segments and pickers, the color picker, the file chooser
- constraint validation (required, pattern, min, max, the email and url formats) and :user-invalid after an edit
- the platform keyboard chosen by type, inputmode and autocomplete, one-time codes from SMS and password managers
- the form value, and a textarea that grows with its content (field-sizing)

### Svelte Lean owns

- input.css: every type in the field look, three sizes, outlined, filled and standard, status and disabled states, one-time code cells
- the field shell: prefix and suffix adornments, a clear button generated on first use, a password reveal, steppers that call stepUp() and stepDown()
- one shared listener per event type for every shell on the page; nothing for a bare input

Accessibility

- A visible label through `<label for>`. A `placeholder` is a hint about the format, not a label: it disappears when the user types.
- `autocomplete` tokens let the browser and password managers fill the field (`one-time-code` brings codes from SMS); `type` and `inputmode` choose the on-screen keyboard.
- The browser exposes `required` and constraint failures itself. An error the application found is `aria-invalid="true"` on the input and a message listed in `aria-describedby`; Field with `data-slean-validate` writes the browser’s own message.
- A one-time code is one input with one label, not six unlabeled boxes; a pasted code lands whole.
- The reveal button is a toggle button with a fixed name and `aria-pressed`, in the tab order. The clear and step buttons are out of it (`tabindex="-1"`): the keys already clear and step the field.
- A unit or currency beside the value is not read with it: repeat it in the label (“Amount (USD)”) when it changes the meaning.
- Under a coarse pointer the text takes the base size, which keeps iOS Safari from zooming the page when a field takes focus.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `Every input type, <textarea>, ::placeholder, :placeholder-shown, stepUp()` | Widely available | Not applicable |
| `:user-invalid` | Baseline 2023 | No invalid border after an edit |
| `:has() (the shell’s states)` | Baseline 2023 | The shell shows no focus halo or error border |
| `@media (scripting)` | Baseline 2023 | The step buttons show without JavaScript and do nothing |
| `field-sizing: content` | Newly available since June 2026 | The textarea keeps the height of its rows and scrolls |

Without JavaScript

A bare input is fully functional, including validation on submit and the invalid border after an edit. In a shell the field and its adornments work; the step buttons and the clear button are hidden and the native spin buttons and search cancel button stay; the reveal button does nothing.

Server rendering and hydration

The `value` attribute, or the textarea’s text, renders the initial value; `required`, `pattern`, `aria-invalid`, the adornments and the authored buttons render as markup. The only generated part is the clear button, created on first use.

A bare input has nothing attached. A shell is handled by the document’s shared listeners from its first event; hydration attaches nothing per shell.

Compatibility

Every input type and the textarea are widely available; `:user-invalid`, `:has()` and `@media (scripting)` are Baseline 2023. `field-sizing` is newly available since June 2026: where it is missing, the textarea keeps the height of its rows and scrolls. The date and time segments and the color swatch are each engine’s own.

Tests and source

- `packages/primitives/tests/input.test.ts` Vitest: steppers, the generated clear button, reveal, focus forwarding, validation, 1000 shells
- `apps/playground/tests/primitives/forms.spec.ts` Playwright: keyboard, form values, :user-invalid, names and descriptions, axe
- `apps/playground/tests/primitives/fields.spec.ts` Playwright, with page JavaScript enabled and disabled, and axe
- `apps/playground/tests/primitives/inputs-overlays.spec.ts` Playwright, with page JavaScript enabled and disabled, and axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/input/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/input/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/input/behavior.ts` the field shell behavior and its helpers
- `packages/primitives/src/input/register.ts` the registration module
- `packages/styles/css/input.css` the optional stylesheet
