Skip to content
Download ZIP

Blue accent palette · 11.0 KB ZIP File receipt View as Markdown View code

Preview

Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.

Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
cmp_email_field_01 · version 1.0.0 · Blue accent palette
Using the PageSugar MCP server, fetch component cmp_email_field_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.

Not connected yet? Set up the MCP server

Code

Palette
Blue accent
Version
1.0.0
Digest
Full digest
sha256-c9d2612c4c01c300e1589c872a4e7d6c79731e1ac2ee4b651f7e4dd00902e8f4
EmailField.svelte Svelte · 18.6 KB Raw
<!--
	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>

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.

Suggested location
src/lib/components/email-field-01
Required props
label

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'.

Example

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>

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.

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 and content inputs#

On this page
NameTypeRequiredDefaultDescription
labelstringYesNoneVisible label, such as 'Email address'. Always shown.
valuestringNoNoneBindable value. Trimmed when the field loses focus (and the commas tidied with multiple); the case is never changed.
namestringNoNoneForm 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.
multiplebooleanNofalseAccept several comma-separated addresses (the native multiple attribute).
suggestDomainsreadonly string[] | falseNoCOMMON_EMAIL_DOMAINSDomains 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.
hintstringNoNoneHelp text under the label, linked first in aria-describedby.
errorstring | nullNonullValidation 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.
requiredbooleanNofalseNative required attribute. Shows the Required marker unless requiredIndicator says otherwise.
requiredIndicator'required' | 'optional' | 'none'NoNoneWhich marker follows the label. Defaults to 'required' for required fields and 'none' otherwise.
requiredLabelstringNo'Required'Marker text for required fields.
optionalLabelstringNo'Optional'Marker text for optional fields.
errorPrefixstringNo'Error:'Visually hidden text read before the error.
suggestionTextstringNo'Did you mean {email}?'The suggestion sentence. {email} marks where the suggested address, the button, goes.
suggestionActionstringNo'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.
reserveFeedbackbooleanNofalseHold 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.
idstringNoNoneInput id. Defaults to one generated per instance. The hint, error and suggestion always take generated ids.
classstringNoNoneExtra classes on the outer wrapper, for placement.

Customization#

On this page

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.

Public CSS variables

VariableToken
--email-field-accentaccent
--email-field-dangerdanger
--email-field-inkink
--email-field-mutedmuted
--email-field-placeholderplaceholder
--email-field-hairlinehairline
--email-field-surfacesurface

Accessibility#

On this page
  • 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.

Release details#

On this page
Integration
  • Local interaction
  • Works without client-side JavaScript
  • Server-side rendering supported
Dependencies
No additional runtime packages beyond Svelte and Tailwind CSS
License

MIT. Default license approval is pending; see the license status before adopting the source.

Version history
  • 1.0.0 (Published) Current release · 22 October 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.