Mask
import { useState } from 'react';
const css = `
@keyframes field-mask-drop { from { opacity: 0; transform: translateY(-9px); } 60% { opacity: 1; transform: translateY(1px); } }
@keyframes field-mask-draw { to { stroke-dashoffset: 0; } }
.field-mask { width: 250px; }
.field-mask__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-mask__box {
position: relative;
height: 44px;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
transition: box-shadow 0.2s;
}
.field-mask__box:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-mask__input, .field-mask__mirror { padding: 0 14px; font: 500 16px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 1px; }
.field-mask__input { position: relative; z-index: 1; width: 100%; height: 100%; border: 0; outline: 0; background: none; color: transparent; caret-color: light-dark(#373a40, #f1f2f3); }
.field-mask__mirror { position: absolute; inset: 0; display: flex; align-items: center; white-space: pre; pointer-events: none; }
.field-mask__slot { display: inline-block; color: light-dark(#cbced3, #393c41); }
.field-mask__fill { display: inline-block; color: light-dark(#373a40, #f1f2f3); }
.field-mask__fill[data-digit] { animation: field-mask-drop 0.28s cubic-bezier(0.2, 0.8, 0.3, 1); }
.field-mask__ok { position: absolute; right: 12px; top: 13px; width: 18px; height: 18px; color: light-dark(#373a40, #f1f2f3); }
.field-mask__ok path { stroke-dasharray: 1; stroke-dashoffset: 1; animation: field-mask-draw 0.35s ease-out forwards; }
@media (prefers-reduced-motion: reduce) { .field-mask__fill[data-digit], .field-mask__ok path { animation: none; stroke-dashoffset: 0; } }
`;
const TEMPLATE = '+1 (___) ___-____';
const SLOTS = [...TEMPLATE].flatMap((c, i) => (c === '_' ? [i] : []));
/** The template filled with the digits, cut after the last digit placed. */
const format = (digits: string) => {
if (!digits) return '';
const out = [...TEMPLATE];
[...digits].forEach((d, i) => (out[SLOTS[i]] = d));
return out.slice(0, SLOTS[digits.length - 1] + 1).join('');
};
export function MaskInput() {
const [digits, setDigits] = useState('415');
const shown = format(digits);
return (
<>
<style>
{css}
</style>
<div className="field-mask">
<label className="field-mask__label" htmlFor="field-mask-input">
Phone
</label>
<div className="field-mask__box">
<span className="field-mask__mirror" aria-hidden="true">
{[...TEMPLATE].map((c, i) =>
i < shown.length ? (
<span key={`f${i}${shown[i]}`} className="field-mask__fill" data-digit={SLOTS.includes(i) || undefined}>
{shown[i]}
</span>
) : (
<span key={`s${i}`} className="field-mask__slot">
{c}
</span>
),
)}
</span>
<input
id="field-mask-input"
className="field-mask__input"
type="tel"
inputMode="tel"
autoComplete="tel-national"
value={shown}
aria-describedby="field-mask-format"
onChange={(e) => {
const raw = e.target.value.startsWith('+1') ? e.target.value.slice(2) : e.target.value;
let next = raw.replace(/\D/g, '').slice(0, SLOTS.length);
// Backspace over a bracket or dash removes the digit before it.
if (next === digits && e.target.value.length < shown.length) next = next.slice(0, -1);
setDigits(next);
}}
/>
{digits.length === SLOTS.length && (
<svg className="field-mask__ok" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5 12.5l4.5 4.5L19 7.5" pathLength={1} />
</svg>
)}
</div>
<span id="field-mask-format" hidden>
US number, ten digits
</span>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldMask(el) on the mounted element instead.
<div class="field-mask">
<label class="field-mask__label" for="field-mask-input">Phone</label>
<div class="field-mask__box">
<span class="field-mask__mirror" aria-hidden="true"><span class="field-mask__fill">+</span><span class="field-mask__fill">1</span><span class="field-mask__fill"> </span><span class="field-mask__fill">(</span><span class="field-mask__fill" data-digit="true">4</span><span class="field-mask__fill" data-digit="true">1</span><span class="field-mask__fill" data-digit="true">5</span><span class="field-mask__slot">)</span><span class="field-mask__slot"> </span><span class="field-mask__slot">_</span><span class="field-mask__slot">_</span><span class="field-mask__slot">_</span><span class="field-mask__slot">-</span><span class="field-mask__slot">_</span><span class="field-mask__slot">_</span><span class="field-mask__slot">_</span><span class="field-mask__slot">_</span></span>
<input id="field-mask-input" class="field-mask__input" type="tel" inputMode="tel" autoComplete="tel-national" aria-describedby="field-mask-format" value="+1 (415"/>
</div>
<span id="field-mask-format" hidden="">US number, ten digits</span>
</div>@keyframes field-mask-drop { from { opacity: 0; transform: translateY(-9px); } 60% { opacity: 1; transform: translateY(1px); } }
@keyframes field-mask-draw { to { stroke-dashoffset: 0; } }
.field-mask { width: 250px; }
.field-mask__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-mask__box {
position: relative;
height: 44px;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
transition: box-shadow 0.2s;
}
.field-mask__box:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-mask__input, .field-mask__mirror { padding: 0 14px; font: 500 16px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 1px; }
.field-mask__input { position: relative; z-index: 1; width: 100%; height: 100%; border: 0; outline: 0; background: none; color: transparent; caret-color: light-dark(#373a40, #f1f2f3); }
.field-mask__mirror { position: absolute; inset: 0; display: flex; align-items: center; white-space: pre; pointer-events: none; }
.field-mask__slot { display: inline-block; color: light-dark(#cbced3, #393c41); }
.field-mask__fill { display: inline-block; color: light-dark(#373a40, #f1f2f3); }
.field-mask__fill[data-digit] { animation: field-mask-drop 0.28s cubic-bezier(0.2, 0.8, 0.3, 1); }
.field-mask__ok { position: absolute; right: 12px; top: 13px; width: 18px; height: 18px; color: light-dark(#373a40, #f1f2f3); }
.field-mask__ok path { stroke-dasharray: 1; stroke-dashoffset: 1; animation: field-mask-draw 0.35s ease-out forwards; }
@media (prefers-reduced-motion: reduce) { .field-mask__fill[data-digit], .field-mask__ok path { animation: none; stroke-dashoffset: 0; } }function initFieldMask(root) {
var input = root.querySelector('.field-mask__input');
var mirror = root.querySelector('.field-mask__mirror');
var box = root.querySelector('.field-mask__box');
if (!input || !mirror || !box) return;
var TEMPLATE = '+1 (___) ___-____';
var SLOTS = [];
TEMPLATE.split('').forEach(function (c, i) { if (c === '_') SLOTS.push(i); });
// The template filled with the digits, cut after the last digit placed.
function format(digits) {
if (!digits) return '';
var out = TEMPLATE.split('');
digits.split('').forEach(function (d, i) { out[SLOTS[i]] = d; });
return out.slice(0, SLOTS[digits.length - 1] + 1).join('');
}
var digits = input.value.replace(/^\+1/, '').replace(/\D/g, '');
var shown = format(digits);
function render(prev) {
mirror.textContent = '';
TEMPLATE.split('').forEach(function (c, i) {
var s = document.createElement('span');
if (i < shown.length) {
s.className = 'field-mask__fill';
// Only a digit that was just placed drops in.
if (SLOTS.indexOf(i) >= 0 && prev[i] !== shown[i]) s.setAttribute('data-digit', '');
s.textContent = shown[i];
} else {
s.className = 'field-mask__slot';
s.textContent = c;
}
mirror.appendChild(s);
});
var ok = box.querySelector('.field-mask__ok');
var done = digits.length === SLOTS.length;
if (done && !ok) {
box.insertAdjacentHTML('beforeend', '<svg class="field-mask__ok" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" pathLength="1"></path></svg>');
} else if (!done && ok) ok.remove();
}
function change() {
var raw = input.value.indexOf('+1') === 0 ? input.value.slice(2) : input.value;
var next = raw.replace(/\D/g, '').slice(0, SLOTS.length);
// Backspace over a bracket or dash removes the digit before it.
if (next === digits && input.value.length < shown.length) next = next.slice(0, -1);
var prev = shown;
digits = next;
shown = format(digits);
input.value = shown;
render(prev);
}
input.addEventListener('input', change);
return function () { input.removeEventListener('input', change); };
}
document.querySelectorAll('.field-mask').forEach(initFieldMask);Goal: Create a "Mask" input.
Visual behavior: A phone field with its format printed in place: digits drop into the slots of +1 (___) ___-____ as you type, the remaining slots stay faint, and a tick draws in once it is complete.
Technique:
- The real input keeps transparent text and a visible caret; an aria-hidden mirror with the same font, size and padding (and font-kerning: none) draws the characters on top, so the caret always lines up.
- The value is the template filled with the digits and cut after the last one, so the caret sits after it; the mirror shows that plus the faint rest of the template.
- Typing extracts the digits again (ignoring the +1 prefix); backspacing over a bracket or dash removes the digit before it, which is what people expect.
- Each newly placed digit drops into its slot; the tick draws when all ten are in.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. type="tel" with an autocomplete hint and a hidden description of the format. Under prefers-reduced-motion nothing drops or draws.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Mask" input.
Visual behavior: A phone field with its format printed in place: digits drop into the slots of +1 (___) ___-____ as you type, the remaining slots stay faint, and a tick draws in once it is complete.
Technique:
- The real input keeps transparent text and a visible caret; an aria-hidden mirror with the same font, size and padding (and font-kerning: none) draws the characters on top, so the caret always lines up.
- The value is the template filled with the digits and cut after the last one, so the caret sits after it; the mirror shows that plus the faint rest of the template.
- Typing extracts the digits again (ignoring the +1 prefix); backspacing over a bracket or dash removes the digit before it, which is what people expect.
- Each newly placed digit drops into its slot; the tick draws when all ten are in.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. type="tel" with an autocomplete hint and a hidden description of the format. Under prefers-reduced-motion nothing drops or draws.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A phone field with its format printed in place: digits drop into the slots of +1 (___) ___-____ as you type, the remaining slots stay faint, and a tick draws in once it is complete.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- focus, state change, draw
- Techniques
- keyframes, stroke dashoffset, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <input> with a visible <label>; decoration is aria-hidden. type="tel" with an autocomplete hint and a hidden description of the format. Under prefers-reduced-motion nothing drops or draws.