# Email field

> An email field with type=email, autocomplete and the email keyboard, an optional 'Did you mean …?' line for mistyped common domains that applies only when pressed, multiple addresses, hint and consumer-set error.

- ID: `cmp_email_field_01`
- Slug: `email-field-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-22T03:45:46Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `text-inputs`
- Detail page: https://pagesugar.com/components/email-field-01?variant=blue
- Preview: https://pagesugar.com/preview/email-field-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-4285de3324ee4ad5baffc918c055e90eabd3a349e9323f512b8e00510410a933` |
| `blue` | Blue accent | no | `sha256-c9d2612c4c01c300e1589c872a4e7d6c79731e1ac2ee4b651f7e4dd00902e8f4` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/email-field-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Give it a label and a name and it renders a labelled native email input that submits with its form before any script runs. Once hydrated it trims the value on blur and, when the domain looks like a typo of a common one, offers the corrected address under the field. It checks format only through the browser's own type=email rules; it does not verify that an address exists or receives mail, does not auto-correct, and does not check whether the address is already registered.

Required props: `label`

```svelte
<script lang="ts">
	import EmailField from '$lib/components/email-field-01/EmailField.svelte';

	let email = $state('');
	let error = $state<string | null>(null);

	/* novalidate hides the browser's bubble; the field's own type=email check still decides. */
	function check(event: SubmitEvent) {
		const form = event.currentTarget as HTMLFormElement;
		const field = form.elements.namedItem('email') as HTMLInputElement;
		error = field.validity.valid
			? null
			: field.validity.valueMissing
				? 'Enter your email address'
				: 'Enter an email address in the correct format, like name@example.com';
		if (error) event.preventDefault();
	}
</script>

<form method="POST" class="max-w-sm" novalidate onsubmit={check}>
	<EmailField
		label="Email address"
		name="email"
		required
		hint="We'll send your receipt here."
		bind:value={email}
		{error}
	/>
</form>
```

Limitations:

- Format checking is the browser's type=email rule, which accepts addresses such as name@localhost. It says nothing about whether the address exists or can receive mail; confirm ownership with an email from your backend.
- Suggestions need the page to have hydrated. Before that the field still works and submits; no suggestion appears.
- Suggestions only compare against the domains you pass (by default fourteen common webmail domains). A company or school domain is never suggested unless it is in the list.
- Native multiple does not tell screen reader users that commas separate addresses. Say so in the hint.
- With multiple, only the first mistyped address gets a suggestion at a time. The bound value is tidied to 'a, b' on blur and empty entries are dropped; the browser itself submits the list as 'a,b'.
- The error and suggestion appear below the input and the field grows to fit them. reserveFeedback holds one line (two in a field under 22rem wide); a longer suggestion, or an error and a suggestion together, still grow it.
- Suggestions compare plain ASCII domains in lower case. Internationalised domains are neither normalised nor converted to punycode, so list them in the form people type them.
- Only a plain address gets a suggestion: one @, something before it, and no spaces (or commas without multiple). Short domains such as aol.com allow one edit, longer ones two, to keep real domains like mail.com from being 'corrected'.

## Usage guide

### Email field

The text field's label, hint and error structure with the email-specific parts set for you:
`type="email"`, `autocomplete="email"`, `inputmode="email"`, spellcheck and autocapitalize off,
and an optional typo suggestion for common webmail domains.

```svelte
<EmailField
	label="Email address"
	name="email"
	required
	hint="We'll send your receipt here."
	bind:value={email}
	{error}
/>
```

#### The typo suggestion

When the field loses focus, the domain is compared with `suggestDomains`. If it is one edit away
from a short domain (`aol.com`, `me.com`) or two from a longer one (`gmial.com`, `hotmial.com`),
a line appears under the field:

> Did you mean sam@**gmail.com**?

The address is a button. Pressing it writes the address into the field, fires `input` and
`change`, and puts focus back in the field. Nothing happens unless it is pressed: the order
or sign-up still submits with what was typed. Typing again hides the line until the next blur.

The local part (before the `@`) keeps the case it was typed in. Only the domain is compared, in
lower case.

Turn it off with `suggestDomains={false}`, which suits sign-in forms where the account already
exists. Extend it for your audience:

```svelte
<script lang="ts">
	import EmailField, {
		COMMON_EMAIL_DOMAINS
	} from '$lib/components/email-field-01/EmailField.svelte';
	const domains = ['gmx.de', 'web.de', 't-online.de', ...COMMON_EMAIL_DOMAINS];
</script>

<EmailField
	label="E-Mail-Adresse"
	name="email"
	suggestDomains={domains}
	suggestionText="Meinten Sie {email}?"
	suggestionAction="{email} verwenden"
/>
```

`suggestEmail(value, domains, multiple)` is exported too, if you want the same check on the
server or in a confirmation step.

#### What it does not do

It never tells anyone an address is real. Format checking is the browser's `type="email"` rule;
whether the address exists, receives mail or is already registered is for your backend to find
out, usually by sending a confirmation email. Keep the copy around the field honest to that:
"We'll send your receipt here", not "Verified".

#### Sign-in forms

Use `autocomplete="username"` (or `"username webauthn"` with passkeys) so password managers pair
the field with the password below it, and switch suggestions off.

#### Several addresses

`multiple` sets the native attribute: the browser accepts comma-separated addresses and checks
each one. Say "Separate addresses with commas" in the hint, because nothing else tells a screen
reader user. On blur the bound value is tidied to `a, b, c` and empty entries are dropped; the
browser submits the list without the spaces (`a,b,c`).

#### Keeping the layout still

The error and the suggestion each add a line under the field. Set `reserveFeedback` to hold space
for them, so a suggestion appearing as someone tabs on doesn't push the next field down: one line,
or two when the field is under 22rem wide, where "Did you mean …?" usually wraps. A longer
suggestion, or an error and a suggestion together, still grow the field.

#### Retoning

On a `#09090b` band:

```css
.band {
	background: #09090b;
	color-scheme: dark;
	--email-field-accent: #fafafa;
	--email-field-ink: #fafafa;
	--email-field-muted: #a1a1aa;
	--email-field-placeholder: #a1a1aa;
	--email-field-hairline: rgb(255 255 255 / 0.35);
	--email-field-surface: #18181b;
	--email-field-danger: #f87171;
}
```

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | yes |  | Visible label, such as 'Email address'. Always shown. |
| `value` | `string` | no |  | Bindable value. Trimmed when the field loses focus (and the commas tidied with multiple); the case is never changed. |
| `name` | `string` | no |  | Form field name, so native submission includes the field. |
| `autocomplete` | 'email' \| 'username' \| 'username webauthn' \| 'off' | no | `'email'` | Autofill token. 'email' for sign-up, checkout and contact forms; 'username' on sign-in forms so password managers pair it with the password; 'off' for invite fields holding other people's addresses. |
| `multiple` | `boolean` | no | `false` | Accept several comma-separated addresses (the native multiple attribute). |
| `suggestDomains` | readonly string\[\] \| false | no | `COMMON_EMAIL_DOMAINS` | Domains to suggest when one is mistyped: fourteen common webmail domains by default (gmail.com, outlook.com, hotmail.com, yahoo.com, icloud.com and others). false turns suggestions off. |
| `hint` | `string` | no |  | Help text under the label, linked first in aria-describedby. |
| `error` | string \| null | no | `null` | Validation message from your code. When set, the field shows it with an icon, turns its edge and focus ring to the danger colour and sets aria-invalid. |
| `required` | `boolean` | no | `false` | Native required attribute. Shows the Required marker unless requiredIndicator says otherwise. |
| `requiredIndicator` | 'required' \| 'optional' \| 'none' | no |  | Which marker follows the label. Defaults to 'required' for required fields and 'none' otherwise. |
| `requiredLabel` | `string` | no | `'Required'` | Marker text for required fields. |
| `optionalLabel` | `string` | no | `'Optional'` | Marker text for optional fields. |
| `errorPrefix` | `string` | no | `'Error:'` | Visually hidden text read before the error. |
| `suggestionText` | `string` | no | `'Did you mean {email}?'` | The suggestion sentence. {email} marks where the suggested address, the button, goes. |
| `suggestionAction` | `string` | no | `'Use {email} instead'` | Accessible name of the suggestion button. Keep {email} in it, so the visible address is part of the name. |
| `size` | 'sm' \| 'md' \| 'lg' | no | `'md'` | 32, 36 or 40 px tall with 13, 14 or 16 px text, matching buttons of the same size. Every size is 44 px with 16 px text on touch screens. |
| `reserveFeedback` | `boolean` | no | `false` | Hold space under the field for the error or the suggestion, so one appearing moves nothing below: one line, or two in a field under 22rem wide, where the suggestion usually wraps. |
| `id` | `string` | no |  | Input id. Defaults to one generated per instance. The hint, error and suggestion always take generated ids. |
| `class` | `string` | no |  | Extra classes on the outer wrapper, for placement. |

## Customization

Change the copy and the suggestion domains through props and retone the field through seven --email-field-\* CSS variables. Hover, the disabled fill and the suggestion's underline are mixed from those, so they follow any retone.

- Accent: --email-field-accent colours the focus edge, its halo and the suggestion button's focus ring, and tints the autofilled fill. Keep it at 3:1 against the page.
- Danger: --email-field-danger colours the error edge, the error focus ring, the icon and the message. The default is red-700 (6.5:1 on white).
- Neutrals: --email-field-ink is the label, the value and the suggested address; --email-field-muted the hint, marker and suggestion sentence; --email-field-placeholder the placeholder; --email-field-hairline the resting edge (keep it at 3:1, since it is the only thing marking an empty field); --email-field-surface the fill.
- Worked retone for a #09090b band: --email-field-accent: #fafafa; --email-field-ink: #fafafa; --email-field-muted: #a1a1aa; --email-field-placeholder: #a1a1aa; --email-field-hairline: rgb(255 255 255 / 0.35); --email-field-surface: #18181b; --email-field-danger: #f87171. Add color-scheme: dark on the band.
- Cream page such as a bakery's #fbf7f0: leave the hairline alone (it is alpha black, so it tints with the page) and set --email-field-surface: #fffdf9.
- Suggestion domains: import COMMON\_EMAIL\_DOMAINS and add what your audience uses (\['gmx.de', 'web.de', ...COMMON\_EMAIL\_DOMAINS\]), or pass false on sign-in forms where the account already exists.
- Copy: translate suggestionText and suggestionAction with the page, keeping {email} in both. Translate requiredLabel, optionalLabel and errorPrefix too.
- Sizes: sm for dense tables, md for app forms, lg for marketing and checkout forms. Match the size of any button in the same row.
- Layout: the field fills its container. Stack fields with a 20 to 24 px gap; set reserveFeedback in tight forms where a suggestion appearing should not push the next field.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--email-field-accent` |
| `danger` | `--email-field-danger` |
| `ink` | `--email-field-ink` |
| `muted` | `--email-field-muted` |
| `placeholder` | `--email-field-placeholder` |
| `hairline` | `--email-field-hairline` |
| `surface` | `--email-field-surface` |

## Accessibility

- A native \<label for\> names the input. type=email, autocomplete email (or username) and inputmode email identify its purpose (WCAG 1.3.5); spellcheck and autocapitalize are off so phones do not change the address.
- aria-describedby lists the hint, the error, then the suggestion, then any aria-describedby you pass, each only when present. aria-invalid is set only while error is set.
- The suggestion sits in a polite live region, so it is read out when it appears on blur without moving focus. Its button is named 'Use sam@gmail.com instead' (suggestionAction), which contains the visible address; pressing it applies the address, fires input and change, and returns focus to the field.
- The error is never colour alone: it has an icon, text, and a visually hidden errorPrefix. It is not a live region; move focus or announce a summary when a submit fails, as your form decides.
- With multiple, nothing tells a screen reader user that commas separate the addresses. Put that in the hint.
- Focus draws a two-pixel edge in the accent (the danger colour while invalid) plus a soft halo; in forced-colours mode it becomes a two-pixel Highlight outline. The suggestion button's hit area grows to 44 px on touch screens without covering the input.
- The input stays left to right on a right-to-left page, because an address reads that way in every language.

Known limitations:

- Suggestions appear only after hydration; without JavaScript the field still submits.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Blue accent (`blue`)
- Entry: `EmailField.svelte`
- Suggested directory: `src/lib/components/email-field-01`
- Files: 1
- Artifact digest: `sha256-c9d2612c4c01c300e1589c872a4e7d6c79731e1ac2ee4b651f7e4dd00902e8f4`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `EmailField.svelte`

Role: entry · 19080 bytes · SHA-256 `d0ea85350af64a4481288ef9cfa19794f4b8c6b04984ea516d60b4bec5590ced`

```svelte
<!--
	An email address field: a visible label, an optional hint, a native type=email input with the
	email keyboard and autofill hints, a consumer-supplied error, and an optional "Did you mean
	…?" line when the domain looks like a typo of a common one. The suggestion never changes the
	value on its own; a press applies it. It is a plain native <input name>, so it submits with a
	form before any script runs, and it never claims an address exists or can receive mail.
-->
<script lang="ts" module>
	export type EmailFieldSize = 'sm' | 'md' | 'lg';
	export type EmailFieldRequiredIndicator = 'required' | 'optional' | 'none';
	/** 'email' for sign-up and checkout; 'username' (or 'username webauthn') on sign-in forms. */
	export type EmailFieldAutocomplete = 'email' | 'username' | 'username webauthn' | 'off';

	/** A suggested replacement for one address in the field. */
	export interface EmailSuggestion {
		/** The whole field value with the suggestion applied. */
		value: string;
		/** The corrected address, such as sam@gmail.com. */
		address: string;
		/** The part before the @, exactly as typed. */
		local: string;
		/** The suggested domain, such as gmail.com. */
		domain: string;
	}

	/**
	 * Webmail domains people mistype most. The last four are real domains one edit from a bigger
	 * one, listed so that sam@mail.com is never "corrected" to gmail.com. Pass your own list when
	 * your audience differs.
	 */
	export const COMMON_EMAIL_DOMAINS = [
		'gmail.com',
		'outlook.com',
		'hotmail.com',
		'yahoo.com',
		'icloud.com',
		'live.com',
		'aol.com',
		'msn.com',
		'me.com',
		'proton.me',
		'googlemail.com',
		'ymail.com',
		'mail.com',
		'email.com'
	];

	/** Edit distance counting a swap of two neighbours as one edit, so gmial is one from gmail. */
	function distance(a: string, b: string): number {
		const rows = Array.from({ length: a.length + 1 }, (_, i) =>
			Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))
		);
		for (let i = 1; i <= a.length; i++) {
			for (let j = 1; j <= b.length; j++) {
				const cost = a[i - 1] === b[j - 1] ? 0 : 1;
				rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
				if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
					rows[i][j] = Math.min(rows[i][j], rows[i - 2][j - 2] + 1);
				}
			}
		}
		return rows[a.length][b.length];
	}

	/*
	 * Short domains get one edit and longer ones two, so mail.com is never "corrected" to aol.com.
	 * A domain with no dot is compared with the name alone, so sam@gmail suggests gmail.com.
	 */
	function closestDomain(typed: string, domains: readonly string[]): string | null {
		let best: string | null = null;
		let bestDistance = Infinity;
		for (const domain of domains) {
			const target = typed.includes('.') ? domain : domain.slice(0, domain.indexOf('.'));
			const limit = target.length <= 7 ? 1 : 2;
			/* No pair further apart in length than the limit can be within it, so skip the matrix. */
			if (Math.abs(typed.length - target.length) > limit) continue;
			const d = distance(typed, target);
			if (d <= limit && d < bestDistance) {
				best = domain;
				bestDistance = d;
			}
		}
		return best;
	}

	/**
	 * The first address in `value` whose domain is close to, but not exactly, one of `domains`.
	 * The local part keeps its case; only the domain is compared, in lower case.
	 */
	export function suggestEmail(
		value: string,
		domains: readonly string[],
		multiple = false
	): EmailSuggestion | null {
		const known = domains.map((domain) => domain.toLowerCase());
		const entries = multiple ? value.split(',').map((entry) => entry.trim()) : [value.trim()];
		for (const [index, entry] of entries.entries()) {
			/* Only a plain address gets a suggestion: one @, something before it, no spaces or commas. */
			const at = entry.indexOf('@');
			if (at < 1 || at !== entry.lastIndexOf('@') || /[\s,]/.test(entry)) continue;
			const local = entry.slice(0, at);
			const typed = entry.slice(at + 1).toLowerCase();
			if (!typed || known.includes(typed)) continue;
			const domain = closestDomain(typed, known);
			if (!domain) continue;
			const address = `${local}@${domain}`;
			const next = entries.map((other, i) => (i === index ? address : other)).filter(Boolean);
			return { value: multiple ? next.join(', ') : address, address, local, domain };
		}
		return null;
	}
</script>

<script lang="ts">
	import type { HTMLInputAttributes } from 'svelte/elements';

	type Passthrough = Omit<
		HTMLInputAttributes,
		| 'type'
		| 'value'
		| 'id'
		| 'name'
		| 'size'
		| 'multiple'
		| 'required'
		| 'autocomplete'
		| 'class'
		| 'children'
		| 'aria-invalid'
	>;

	interface Props extends Passthrough {
		/** Visible label, such as 'Email address'. Always shown. */
		label: string;
		/** Bindable value. Trimmed when the field loses focus; its case is never changed. */
		value?: string;
		/** Form field name for native submission. */
		name?: string;
		/** Autofill token: 'email' by default, 'username' on sign-in forms. */
		autocomplete?: EmailFieldAutocomplete;
		/** Accept several comma-separated addresses (the native multiple attribute). */
		multiple?: boolean;
		/** Domains to suggest when one is mistyped. false turns suggestions off. */
		suggestDomains?: readonly string[] | false;
		/** Help text under the label, linked with aria-describedby. */
		hint?: string;
		/** Validation message from the consumer. Sets aria-invalid; the component never validates. */
		error?: string | null;
		/** Native required attribute. Shows the Required marker unless requiredIndicator says otherwise. */
		required?: boolean;
		/** Which marker follows the label. Defaults to 'required' for required fields, otherwise 'none'. */
		requiredIndicator?: EmailFieldRequiredIndicator;
		/** Marker text for required fields, for other languages. */
		requiredLabel?: string;
		/** Marker text for optional fields, for other languages. */
		optionalLabel?: string;
		/** Read before the error by screen readers only, for other languages. */
		errorPrefix?: string;
		/** The suggestion sentence; {email} marks where the suggested address goes. */
		suggestionText?: string;
		/** Accessible name of the suggestion button; must contain {email}. */
		suggestionAction?: string;
		/** sm 32 px, md 36 px, lg 40 px tall, matching action buttons; 44 px on touch screens. */
		size?: EmailFieldSize;
		/** Hold one line under the field for the error or suggestion, so neither moves what follows. */
		reserveFeedback?: boolean;
		/** Input id. Defaults to one generated per instance. */
		id?: string;
		/** Extra classes on the outer wrapper, for placement. */
		class?: string;
	}

	let {
		label,
		value = $bindable(),
		name,
		autocomplete = 'email',
		multiple = false,
		suggestDomains = COMMON_EMAIL_DOMAINS,
		hint,
		error = null,
		required = false,
		requiredIndicator,
		requiredLabel = 'Required',
		optionalLabel = 'Optional',
		errorPrefix = 'Error:',
		suggestionText = 'Did you mean {email}?',
		suggestionAction = 'Use {email} instead',
		size = 'md',
		reserveFeedback = false,
		id,
		class: className,
		...rest
	}: Props = $props();

	const uid = $props.id();
	const inputId = $derived(id ?? `${uid}-input`);
	/* Hint, error and suggestion ids come from the instance, so a supplied input id never collides. */
	const hintId = `${uid}-hint`;
	const errorId = `${uid}-error`;
	const suggestionId = `${uid}-suggestion`;

	let input: HTMLInputElement | undefined = $state();
	/*
	 * The value as it was last checked for a typo: on blur, or on mount for a value that arrived
	 * from the server. Typing makes the value differ from it, which hides the suggestion until the
	 * next blur. It stays null during server rendering, so no button renders before hydration.
	 */
	let checked: string | null = $state(null);

	$effect(() => {
		if (input?.value) checked = tidy(input.value);
	});

	const invalid = $derived(!!error);
	const marker = $derived(requiredIndicator ?? (required ? 'required' : 'none'));
	const suggestion = $derived(
		suggestDomains &&
			!rest.disabled &&
			!rest.readonly &&
			checked !== null &&
			checked === tidy(value ?? '')
			? suggestEmail(checked, suggestDomains, multiple)
			: null
	);
	const [textBefore, textAfter = ''] = $derived(suggestionText.split('{email}'));
	/*
	 * The local part wraps after its dots, and a short segment never splits at its own hyphen,
	 * which would read as part of the address. A very long segment may still break anywhere.
	 */
	const localSegments = $derived(suggestion ? suggestion.local.split('.') : []);
	/* Hint first, then the error, then the suggestion, then anything the consumer attached. */
	const describedBy = $derived(
		[
			...new Set(
				[
					hint && hintId,
					invalid && errorId,
					suggestion && suggestionId,
					...(rest['aria-describedby'] ?? '').split(/\s+/)
				].filter(Boolean)
			)
		].join(' ') || undefined
	);

	/* Trim whitespace (and tidy the commas between several addresses), never the case. */
	function tidy(raw: string) {
		if (!multiple) return raw.trim();
		return raw
			.split(',')
			.map((entry) => entry.trim())
			.filter(Boolean)
			.join(', ');
	}

	/* Typing invalidates the last check, so the suggestion stays hidden until the next blur. */
	function handleInput() {
		checked = null;
	}

	/* Listens on the wrapper, so an onblur you pass reaches the input untouched. */
	function handleFocusOut() {
		if (!input) return;
		const next = tidy(input.value);
		if (next !== value) value = next;
		checked = next;
	}

	/*
	 * Writes the suggestion through the input and fires input and change, so bind:value, form
	 * libraries and native listeners all see it, then returns focus to the field.
	 */
	function applySuggestion() {
		if (!input || !suggestion) return;
		const next = suggestion.value;
		input.value = next;
		input.dispatchEvent(new Event('input', { bubbles: true }));
		input.dispatchEvent(new Event('change', { bubbles: true }));
		checked = tidy(input.value);
		input.focus();
	}
</script>

<div
	class={['email-field @container min-w-0', className]}
	data-size={size}
	data-invalid={invalid ? '' : undefined}
>
	<div class="grid gap-2">
		<div class="min-w-0">
			<label
				for={inputId}
				class={[
					'block leading-5 font-medium text-pretty break-words text-(--_ink)',
					size === 'sm' ? 'text-[13px]' : 'text-sm'
				]}
			>
				{label}{#if marker === 'required'}
					<!-- The native required attribute already says so to assistive technology. The en
					     space is a break point, so a marker that wraps starts on the label's edge. -->
					<span class="text-[13px] font-normal text-(--_muted)" aria-hidden="true"
						>&ensp;{requiredLabel}</span
					>{:else if marker === 'optional'}
					<span class="text-[13px] font-normal text-(--_muted)">&ensp;{optionalLabel}</span>{/if}
			</label>
			{#if hint}
				<p id={hintId} class="mt-1 text-[13px] leading-5 text-pretty break-words text-(--_muted)">
					{hint}
				</p>
			{/if}
		</div>

		<div class="min-w-0">
			<div
				oninput={handleInput}
				onfocusout={handleFocusOut}
				class={[
					'email-field__control flex min-h-(--_h) items-stretch border',
					'border-(--_edge) bg-(--_surface) text-(--_ink)',
					size === 'sm' ? 'rounded-md' : 'rounded-lg'
				]}
			>
				<!-- An address reads left to right in every language, so the input keeps ltr and sits on
				     the start edge of a right-to-left form. -->
				<input
					bind:this={input}
					inputmode="email"
					spellcheck="false"
					autocapitalize="none"
					dir="ltr"
					{...rest}
					bind:value
					type="email"
					{name}
					{autocomplete}
					{multiple}
					{required}
					id={inputId}
					aria-describedby={describedBy}
					aria-invalid={invalid ? 'true' : undefined}
					class={[
						'email-field__input min-w-0 flex-1 bg-transparent px-3 text-(--_ink) outline-none rtl:text-end',
						'placeholder:text-(--_placeholder) disabled:cursor-not-allowed',
						'pointer-coarse:text-base',
						size === 'sm' && 'text-[13px] leading-5',
						size === 'md' && 'text-sm leading-5',
						size === 'lg' && 'text-base leading-6'
					]}
				/>
			</div>

			<!-- Error and suggestion share one region. With reserveFeedback it holds one line for them,
			     or two in a field under 22rem wide, where "Did you mean …?" usually wraps. flow-root keeps
			     the first line's top margin inside the reserved height. -->
			<div
				class={[
					'email-field__feedback flow-root',
					reserveFeedback && 'min-h-6 @max-[22rem]:min-h-11'
				]}
			>
				{#if invalid}
					<p
						id={errorId}
						class="mt-1 flex items-start gap-1 text-[13px] leading-5 text-(--_danger)"
					>
						<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
							<svg class="size-3.5" viewBox="0 0 16 16" fill="none">
								<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.75" />
								<path
									d="M8 4.75v3.5"
									stroke="currentColor"
									stroke-width="1.75"
									stroke-linecap="round"
								/>
								<circle cx="8" cy="11" r="1" fill="currentColor" />
							</svg>
						</span>
						<span class="min-w-0 text-pretty break-words"
							><span class="sr-only">{`${errorPrefix} `}</span>{error}</span
						>
					</p>
				{/if}

				<!-- Always present and polite, so a suggestion that appears on blur is read out without
				     taking focus from wherever the person went next. -->
				<div aria-live="polite" aria-atomic="true">
					{#if suggestion}
						<p
							id={suggestionId}
							class="mt-1 flex items-start gap-1 text-[13px] leading-5 text-(--_muted)"
						>
							<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
								<svg class="size-3.5 rtl:-scale-x-100" viewBox="0 0 16 16" fill="none">
									<path
										d="M3.75 2.75v4.5a2 2 0 0 0 2 2h6.5m-2.5-2.5 2.5 2.5-2.5 2.5"
										stroke="currentColor"
										stroke-width="1.75"
										stroke-linecap="round"
										stroke-linejoin="round"
									/>
								</svg>
							</span>
							<!-- The button holds the address and the closing punctuation, so a wrapped address
							     never strands the "?". The address is isolated so it stays left to right in any
							     sentence; it wraps after the dots of its local part, never inside the domain. -->
							<span class="min-w-0 text-pretty break-words"
								>{textBefore}<button
									type="button"
									class={[
										'email-field__apply group relative inline rounded-sm text-start text-(--_ink)',
										'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent)'
									]}
									aria-label={suggestionAction.replace('{email}', suggestion.address)}
									onclick={applySuggestion}
									><bdi
										class={[
											'underline decoration-(--_rule) underline-offset-[3px]',
											'transition-[text-decoration-color] duration-150 ease-(--_ease) motion-reduce:transition-none',
											'group-hover:decoration-(--_ink)'
										]}
										><span class="break-words"
											>{#each localSegments as segment, i (i)}{#if i > 0}.<wbr />{/if}<span
													class={segment.length <= 24 ? 'whitespace-nowrap' : undefined}
													>{segment}</span
												>{/each}</span
										><span class="whitespace-nowrap"
											>@<span class="font-semibold">{suggestion.domain}</span></span
										></bdi
									><span class="text-(--_muted)">{textAfter}</span></button
								></span
							>
						</p>
					{/if}
				</div>
			</div>
		</div>
	</div>
</div>

<style>
	/* Public tokens: set --email-field-* on the field or any ancestor to retone it. */
	.email-field {
		--_accent: var(--email-field-accent, #1d4ed8);
		--_danger: var(--email-field-danger, #b91c1c);
		--_ink: var(--email-field-ink, #18181b);
		--_muted: var(--email-field-muted, #52525b);
		--_placeholder: var(--email-field-placeholder, #71717a);
		/* 3:1 against white: the edge is the only thing marking an empty field (WCAG 1.4.11). */
		--_hairline: var(--email-field-hairline, rgb(0 0 0 / 0.44));
		--_surface: var(--email-field-surface, #ffffff);

		/* The field's height per size. */
		--_h: 2.25rem;
		--_edge: var(--_hairline);
		--_ring: var(--_accent);
		/* The suggestion's resting underline: the edge colour, softened. */
		--_rule: color-mix(in oklab, var(--_hairline) 70%, transparent);
		--_ease: cubic-bezier(0.2, 0, 0, 1);
	}
	.email-field[data-size='sm'] {
		--_h: 2rem;
	}
	.email-field[data-size='lg'] {
		--_h: 2.5rem;
	}
	@media (pointer: coarse) {
		.email-field[data-size] {
			--_h: 2.75rem;
		}
	}

	/* Invalid: the edge and the focus ring both carry the error colour. */
	.email-field[data-invalid] {
		--_edge: var(--_danger);
		--_ring: var(--_danger);
	}

	/*
	 * The edge, fill and halo change through the rules below, which follow the input inside, so the
	 * transition that animates them lives here with them.
	 */
	.email-field__control {
		transition-property: border-color, box-shadow, background-color;
		transition-duration: 150ms;
		transition-timing-function: var(--_ease);
	}

	/* Hover only where typing is possible: one step darker edge. */
	@media (hover: hover) {
		.email-field__control:hover:not(:has(input:disabled, input[readonly])) {
			--_edge: color-mix(in oklab, var(--_hairline), var(--_ink) 25%);
		}
		.email-field[data-invalid] .email-field__control:hover {
			--_edge: var(--_danger);
		}
	}

	/* Focus: a two-pixel edge in the accent (one border plus one ring), with a soft halo outside. */
	.email-field__control:has(input:focus-visible) {
		border-color: var(--_ring);
		box-shadow:
			0 0 0 1px var(--_ring),
			0 0 0 4px color-mix(in oklab, var(--_ring) 14%, transparent);
	}

	/* Read-only: a raised fill, full-strength text, still focusable and selectable. */
	.email-field__control:has(input[readonly]) {
		background-color: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
	}

	/* Disabled: raised fill, faded, and not interactive. */
	.email-field__control:has(input:disabled) {
		background-color: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
		opacity: 0.55;
		cursor: not-allowed;
	}

	/* Autofill: keep the browser's fill off the input and tint the whole shape instead. */
	.email-field__input:autofill {
		-webkit-text-fill-color: var(--_ink);
		transition: background-color 0s 86400s;
	}
	.email-field__control:has(input:autofill) {
		background-color: color-mix(in oklab, var(--_surface), var(--_accent) 6%);
	}

	/*
	 * On touch, the suggestion's hit area grows to 44 px: up to the field's edge and down into the
	 * gap below, never over the input itself.
	 */
	@media (pointer: coarse) {
		.email-field__apply::after {
			content: '';
			position: absolute;
			inset: -0.25rem -0.25rem -1.25rem;
		}
	}

	/* Forced colours drop shadows and repaint borders, so focus becomes a system-colour outline. */
	@media (forced-colors: active) {
		.email-field__control:has(input:focus-visible) {
			outline: 2px solid Highlight;
			outline-offset: 2px;
		}
	}
</style>
```

## Artifacts

### Blue accent (`blue`)

- Artifact digest: `sha256-c9d2612c4c01c300e1589c872a4e7d6c79731e1ac2ee4b651f7e4dd00902e8f4`
- Entry: `EmailField.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_email_field_01/1.0.0/blue/sha256-c9d2612c4c01c300e1589c872a4e7d6c79731e1ac2ee4b651f7e4dd00902e8f4/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_email_field_01/1.0.0/blue/sha256-c9d2612c4c01c300e1589c872a4e7d6c79731e1ac2ee4b651f7e4dd00902e8f4/bundle.zip (11239 bytes, sha256 `0b5f1adab6a034bb2570e326b0776b00209fda45bc9a83b5e90fe0fda07f9770`)

Files:

- `EmailField.svelte` (entry, 19080 bytes): https://pagesugar.com/artifacts/cmp_email_field_01/1.0.0/blue/sha256-c9d2612c4c01c300e1589c872a4e7d6c79731e1ac2ee4b651f7e4dd00902e8f4/source/EmailField.svelte
