Skip to content
Download ZIP

Blue accent palette · 14.5 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_masked_field_01 · version 1.0.0 · Blue accent palette
Using the PageSugar MCP server, fetch component cmp_masked_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-abc3167c9fc82e9a7159a17e9088299df086a8dee52adf2f9857acab85b3bfd5

This component needs all 2 files. Download the ZIP

mask.ts TypeScript · 8.2 KB Raw
/**
 * 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
	};
}

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.

Suggested location
src/lib/components/masked-field-01
Required props
labelmaskhint

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.

Example

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>

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.

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

On this page
NameTypeRequiredDefaultDescription
labelstringYesNoneVisible label.
maskstringYesNoneThe 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'.
valuestringNoNoneBindable raw value without literals, for example '309412'. A formatted value such as '30-94-12' is read too.
formattedValuestringNoNoneBindable and read-only: the value as displayed, for example '30-94-12'.
namestringNoNoneForm name. The raw value submits under it.
formattedNamestringNoNoneForm name for a hidden copy of the formatted value, when your server wants both.
inputmode'numeric' | 'text' | 'tel'NoNoneKeyboard hint. Defaults to 'numeric' when every slot is a digit, otherwise 'text'. Use 'tel' for phone numbers.
autocompleteHTMLInputAttributes['autocomplete']NoNoneAutofill token where one exists, for example 'tel-national'.
hintstringYesNoneThe format in words and an example, for example '6 digits, like 30-94-12.' Shown above the input and first in aria-describedby.
errorstring | nullNonullValidation 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) => voidNoNoneCalled with the raw value whenever an edit fills the last slot.
monospacebooleanNofalseSets 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.
rejectMessagesPartial<Record<'digit' | 'letter' | 'alphanumeric' | 'replace', string>>NoNoneWhat 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.'
reserveFeedbackbooleanNotrueHolds 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.
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.
idstringNoNoneInput id. Defaults to one generated per instance.
classstringNoNoneExtra classes on the outer wrapper, for placement.

Customization#

On this page

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.

Public CSS variables

VariableToken
--masked-field-accentaccent
--masked-field-dangerdanger
--masked-field-inkink
--masked-field-mutedmuted
--masked-field-hairlinehairline
--masked-field-surfacesurface

Accessibility#

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

Release details#

On this page
Integration
  • Local interaction
  • Requires client-side JavaScript to be interactive
  • 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.