Skip to content
Download ZIP

Neutral 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 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_masked_field_01 version 1.0.0 with variant "neutral", 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
Neutral
Version
1.0.0
Digest
Full digest
sha256-5b1f5a396604615999a591723bcd6e78092ea403bf9287fd3260036dccfb762a

This component needs all 2 files. Download the ZIP

MaskedField.svelte Svelte · 20.9 KB Raw
<!--
	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>

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.