# Masked field

> A text field for one fixed format, such as a sort code, a US phone number or a product key. Adds separators as you type, keeps the caret in place, re-masks pasted text and submits the raw value.

- ID: `cmp_masked_field_01`
- Slug: `masked-field-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-22T05:10:29Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `text-inputs`
- Detail page: https://pagesugar.com/components/masked-field-01?variant=neutral
- Preview: https://pagesugar.com/preview/masked-field-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-5b1f5a396604615999a591723bcd6e78092ea403bf9287fd3260036dccfb762a` |
| `blue` | Blue accent | no | `sha256-abc3167c9fc82e9a7159a17e9088299df086a8dee52adf2f9857acab85b3bfd5` |

## 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: yes
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/masked-field-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Give it a label, a mask such as '99-99-99' and a hint that states the format in words, and it formats what is typed, keeps the raw value in value and submits it under name. It checks only that characters fit the mask's slots: it does not validate meaning (whether a sort code exists), does not handle variable-length or international formats, and is not for card numbers, which belong in your payment provider's hosted fields.

Required props: `label`, `mask`, `hint`

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

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

	function check(event: SubmitEvent) {
		error = sortCode.length === 6 ? null : 'Enter all 6 digits of your sort code.';
		if (error) event.preventDefault();
	}
</script>

<form method="POST" class="max-w-sm" onsubmit={check}>
	<MaskedField
		label="Sort code"
		mask="99-99-99"
		name="sort-code"
		required
		hint="6 digits, like 30-94-12."
		bind:value={sortCode}
		{error}
	/>
</form>
```

Limitations:

- Fixed formats only. Do not mask names, email addresses, international phone numbers or anything whose length varies; use a plain text field and normalise on the server.
- Not for payment card numbers. Use your payment provider's hosted fields so raw card data never touches your page.
- Letter slots take A to Z only. Digits typed in Arabic-Indic, Persian, Devanagari, Bengali or full-width forms are stored as 0 to 9.
- Because the field rewrites its own value, the browser's undo history does not survive an edit; Ctrl+Z may not step back through typed characters.
- Before hydration the input is a plain text field that submits what was typed, separators included, under name; once the script runs it reads that text back through the mask. Normalise on the server either way.
- In a mask that mixes letters and digits, a deletion or insertion that would push later characters out of slots they fit is refused with a notice saying to select the character and type over it.

## Usage guide

### Masked field

A text field for one fixed format. It adds the separators as the person types, keeps the caret
where they are editing, and keeps the raw value apart from what is shown.

```svelte
<MaskedField
	label="Sort code"
	mask="99-99-99"
	name="sort-code"
	hint="6 digits, like 30-94-12."
	bind:value={sortCode}
	{error}
/>
```

#### When to mask, and when not to

Mask a format that is fixed and well known to the person typing it: a UK sort code, a US
national phone number, a product key printed on a card. Do not mask:

- names, which have apostrophes, hyphens, spaces and any length;
- email addresses;
- international phone numbers, which run from 7 to 15 digits in many groupings;
- payment card numbers, which belong in your payment provider's hosted fields;
- anything whose length varies. Use a plain text field and normalise on the server.

#### The mask

| Character | Slot                            |
| --------- | ------------------------------- |
| `9`       | a digit                         |
| `A`       | a letter, A to Z                |
| `*`       | a letter or a digit             |
| any other | a literal, added for the person |

Literals appear only once the slot after them has a character, so `1234` shows as `12-34` and an
empty field shows nothing. There is deliberately no `__-__-__` placeholder: screen readers read
it as underscores and in forced-colours mode it looks already filled. Put the format in `hint`
instead, in words and with an example.

#### Values

- `value` is the raw value, `309412`. Bind it; it is also what submits under `name`.
- `formattedValue` is what is shown, `30-94-12`. Bind it to read it; pass `formattedName` to
  submit it too.
- An initial `value` may be raw or formatted. It is read back through the mask either way.
- `onComplete(raw)` runs whenever an edit fills the last slot, for example to move on or to
  look up a bank name.

Before the page hydrates the field is a plain text input carrying `name`, so a form posted
without script still sends what was typed, separators and all. Normalise on the server.

#### Editing behaviour

- Backspace beside a separator deletes the character before the separator; delete removes the
  one after it. The caret never sticks on a literal.
- Typing or deleting in the middle keeps the caret after the same character.
- Pasted text is stripped of spaces, dashes, dots, brackets and the mask's own literals, then
  re-masked: `123456`, `12 34 56` and `12-34-56` all give the raw value `123456`.
- A character that fits no slot is refused, and a muted notice under the field says what does
  fit. A second refusal in a row is also announced through a polite status. Translate the
  sentences with `rejectMessages`.

#### Validation is yours

The field only checks that each character fits its slot. Whether a sort code exists or a phone
number is in service is your server's question. Set `error` when your check fails and clear it
(`null`) when it passes.

#### Retoning

On a `#09090b` band:

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

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | yes |  | Visible label. |
| `mask` | `string` | yes |  | The format. 9 is a digit, A a letter (A to Z), \* a letter or digit, and any other character a literal that is added for you, for example '99-99-99' or '(999) 999-9999'. |
| `value` | `string` | no |  | Bindable raw value without literals, for example '309412'. A formatted value such as '30-94-12' is read too. |
| `formattedValue` | `string` | no |  | Bindable and read-only: the value as displayed, for example '30-94-12'. |
| `name` | `string` | no |  | Form name. The raw value submits under it. |
| `formattedName` | `string` | no |  | Form name for a hidden copy of the formatted value, when your server wants both. |
| `inputmode` | 'numeric' \| 'text' \| 'tel' | no |  | Keyboard hint. Defaults to 'numeric' when every slot is a digit, otherwise 'text'. Use 'tel' for phone numbers. |
| `autocomplete` | `HTMLInputAttributes['autocomplete']` | no |  | Autofill token where one exists, for example 'tel-national'. |
| `hint` | `string` | yes |  | The format in words and an example, for example '6 digits, like 30-94-12.' Shown above the input and first in aria-describedby. |
| `error` | string \| null | no | `null` | Validation message from your code. Sets aria-invalid and shows the message with an icon. The component never decides the value is wrong. |
| `onComplete` | `(raw: string) => void` | no |  | Called with the raw value whenever an edit fills the last slot. |
| `monospace` | `boolean` | no | `false` | Sets the value in your monospace font, for codes that mix letters and digits. |
| `width` | 'format' \| 'full' | no | `'format'` | 'format' sizes the box to the mask's length, capped at its container; 'full' fills the container. |
| `letterCase` | 'none' \| 'upper' \| 'lower' | no | `'none'` | Changes the case of typed and pasted letters, for codes printed in capitals. |
| `rejectMessages` | Partial\<Record\<'digit' \| 'letter' \| 'alphanumeric' \| 'replace', string\>\> | no |  | What the notice says, for other languages: after a refused character, by the kind of slot it was aimed at, and 'replace' when an edit in a mixed mask would push later characters out of slots they fit. Defaults to English sentences such as 'Only numbers can be typed here.' |
| `reserveFeedback` | `boolean` | no | `true` | Holds one line under the field for the refusal notice and the error, so neither moves anything below. Stack fields with no extra gap while it is on; a longer error still grows the field. |
| `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. |
| `id` | `string` | no |  | Input id. Defaults to one generated per instance. |
| `class` | `string` | no |  | Extra classes on the outer wrapper, for placement. |

## Customization

Change the copy, mask and messages through props and retone the field through six --masked-field-\* CSS variables. Hover and the disabled fill are mixed from those, so they follow any retone.

- Mask: write the format with 9, A and \*, and every other character as it should appear. Keep it to one fixed length; for anything that varies, use a plain text field.
- Hint: say the format in words and give an example in the same punctuation as the mask ('10 digits, like (555) 010-0142'). It is the only place the format is spelt out; there is no placeholder mask.
- Accent: --masked-field-accent colours the focus edge and halo and tints the autofilled fill. Keep it at 3:1 against the page.
- Danger: --masked-field-danger colours the error edge, ring, icon and message. The default is red-700.
- Neutrals: --masked-field-ink is the label and value, --masked-field-muted the hint, marker and refusal notice, --masked-field-hairline the resting edge (keep it at 3:1, it is all that marks an empty field) and --masked-field-surface the fill.
- Worked retone for a #09090b band: --masked-field-accent: #fafafa; --masked-field-ink: #fafafa; --masked-field-muted: #a1a1aa; --masked-field-hairline: rgb(255 255 255 / 0.35); --masked-field-surface: #18181b; --masked-field-danger: #f87171. Add color-scheme: dark on the band.
- Width: the box fits the format by default, so a sort code is short and a product key long. Pass width='full' when it sits in a row of full-width fields, or class for placement.
- Spacing: one line under each field is held for its notice and error (reserveFeedback), so stack fields with no extra gap and the label below sits 24 px from the box. Turn it off for a lone field with nothing beneath it.
- Codes: set monospace and letterCase='upper' for product keys and voucher codes, so every character takes the same width and reads as printed.
- Other languages: translate rejectMessages, requiredLabel, optionalLabel and errorPrefix with the page. The value always reads left to right.

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

## Accessibility

- A native \<label for\> names the input. The hint is always present and linked first in aria-describedby, so the format is announced in words; there is no placeholder mask, which screen readers read as underscores.
- Separators are part of the displayed value, so screen readers read 30-94-12 as the person sees it. maxlength matches the formatted length.
- A refused character never vanishes silently: a muted notice saying which characters fit appears under the field at once, and a polite status announces it from the second refusal in a row. An edit refused because it would drop characters in a mixed mask is announced at once. Notices clear on the next accepted edit or on blur, and one line is reserved for them by default so the form does not move.
- The error has an icon, text and a visually hidden errorPrefix, and sets aria-invalid. It is not a live region; move focus or announce a summary when a submit fails.
- Focus draws a two-pixel edge in the accent (danger while invalid) with a soft halo, and a Highlight outline in forced-colours mode.
- Inputs use 16 px text and grow to 44 px on touch screens. inputmode defaults to numeric for digit-only masks; pass 'tel' for phone numbers.
- Mask only formats that never vary. Names, email addresses and international phone numbers must not be masked.

Known limitations:

- The browser's undo history does not survive an edit, because the field writes its own value.

## License

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

## Source

- Palette: Neutral (`neutral`)
- Entry: `MaskedField.svelte`
- Suggested directory: `src/lib/components/masked-field-01`
- Files: 2
- Artifact digest: `sha256-5b1f5a396604615999a591723bcd6e78092ea403bf9287fd3260036dccfb762a`

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

#### `MaskedField.svelte`

Role: entry · 21401 bytes · SHA-256 `da06d505b30bf6fb4045f62a8a914b2c2985eb6fb65d0ae5eb562e50c5c5a4ae`

```svelte
<!--
	A text field for one fixed format: a sort code, a national phone number, a product key. It adds
	the format's separators as you type, keeps the caret where you are editing, accepts pasted text
	with or without separators, and says what it refused instead of dropping keys silently. The box
	is cut to the format's width in tabular figures. The raw value (no separators) is bindable and
	is what submits under `name`; the formatted string is bindable and can submit too.
-->
<script lang="ts" module>
	export type MaskedFieldInputMode = 'numeric' | 'text' | 'tel';
	export type MaskedFieldWidth = 'format' | 'full';
	export type MaskedFieldLetterCase = 'none' | 'upper' | 'lower';
	export type MaskedFieldRequiredIndicator = 'required' | 'optional' | 'none';
	/** Notice text per refused slot kind, plus 'replace' for an edit that would drop characters. */
	export type MaskedFieldRejectMessages = Partial<
		Record<'digit' | 'letter' | 'alphanumeric' | 'replace', string>
	>;
</script>

<script lang="ts">
	import { flushSync, onMount } from 'svelte';
	import type { HTMLInputAttributes } from 'svelte/elements';
	import {
		caretPosition,
		compileMask,
		deleteText,
		format,
		insertText,
		normalise,
		parse,
		slotsBefore,
		type EditResult,
		type SlotKind
	} from './mask';

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

	interface Props extends Passthrough {
		/** Visible label. */
		label: string;
		/** 9 a digit, A a letter, * a letter or digit, anything else literal: '99-99-99'. */
		mask: string;
		/** Bindable raw value without literals, such as '309412'. A formatted value is read too. */
		value?: string;
		/** Bindable, read-only: the value as displayed, such as '30-94-12'. */
		formattedValue?: string;
		/** Form name for the raw value. */
		name?: string;
		/** Form name for a hidden copy of the formatted value. */
		formattedName?: string;
		/** Keyboard hint. Defaults to 'numeric' for all-digit masks, otherwise 'text'. */
		inputmode?: MaskedFieldInputMode;
		/** Autofill token where one exists, such as 'tel-national'. */
		autocomplete?: HTMLInputAttributes['autocomplete'];
		/** The format in words and an example: 'Must be 6 digits, like 30-94-12'. */
		hint: string;
		/** Validation message from the consumer. The component checks the format, not meaning. */
		error?: string | null;
		/** Called with the raw value when an edit fills the last slot. */
		onComplete?: (raw: string) => void;
		/** Sets the value in the consumer's monospace font, for codes mixing letters and digits. */
		monospace?: boolean;
		/** 'format' sizes the box to the mask; 'full' fills the container. */
		width?: MaskedFieldWidth;
		/** Changes the case of typed letters, for codes printed in capitals. */
		letterCase?: MaskedFieldLetterCase;
		/** What the notice says after a refused character, per slot kind, for other languages. */
		rejectMessages?: MaskedFieldRejectMessages;
		/** Hold one line under the field for the notice and error, so they move nothing below. */
		reserveFeedback?: boolean;
		/** 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?: MaskedFieldRequiredIndicator;
		/** Marker text for required fields. */
		requiredLabel?: string;
		/** Marker text for optional fields. */
		optionalLabel?: string;
		/** Read before the error by screen readers only. */
		errorPrefix?: string;
		/** Input id. Defaults to one generated per instance. */
		id?: string;
		/** Extra classes on the outer wrapper, for placement. */
		class?: string;
	}

	let {
		label,
		mask,
		value = $bindable(),
		// An output the parent binds; the effect below writes it, nothing here reads it.
		// eslint-disable-next-line no-useless-assignment
		formattedValue = $bindable(),
		name,
		formattedName,
		inputmode,
		autocomplete,
		hint,
		error = null,
		onComplete,
		monospace = false,
		width = 'format',
		letterCase = 'none',
		rejectMessages,
		reserveFeedback = true,
		required = false,
		requiredIndicator,
		requiredLabel = 'Required',
		optionalLabel = 'Optional',
		errorPrefix = 'Error:',
		id,
		class: className,
		...rest
	}: Props = $props();

	const uid = $props.id();
	const inputId = $derived(id ?? `${uid}-input`);
	const hintId = `${uid}-hint`;
	const errorId = `${uid}-error`;
	const noticeId = `${uid}-notice`;

	const compiled = $derived(compileMask(mask));
	const recase = (text: string) =>
		letterCase === 'upper'
			? text.toUpperCase()
			: letterCase === 'lower'
				? text.toLowerCase()
				: text;
	const raw = $derived(normalise(compiled, recase(value ?? '')).raw);
	const formatted = $derived(format(compiled, raw));
	const complete = $derived(raw.length === compiled.slots.length);

	/*
	 * formattedValue is an output the parent binds to read what is shown. A value passed in
	 * formatted, or in the wrong case, is written back raw, so the binding holds what submits.
	 */
	$effect.pre(() => {
		formattedValue = formatted;
		if (value !== undefined && value !== raw) value = raw;
	});

	const keyboard = $derived(
		inputmode ?? (compiled.slots.every((s) => s === 'digit') ? 'numeric' : 'text')
	);

	/*
	 * The box's width in ch: one per character, and more for letter slots in a proportional face,
	 * where capitals run wider than tabular figures. Monospace is exact.
	 */
	const chars = $derived(
		compiled.tokens.reduce(
			(sum, t) => sum + ('slot' in t && t.slot !== 'digit' && !monospace ? 1.4 : 1),
			0
		)
	);

	const invalid = $derived(!!error);
	const marker = $derived(requiredIndicator ?? (required ? 'required' : 'none'));
	const messages = $derived({
		digit: 'Only numbers can be typed here.',
		letter: 'Only letters can be typed here.',
		alphanumeric: 'Only letters and numbers can be typed here.',
		replace: 'To change a character, select it and type over it.',
		...rejectMessages
	});

	/* A refused character shows the notice at once; it is announced from the second in a row. */
	let streak = $state(0);
	let noticeKind = $state<SlotKind | 'replace' | null>(null);
	let announcement = $state('');
	let flip = false;

	const describedBy = $derived(
		[
			...new Set(
				[
					hintId,
					invalid && errorId,
					noticeKind && noticeId,
					...(rest['aria-describedby'] ?? '').split(/\s+/)
				].filter(Boolean)
			)
		].join(' ')
	);

	/*
	 * Before hydration the visible input carries the name, so a form posted without script still
	 * sends what was typed. Once masking runs, the hidden raw input takes the name over.
	 */
	let hydrated = $state(false);
	let input: HTMLInputElement | undefined = $state();
	let dispatching = false;
	let before: { text: string; start: number; end: number } | null = null;

	onMount(() => {
		/* Read anything typed before the script arrived, before the hidden input takes the name. */
		if (input && input.value !== formatted) reread();
		hydrated = true;

		/* A native reset puts the input back to its default without an input event; follow it. */
		const form = input?.form;
		let timer: ReturnType<typeof setTimeout> | undefined;
		const onReset = () => {
			clearTimeout(timer);
			timer = setTimeout(() => {
				clearNotice();
				reread();
			});
		};
		form?.addEventListener('reset', onReset);
		return () => {
			clearTimeout(timer);
			form?.removeEventListener('reset', onReset);
		};
	});

	/*
	 * In a mask that mixes letters and digits, a deletion or insertion in the middle can leave
	 * later characters in slots they no longer fit. Rather than drop them, refuse the edit and
	 * say how to change a character instead; this is announced at once, as it is never a stray key.
	 */
	function refuse() {
		noticeKind = 'replace';
		flip = !flip;
		announcement = messages.replace + (flip ? '\u00a0' : '');
	}

	function note(result: EditResult, changed: boolean) {
		if (result.rejected && result.rejectedKind) {
			streak += result.rejected;
			noticeKind = result.rejectedKind;
			if (streak >= 2) {
				/* A trailing no-break space makes a repeated sentence count as new to screen readers. */
				flip = !flip;
				announcement = messages[result.rejectedKind] + (flip ? ' ' : '');
			}
		} else if (changed) {
			clearNotice();
		}
	}

	function clearNotice() {
		streak = 0;
		noticeKind = null;
		announcement = '';
	}

	function apply(result: EditResult, dispatch: boolean) {
		if (!input) return;
		const before = raw;
		/* Refit from the first slot, so the display, bindings and submitted value always agree. */
		const next = normalise(compiled, result.raw).raw;
		const changed = next !== before;
		note(result, changed);
		value = next;
		flushSync();
		const text = format(compiled, next);
		if (input.value !== text) input.value = text;
		/* Only move a caret the person can see; a reset or hydration leaves selection alone. */
		if (document.activeElement === input) {
			const position = caretPosition(compiled, next, result.caret);
			input.setSelectionRange(position, position);
		}
		if (changed && dispatch) {
			/* The edit replaced the browser's own, so tell form listeners the field changed. */
			dispatching = true;
			input.dispatchEvent(new Event('input', { bubbles: true }));
			dispatching = false;
		}
		if (changed && next.length === compiled.slots.length) onComplete?.(next);
	}

	/*
	 * Applies a typed or pasted edit, unless it would drop characters already there, or it is a
	 * replacement where every character was refused: then the value and the selection stay as they
	 * were and only the notice answers, so a wrong key never clears what was selected.
	 */
	function settle(result: EditResult, start: number, end: number) {
		if (result.dropped) return refuse();
		const accepted = result.caret - slotsBefore(compiled, start);
		if (start !== end && accepted === 0 && result.rejected) return note(result, false);
		apply(result, true);
	}

	function selection() {
		const start = input?.selectionStart ?? formatted.length;
		return [start, input?.selectionEnd ?? start] as const;
	}

	/* Script can still dispatch edits at a field the browser would not let anyone edit. */
	function editable() {
		return !!input && !input.readOnly && !input.matches(':disabled');
	}

	/*
	 * Typing and single-character deletes are handled here, before the browser edits the value.
	 * An edit the browser will make regardless (not cancelable) is left to it and reread after.
	 */
	function handleBeforeInput(event: InputEvent & { currentTarget: HTMLInputElement }) {
		rest.onbeforeinput?.(event);
		if (event.defaultPrevented || event.isComposing || !editable()) return;
		const [start, end] = selection();
		/* What the field held before, in case the browser makes this edit itself. */
		before = { text: input?.value ?? '', start, end };
		if (!event.cancelable) return;
		let result: EditResult;
		if (event.inputType === 'insertText' && event.data) {
			result = insertText(
				compiled,
				raw,
				slotsBefore(compiled, start),
				slotsBefore(compiled, end),
				recase(event.data)
			);
		} else if (
			event.inputType === 'deleteContentBackward' ||
			event.inputType === 'deleteContentForward'
		) {
			const direction = event.inputType === 'deleteContentBackward' ? 'backward' : 'forward';
			result = deleteText(compiled, raw, start, end, direction);
		} else {
			return;
		}
		event.preventDefault();
		before = null;
		settle(result, start, end);
	}

	/* Paste is read from the clipboard event: beforeinput carries no text for it in every browser. */
	function handlePaste(event: ClipboardEvent & { currentTarget: HTMLInputElement }) {
		rest.onpaste?.(event);
		const text = event.clipboardData?.getData('text/plain');
		if (event.defaultPrevented || text == null || !editable()) return;
		event.preventDefault();
		const [start, end] = selection();
		const result = insertText(
			compiled,
			raw,
			slotsBefore(compiled, start),
			slotsBefore(compiled, end),
			recase(text)
		);
		settle(result, start, end);
	}

	/*
	 * Everything else the browser did itself (autofill, cut, drop, undo, autocorrect, a finished
	 * composition): read the whole value back and keep the caret after the same slot character.
	 */
	function reread() {
		if (!input) return;
		const value = input.value;
		const caretAt = input.selectionStart ?? value.length;
		const all = parse(compiled, recase(value));
		/* Recase the prefix on its own: a letter can change length when its case changes. */
		const caret = parse(compiled, recase(value.slice(0, caretAt))).raw.length;
		apply({ ...all, caret }, false);
	}

	/*
	 * A deletion the browser made itself (a word delete, a cut, one it would not let us cancel):
	 * find what it removed from the value it started with and replay that through the same
	 * guarded edit, so a separator-only delete still removes a character and an edit that would
	 * push later characters out of their slots is put back and refused.
	 */
	function browserDelete(inputType: string) {
		const from = before;
		before = null;
		if (!input || !from) return reread();
		const old = from.text;
		const now = input.value;
		let head = 0;
		while (head < old.length && head < now.length && old[head] === now[head]) head++;
		let tail = 0;
		while (
			tail < old.length - head &&
			tail < now.length - head &&
			old[old.length - 1 - tail] === now[now.length - 1 - tail]
		)
			tail++;
		if (head + tail >= old.length) return reread();
		const direction = inputType.includes('Forward') ? 'forward' : 'backward';
		const result = deleteText(compiled, raw, head, old.length - tail, direction);
		if (result.dropped) {
			input.value = old;
			input.setSelectionRange(from.start, from.end);
			refuse();
			return;
		}
		apply(result, true);
	}

	function handleInput(event: Event & { currentTarget: HTMLInputElement }) {
		rest.oninput?.(event);
		const edit = event as unknown as InputEvent;
		if (dispatching || edit.isComposing) return;
		if (edit.inputType?.startsWith('delete')) browserDelete(edit.inputType);
		else reread();
	}

	function handleCompositionEnd(event: CompositionEvent & { currentTarget: HTMLInputElement }) {
		rest.oncompositionend?.(event);
		reread();
	}

	function handleBlur(event: FocusEvent & { currentTarget: HTMLInputElement }) {
		rest.onblur?.(event);
		clearNotice();
	}
</script>

<div
	class={['masked-field min-w-0', className]}
	data-invalid={invalid ? '' : undefined}
	data-complete={complete ? '' : undefined}
>
	<label
		for={inputId}
		class="block text-sm leading-5 font-medium text-pretty break-words text-(--_ink)"
	>
		{label}{#if marker === 'required'}
			<!-- The native required attribute already says so to assistive technology. -->
			<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>
	<p id={hintId} class="mt-1 text-[13px] leading-5 text-pretty break-words text-(--_muted)">
		{hint}
	</p>

	<div
		class={[
			'masked-field__control mt-2 flex h-(--_h) max-w-full items-stretch rounded-lg border',
			'border-(--_edge) bg-(--_surface) text-sm text-(--_ink) pointer-coarse:text-base',
			width === 'format' ? 'w-(--_fit)' : 'w-full'
		]}
		style:--_chars={chars}
	>
		<input
			bind:this={input}
			{...rest}
			type="text"
			value={formatted}
			name={hydrated ? undefined : name}
			{autocomplete}
			{required}
			id={inputId}
			inputmode={keyboard}
			maxlength={compiled.length}
			autocapitalize={letterCase === 'upper' ? 'characters' : rest.autocapitalize}
			spellcheck={false}
			aria-describedby={describedBy}
			aria-invalid={invalid ? 'true' : undefined}
			class={[
				'masked-field__input -my-px w-full min-w-0 bg-transparent px-3 leading-5 tabular-nums outline-none',
				'disabled:cursor-not-allowed',
				monospace && 'font-mono'
			]}
			onbeforeinput={handleBeforeInput}
			onpaste={handlePaste}
			oninput={handleInput}
			oncompositionend={handleCompositionEnd}
			onblur={handleBlur}
		/>
	</div>

	{#if hydrated && name}
		<input type="hidden" {name} value={raw} form={rest.form} disabled={rest.disabled} />
	{/if}
	{#if formattedName}
		<input
			type="hidden"
			name={formattedName}
			value={formatted}
			form={rest.form}
			disabled={rest.disabled}
		/>
	{/if}

	<!-- One line is held for feedback by default, so a refusal or an error moves nothing below. -->
	{#if invalid || noticeKind || reserveFeedback}
		<!-- A refusal during an error shows under it: the one state that adds a line. -->
		<div class={['mt-1 grid gap-1', reserveFeedback && 'min-h-5']}>
			{#if invalid}
				<p id={errorId} class="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}
			{#if noticeKind}
				<p
					id={noticeId}
					class="flex items-start gap-1 text-[13px] leading-5 text-(--_muted)"
					aria-hidden="true"
				>
					<span class="grid h-lh shrink-0 place-items-center">
						<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 7.25v4"
								stroke="currentColor"
								stroke-width="1.75"
								stroke-linecap="round"
							/>
							<circle cx="8" cy="4.75" r="1" fill="currentColor" />
						</svg>
					</span>
					<span class="min-w-0 text-pretty break-words">{messages[noticeKind]}</span>
				</p>
			{/if}
		</div>
	{/if}
	<!-- Always present, so the first announcement is not lost to a region that just appeared. -->
	<span class="sr-only" role="status">{announcement}</span>
</div>

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

		--_edge: var(--_hairline);
		--_ring: var(--_accent);
	}

	/*
	 * The box is cut to the format: its characters in ch (set from the mask on the control), plus
	 * the inline padding, the border and a caret's width. ch is measured in the control's own font
	 * size, which the input inherits.
	 */
	.masked-field__control {
		--_h: 2.25rem;
		--_fit: calc(var(--_chars) * 1ch + 1.5rem + 4px);
		transition-property: border-color, box-shadow, background-color;
		transition-duration: 150ms;
		transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
	}

	@media (pointer: coarse) {
		.masked-field__control {
			--_h: 2.75rem;
		}
	}

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

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

	/* Focus: a two-pixel edge in the accent (one border plus one ring), with a soft halo outside. */
	.masked-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);
	}

	.masked-field__control:has(input[readonly]) {
		background-color: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
	}
	.masked-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. */
	.masked-field__input:autofill {
		-webkit-text-fill-color: var(--_ink);
		transition: background-color 0s 86400s;
	}
	.masked-field__control:has(input:autofill) {
		background-color: color-mix(in oklab, var(--_surface), var(--_accent) 6%);
	}

	/*
	 * A format reads left to right in every language (a phone number, a sort code), so the value
	 * keeps that direction; on a right-to-left page it still sits against the field's start edge.
	 */
	.masked-field__input {
		direction: ltr;
	}
	.masked-field:dir(rtl) .masked-field__input {
		text-align: right;
	}

	@media (forced-colors: active) {
		.masked-field__control:has(input:focus-visible) {
			outline: 2px solid Highlight;
			outline-offset: 2px;
		}
	}
</style>
```

#### `mask.ts`

Role: helper · 8385 bytes · SHA-256 `ab18d6c9106be047128d9d5553eec9d8f764e280b857e2aeb92f0f6d56f78db2`

```ts
/**
 * The mask grammar and the edits MaskedField makes with it. Pure functions, no DOM, so the
 * behaviour can be tested and reused on a server.
 *
 * A mask is a string where `9` is a digit slot, `A` a letter slot (A to Z), `*` a letter or digit
 * slot, and every other character a literal: `99-99-99`, `(999) 999-9999`, `AAAA-9999-****`.
 * The raw value holds only what was typed into slots; literals are added by `format`.
 */

export type SlotKind = 'digit' | 'letter' | 'alphanumeric';
export type MaskToken = { slot: SlotKind } | { literal: string };

export interface CompiledMask {
	/** The mask string it was compiled from. */
	source: string;
	tokens: MaskToken[];
	/** The kind of each slot, in order. */
	slots: SlotKind[];
	/** Formatted length when complete, for maxlength. */
	length: number;
}

export interface FitResult {
	/** The characters that landed in slots, normalised. */
	raw: string;
	/** Characters that fit no slot and are not a separator. */
	rejected: number;
	/** The slot the first rejected character was aimed at. */
	rejectedKind?: SlotKind;
}

export interface EditResult extends FitResult {
	/** Raw index the caret sits before after the edit. */
	caret: number;
	/** Characters already in the field that no longer fit a slot after the edit and were lost. */
	dropped?: number;
}

const SLOT_CHARS: Record<string, SlotKind> = { '9': 'digit', A: 'letter', '*': 'alphanumeric' };

/* Typed separators are skipped quietly rather than reported, so pasting 12 34 56 or 12.34.56 works. */
const SEPARATOR = /[\s\-‐‑‒–—_./\\()[\]+#:,]/u;

export function compileMask(source: string): CompiledMask {
	const tokens: MaskToken[] = [...source].map((ch) =>
		SLOT_CHARS[ch] ? { slot: SLOT_CHARS[ch] } : { literal: ch }
	);
	const slots = tokens.flatMap((t) => ('slot' in t ? [t.slot] : []));
	return { source, tokens, slots, length: tokens.length };
}

/**
 * The character as it is stored if it fits a slot of this kind, otherwise null. Digits from other
 * scripts (Arabic-Indic, Persian, full-width) are stored as 0 to 9.
 */
export function acceptChar(kind: SlotKind, ch: string): string | null {
	const digit = toAsciiDigit(ch);
	const letter = /^[A-Za-z]$/.test(ch);
	if (kind === 'digit') return digit;
	if (kind === 'letter') return letter ? ch : null;
	return digit ?? (letter ? ch : null);
}

function toAsciiDigit(ch: string): string | null {
	if (/^[0-9]$/.test(ch)) return ch;
	if (!/^\p{Nd}$/u.test(ch)) return null;
	const code = ch.codePointAt(0)!;
	/* The zero of each script people commonly type digits in: Arabic-Indic, Persian, Devanagari,
	   Bengali and full-width. Each block runs 0 to 9 from there. */
	for (const zero of [0x0660, 0x06f0, 0x0966, 0x09e6, 0xff10]) {
		if (code >= zero && code <= zero + 9) return String(code - zero);
	}
	return null;
}

function isSeparator(mask: CompiledMask, ch: string) {
	return SEPARATOR.test(ch) || mask.tokens.some((t) => 'literal' in t && t.literal === ch);
}

/**
 * The formatted display for a raw value. Literals appear only once the slot after them has a
 * character, so `1234` shows as `12-34`, never `12-34-`, and an empty value shows nothing.
 * Literals after the last slot appear once every slot is filled.
 */
export function format(mask: CompiledMask, raw: string): string {
	let out = '';
	let i = 0;
	for (const token of mask.tokens) {
		if ('slot' in token) {
			if (i >= raw.length) break;
			out += raw[i++];
		} else if (i < raw.length || i === mask.slots.length) {
			out += token.literal;
		} else break;
	}
	return out;
}

/**
 * Fits characters into slots starting at slot `from`, ignoring literals: what a typed or pasted
 * insertion contributes. Separators are skipped; anything else that fits no slot is rejected.
 * Stops when the slots run out.
 */
export function fit(mask: CompiledMask, text: string, from = 0): FitResult {
	let raw = '';
	let rejected = 0;
	let rejectedKind: SlotKind | undefined;
	for (const ch of text) {
		const slot = from + raw.length;
		if (slot >= mask.slots.length) break;
		const kind = mask.slots[slot];
		const stored = acceptChar(kind, ch);
		if (stored) raw += stored;
		else if (!isSeparator(mask, ch)) {
			rejected++;
			rejectedKind ??= kind;
		}
	}
	return { raw, rejected, rejectedKind };
}

/**
 * Reads a whole value, formatted or not, back to raw: an initial value, autofill, or what the
 * browser left after an edit it handled itself. Literals in the text line up with the mask's
 * literals, so `(555) 010-0142` and `5550100142` read the same.
 */
export function parse(mask: CompiledMask, text: string): FitResult {
	let raw = '';
	let rejected = 0;
	let rejectedKind: SlotKind | undefined;
	let t = 0;
	for (const ch of text) {
		let matchedLiteral = false;
		while (t < mask.tokens.length) {
			const token = mask.tokens[t];
			if (!('literal' in token)) break;
			t++;
			if (token.literal === ch) {
				matchedLiteral = true;
				break;
			}
		}
		if (matchedLiteral) continue;
		if (t >= mask.tokens.length) break;
		const kind = (mask.tokens[t] as { slot: SlotKind }).slot;
		const stored = acceptChar(kind, ch);
		if (stored) {
			raw += stored;
			t++;
		} else if (!isSeparator(mask, ch)) {
			rejected++;
			rejectedKind ??= kind;
		}
	}
	return { raw, rejected, rejectedKind };
}

/**
 * Reads a value that may be raw or formatted. Text whose every character lands in a slot is raw
 * and kept as it is, so a raw 155 under the mask '+1 999' is not mistaken for the literal 1;
 * anything else is read as formatted text.
 */
export function normalise(mask: CompiledMask, text: string): FitResult {
	const asRaw = fit(mask, text);
	return asRaw.raw.length === [...text].length ? asRaw : parse(mask, text);
}

/** How many slot characters sit before this position in a formatted value. */
export function slotsBefore(mask: CompiledMask, position: number): number {
	let count = 0;
	for (let i = 0; i < position && i < mask.tokens.length; i++) {
		if ('slot' in mask.tokens[i]) count++;
	}
	return count;
}

/**
 * The position in the formatted value for a raw caret index: just before slot `index` when that
 * slot holds a character (so the caret sits after any literal in front of it), else the end.
 */
export function caretPosition(mask: CompiledMask, raw: string, index: number): number {
	const formatted = format(mask, raw);
	if (index >= raw.length) return formatted.length;
	let seen = 0;
	for (let i = 0; i < mask.tokens.length; i++) {
		if ('slot' in mask.tokens[i]) {
			if (seen === index) return i;
			seen++;
		}
	}
	return formatted.length;
}

/**
 * Replaces raw[start, end) with typed or pasted text. When the field is full the insertion is
 * dropped, as maxlength would. Characters after the edit are refitted; in a mixed mask any that
 * no longer fit their new slot are counted in `dropped`, so the caller can refuse the edit.
 */
export function insertText(
	mask: CompiledMask,
	raw: string,
	start: number,
	end: number,
	text: string
): EditResult {
	const head = raw.slice(0, start);
	const tail = raw.slice(end);
	/* Text pasted at the start may be a whole formatted value with the mask's own literals. */
	const typed = start === 0 ? normalise(mask, text) : fit(mask, text, start);
	const room = Math.max(0, mask.slots.length - head.length - tail.length);
	const inserted = typed.raw.slice(0, room);
	const rest = fit(mask, inserted + tail, start).raw;
	return {
		raw: head + rest,
		rejected: typed.rejected,
		rejectedKind: typed.rejectedKind,
		caret: start + inserted.length,
		dropped: inserted.length + tail.length - rest.length
	};
}

/**
 * Deletes from a formatted selection. A collapsed backspace or delete next to a literal removes
 * the slot character beyond it, so the caret never sticks on a separator. Characters after the
 * deletion are refitted, as for an insertion.
 */
export function deleteText(
	mask: CompiledMask,
	raw: string,
	selectionStart: number,
	selectionEnd: number,
	direction: 'backward' | 'forward'
): EditResult {
	let start = slotsBefore(mask, selectionStart);
	let end = slotsBefore(mask, selectionEnd);
	if (start === end) {
		if (direction === 'backward') start = Math.max(0, start - 1);
		else end = Math.min(raw.length, end + 1);
	}
	const tail = raw.slice(end);
	const rest = fit(mask, tail, start).raw;
	return {
		raw: raw.slice(0, start) + rest,
		rejected: 0,
		caret: start,
		dropped: tail.length - rest.length
	};
}
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-5b1f5a396604615999a591723bcd6e78092ea403bf9287fd3260036dccfb762a`
- Entry: `MaskedField.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_masked_field_01/1.0.0/neutral/sha256-5b1f5a396604615999a591723bcd6e78092ea403bf9287fd3260036dccfb762a/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_masked_field_01/1.0.0/neutral/sha256-5b1f5a396604615999a591723bcd6e78092ea403bf9287fd3260036dccfb762a/bundle.zip (14830 bytes, sha256 `040dc4d50271aef98e244c7f2e5b79f14dc0877c76e1a14be399f5f2b68d7bea`)

Files:

- `MaskedField.svelte` (entry, 21401 bytes): https://pagesugar.com/artifacts/cmp_masked_field_01/1.0.0/neutral/sha256-5b1f5a396604615999a591723bcd6e78092ea403bf9287fd3260036dccfb762a/source/MaskedField.svelte
- `mask.ts` (helper, 8385 bytes): https://pagesugar.com/artifacts/cmp_masked_field_01/1.0.0/neutral/sha256-5b1f5a396604615999a591723bcd6e78092ea403bf9287fd3260036dccfb762a/source/mask.ts
