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.
cmp_email_field_01 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.
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
<!--
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"
> {requiredLabel}</span
>{:else if marker === 'optional'}
<span class="text-[13px] font-normal text-(--_muted)"> {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>
Usage#
On this pageGive 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=emailrule, 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.
reserveFeedbackholds 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.comallow one edit, longer ones two, to keep real domains likemail.comfrom being 'corrected'.
Example
<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.
<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:
<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:
.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| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | Visible label, such as 'Email address'. Always shown. |
value | string | No | None | Bindable value. Trimmed when the field loses focus (and the commas tidied with multiple); the case is never changed. |
name | string | No | None | 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 | None | 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 | None | 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 | None | Input id. Defaults to one generated per instance. The hint, error and suggestion always take generated ids. |
class | string | No | None | Extra classes on the outer wrapper, for placement. |
Customization#
On this pageChange 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-accentcolours 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-dangercolours the error edge, the error focus ring, the icon and the message. The default isred-700(6.5:1 on white). - Neutrals:
--email-field-inkis the label, the value and the suggested address;--email-field-mutedthe hint, marker and suggestion sentence;--email-field-placeholderthe placeholder;--email-field-hairlinethe resting edge (keep it at 3:1, since it is the only thing marking an empty field);--email-field-surfacethe fill. - Worked retone for a
#09090bband:--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
suggestionTextandsuggestionActionwith the page, keeping{email}in both. TranslaterequiredLabel,optionalLabelanderrorPrefixtoo. - 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
reserveFeedbackin tight forms where a suggestion appearing should not push the next field.
Public CSS variables
| Variable | Token |
|---|---|
--email-field-accent | accent |
--email-field-danger | danger |
--email-field-ink | ink |
--email-field-muted | muted |
--email-field-placeholder | placeholder |
--email-field-hairline | hairline |
--email-field-surface | surface |
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-describedbylists the hint, the error, then the suggestion, then anyaria-describedbyyou pass, each only when present.aria-invalidis 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.cominstead' (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.