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.
cmp_masked_field_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_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
<!--
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"
> {requiredLabel}</span
>{:else if marker === 'optional'}
<span class="text-[13px] font-normal text-(--_muted)"> {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, #1d4ed8);
--_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>
Usage#
On this pageGive 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
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
<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.
<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#
valueis the raw value,309412. Bind it; it is also what submits undername.formattedValueis what is shown,30-94-12. Bind it to read it; passformattedNameto submit it too.- An initial
valuemay 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 56and12-34-56all give the raw value123456. - 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:
.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| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | Visible label. |
mask | string | Yes | None | 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 | None | Bindable raw value without literals, for example '309412'. A formatted value such as '30-94-12' is read too. |
formattedValue | string | No | None | Bindable and read-only: the value as displayed, for example '30-94-12'. |
name | string | No | None | Form name. The raw value submits under it. |
formattedName | string | No | None | Form name for a hidden copy of the formatted value, when your server wants both. |
inputmode | 'numeric' | 'text' | 'tel' | No | None | Keyboard hint. Defaults to 'numeric' when every slot is a digit, otherwise 'text'. Use 'tel' for phone numbers. |
autocomplete | HTMLInputAttributes['autocomplete'] | No | None | Autofill token where one exists, for example 'tel-national'. |
hint | string | Yes | None | 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 | None | 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 | None | 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 | None | Which marker follows the label. Defaults to 'required' for required fields and 'none' otherwise. |
requiredLabel | string | No | 'Required' | Marker text for required fields. |
optionalLabel | string | No | 'Optional' | Marker text for optional fields. |
errorPrefix | string | No | 'Error:' | Visually hidden text read before the error. |
id | string | No | None | Input id. Defaults to one generated per instance. |
class | string | No | None | Extra classes on the outer wrapper, for placement. |
Customization#
On this pageChange 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-accentcolours the focus edge and halo and tints the autofilled fill. Keep it at 3:1 against the page. - Danger:
--masked-field-dangercolours the error edge, ring, icon and message. The default isred-700. - Neutrals:
--masked-field-inkis the label and value,--masked-field-mutedthe hint, marker and refusal notice,--masked-field-hairlinethe resting edge (keep it at 3:1, it is all that marks an empty field) and--masked-field-surfacethe fill. - Worked retone for a
#09090bband:--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,optionalLabelanderrorPrefixwith the page. The value always reads left to right.
Public CSS variables
| Variable | Token |
|---|---|
--masked-field-accent | accent |
--masked-field-danger | danger |
--masked-field-ink | ink |
--masked-field-muted | muted |
--masked-field-hairline | hairline |
--masked-field-surface | surface |
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 setsaria-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.