UI Components Medium
Input Masks
Phone, card, and date inputs format their values while keeping raw user input easy to edit.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
color-scheme: dark;
--bg: #0b1020;
--panel: #121a2d;
--panel-2: #18233b;
--line: #2b3d62;
--text: #eef2ff;
--muted: #a8b5d1;
--accent: #78a9ff;
--ok: #5fe3a1;
--bad: #ff8080;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(120% 90% at 50% -20%, #1b2b4d, transparent) var(--bg);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
padding: clamp(1rem, 4vw, 3rem);
}
button, input { font: inherit; }
.demo {
width: min(600px, 100%);
margin: auto;
display: grid;
gap: 1.4rem;
}
.demo__head h1 {
margin: 0 0 .4rem;
font-size: 1.5rem;
letter-spacing: -.02em;
}
.muted { margin: 0; color: var(--muted); font-size: .92rem; }
.mask-form {
display: grid;
gap: 1.1rem;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 20px;
padding: clamp(1.1rem, 3vw, 1.6rem);
box-shadow: 0 20px 70px #0006;
}
.field { display: grid; gap: .4rem; }
.field--split { grid-template-columns: 1fr 1fr; gap: 1rem; }
.field--split > div { display: grid; gap: .4rem; }
label {
font-size: .74rem;
font-weight: 600;
letter-spacing: .07em;
text-transform: uppercase;
color: var(--muted);
}
input {
width: 100%;
padding: .72rem .85rem;
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 11px;
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 1rem;
letter-spacing: .05em;
transition: border-color .16s ease, box-shadow .16s ease;
}
input::placeholder { color: #55689a; }
input:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px #78a9ff2e;
}
input[data-state="valid"] { border-color: #5fe3a18c; }
input[data-state="invalid"] { border-color: #ff8080a6; }
.hint {
margin: 0;
font-size: .77rem;
color: var(--muted);
min-height: 1.1em;
}
.hint[data-tone="valid"] { color: var(--ok); }
.hint[data-tone="invalid"] { color: var(--bad); }
.card-wrap { position: relative; }
.card-wrap input { padding-right: 5.2rem; }
.brand {
position: absolute;
right: .55rem;
top: 50%;
transform: translateY(-50%);
font-size: .68rem;
font-weight: 700;
letter-spacing: .09em;
text-transform: uppercase;
color: var(--muted);
background: #101a30;
border: 1px solid var(--line);
border-radius: 7px;
padding: .22rem .5rem;
transition: color .18s ease, border-color .18s ease;
}
.brand[data-known="true"] { color: var(--accent); border-color: #78a9ff73; }
.submit {
margin-top: .2rem;
padding: .78rem 1rem;
border: 0;
border-radius: 11px;
background: linear-gradient(180deg, #8bb7ff, #4b86e6);
color: #06101f;
font-weight: 700;
cursor: pointer;
transition: filter .16s ease, transform .12s ease;
}
.submit:hover { filter: brightness(1.08); }
.submit:active { transform: translateY(1px); }
.status { margin: 0; min-height: 1.15em; font-size: .84rem; color: var(--muted); }
.status[data-tone="valid"] { color: var(--ok); }
.status[data-tone="invalid"] { color: var(--bad); }
.raw {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 16px;
padding: 1rem 1.2rem;
}
.raw h2 {
margin: 0 0 .7rem;
font-size: .72rem;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--muted);
}
.raw dl { margin: 0; display: grid; gap: .38rem; }
.raw dl > div {
display: flex;
justify-content: space-between;
gap: 1rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .82rem;
}
.raw dt { color: var(--muted); }
.raw dd { margin: 0; }
@media (max-width: 460px) {
.field--split { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}/* Input masks — caret-safe formatting with raw-value extraction. Vanilla, no deps. */
(() => {
"use strict";
const digitsOf = (s) => s.replace(/\D+/g, "");
/* ---------- formatters: raw digits -> display string ---------- */
const CARD_BRANDS = [
{ id: "visa", test: /^4/, len: 16, groups: [4, 4, 4, 4] },
{ id: "amex", test: /^3[47]/, len: 15, groups: [4, 6, 5] },
{ id: "mastercard", test: /^(5[1-5]|2[2-7])/, len: 16, groups: [4, 4, 4, 4] },
{ id: "discover", test: /^6(?:011|5)/, len: 16, groups: [4, 4, 4, 4] },
{ id: "diners", test: /^3(?:0[0-5]|[68])/, len: 14, groups: [4, 6, 4] }
];
const brandFor = (d) => CARD_BRANDS.find((b) => b.test.test(d)) || null;
const group = (d, sizes, sep) => {
const out = [];
let i = 0;
for (const size of sizes) {
if (i >= d.length) break;
out.push(d.slice(i, i + size));
i += size;
}
if (i < d.length) out.push(d.slice(i));
return out.join(sep);
};
const luhn = (d) => {
let sum = 0;
let dbl = false;
for (let i = d.length - 1; i >= 0; i--) {
let n = d.charCodeAt(i) - 48;
if (dbl) { n *= 2; if (n > 9) n -= 9; }
sum += n;
dbl = !dbl;
}
return d.length > 0 && sum % 10 === 0;
};
/* Clamp a 2-digit field as the user types, e.g. month "9" -> "09". */
const clampPair = (pair, min, max) => {
if (pair.length === 1) {
// A leading digit that can't start a valid value gets zero-padded.
return Number(pair) > Number(String(max).charAt(0)) ? "0" + pair : pair;
}
const n = Number(pair);
if (n < min) return String(min).padStart(2, "0");
if (n > max) return String(max).padStart(2, "0");
return pair;
};
const daysInMonth = (m, y) => new Date(y, m, 0).getDate();
const MASKS = {
phone: {
max: 10,
format(d) {
if (d.length <= 3) return d;
if (d.length <= 6) return `(${d.slice(0, 3)}) ${d.slice(3)}`;
return `(${d.slice(0, 3)}) ${d.slice(3, 6)}-${d.slice(6)}`;
},
validate: (d) => (d.length === 0 ? [null, "US format · 10 digits"]
: d.length < 10 ? [false, `${10 - d.length} digit${d.length === 9 ? "" : "s"} to go`]
: /^[2-9]/.test(d) ? [true, "Looks like a valid US number"]
: [false, "Area code cannot start with 0 or 1"])
},
card: {
max: 19,
limit: (d) => d.slice(0, (brandFor(d) || { len: 19 }).len),
format(d) {
const b = brandFor(d);
return group(d, b ? b.groups : [4, 4, 4, 4, 3], " ");
},
validate(d) {
if (!d) return [null, "Luhn-checked · brand detected live"];
const b = brandFor(d);
const target = b ? b.len : 16;
if (d.length < target) return [false, `${target - d.length} digits remaining`];
return luhn(d) ? [true, "Checksum OK"] : [false, "Failed the Luhn checksum"];
}
},
expiry: {
max: 4,
format(d) {
if (!d.length) return "";
const mm = clampPair(d.slice(0, 2), 1, 12);
const yy = d.slice(2);
return yy || d.length > 2 ? `${mm}/${yy}` : mm;
},
validate(d) {
if (!d) return [null, "Month 01–12"];
if (d.length < 4) return [false, "MM/YY"];
const mm = Number(d.slice(0, 2));
const yy = 2000 + Number(d.slice(2, 4));
if (mm < 1 || mm > 12) return [false, "Month must be 01–12"];
const now = new Date();
const end = new Date(yy, mm, 1);
return end > now ? [true, "Card not expired"] : [false, "That date is in the past"];
}
},
date: {
max: 8,
format(d) {
if (!d.length) return "";
const dd = clampPair(d.slice(0, 2), 1, 31);
const mm = d.length > 2 ? clampPair(d.slice(2, 4), 1, 12) : "";
const yyyy = d.slice(4);
let out = dd;
if (d.length > 2) out += "/" + mm;
if (d.length > 4) out += "/" + yyyy;
return out;
},
validate(d) {
if (!d) return [null, "Calendar-validated"];
if (d.length < 8) return [false, "DD/MM/YYYY"];
const dd = Number(d.slice(0, 2));
const mm = Number(d.slice(2, 4));
const yyyy = Number(d.slice(4, 8));
if (mm < 1 || mm > 12) return [false, "Month must be 01–12"];
if (yyyy < 1900 || yyyy > new Date().getFullYear()) return [false, "Year out of range"];
if (dd < 1 || dd > daysInMonth(mm, yyyy)) {
return [false, `That month has ${daysInMonth(mm, yyyy)} days`];
}
const date = new Date(yyyy, mm - 1, dd);
return date > new Date() ? [false, "Date is in the future"] : [true, "Valid date"];
}
}
};
/* ---------- caret-preserving apply ---------- */
/* Count how many digits sit before `pos` in `value`. */
const digitsBefore = (value, pos) => digitsOf(value.slice(0, pos)).length;
/* Find the caret offset in `formatted` that sits after `n` digits. */
const posAfterDigits = (formatted, n) => {
if (n <= 0) return 0;
let seen = 0;
for (let i = 0; i < formatted.length; i++) {
if (/\d/.test(formatted[i])) {
seen++;
if (seen === n) return i + 1;
}
}
return formatted.length;
};
const status = document.querySelector("[data-status]");
const brandEl = document.querySelector("[data-brand]");
const state = new Map(); // input -> raw digits
function apply(input, opts) {
const mask = MASKS[input.dataset.mask];
const value = input.value;
const selStart = input.selectionStart ?? value.length;
let raw = digitsOf(value);
let caretDigits = digitsBefore(value, selStart);
/* Backspace on a separator should eat the digit before it, not just the
separator (which the formatter would immediately re-insert). */
if (opts && opts.deletingBackwardOverSeparator && caretDigits > 0) {
raw = raw.slice(0, caretDigits - 1) + raw.slice(caretDigits);
caretDigits--;
}
raw = raw.slice(0, mask.max);
if (mask.limit) raw = mask.limit(raw);
caretDigits = Math.min(caretDigits, raw.length);
const formatted = mask.format(raw);
input.value = formatted;
state.set(input, raw);
if (document.activeElement === input) {
const pos = posAfterDigits(formatted, caretDigits);
input.setSelectionRange(pos, pos);
}
const [ok, message] = mask.validate(raw);
const hint = document.getElementById(input.getAttribute("aria-describedby"));
if (hint) {
hint.textContent = message;
if (ok === null) hint.removeAttribute("data-tone");
else hint.dataset.tone = ok ? "valid" : "invalid";
}
if (!raw) input.removeAttribute("data-state");
else input.dataset.state = ok ? "valid" : "invalid";
if (input.dataset.mask === "card" && brandEl) {
const b = brandFor(raw);
brandEl.textContent = b ? b.id : "card";
brandEl.dataset.known = String(Boolean(b));
}
const rawCell = document.querySelector(`[data-raw-for="${input.name}"]`);
if (rawCell) rawCell.textContent = raw || "—";
}
document.querySelectorAll("input[data-mask]").forEach((input) => {
let pendingBackspaceOnSeparator = false;
input.addEventListener("keydown", (e) => {
pendingBackspaceOnSeparator = false;
if (e.key !== "Backspace") return;
const { selectionStart, selectionEnd, value } = input;
if (selectionStart !== selectionEnd || selectionStart === 0) return;
if (!/\d/.test(value[selectionStart - 1])) pendingBackspaceOnSeparator = true;
});
input.addEventListener("input", () => {
apply(input, { deletingBackwardOverSeparator: pendingBackspaceOnSeparator });
pendingBackspaceOnSeparator = false;
});
input.addEventListener("blur", () => apply(input));
apply(input);
});
document.querySelector(".mask-form").addEventListener("submit", (e) => {
e.preventDefault();
const inputs = [...document.querySelectorAll("input[data-mask]")];
const bad = inputs.find((i) => MASKS[i.dataset.mask].validate(state.get(i) || "")[0] !== true);
if (bad) {
status.textContent = "Fix the highlighted field before saving.";
status.dataset.tone = "invalid";
bad.focus();
return;
}
status.textContent = "Saved — raw values below are what a backend would receive.";
status.dataset.tone = "valid";
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Input Masks</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Input Masks">
<header class="demo__head">
<h1>Input masks</h1>
<p class="muted">Type or paste freely — formatting is applied as you go and the caret stays put. Deleting a separator removes the digit behind it.</p>
</header>
<form class="mask-form" novalidate>
<div class="field">
<label for="phone">Phone number</label>
<input id="phone" name="phone" type="tel" inputmode="tel" autocomplete="tel"
placeholder="(555) 010-4477" data-mask="phone" aria-describedby="phone-hint" />
<p class="hint" id="phone-hint">US format · 10 digits</p>
</div>
<div class="field">
<label for="card">Card number</label>
<div class="card-wrap">
<input id="card" name="card" type="text" inputmode="numeric" autocomplete="cc-number"
placeholder="4242 4242 4242 4242" data-mask="card" aria-describedby="card-hint" />
<span class="brand" data-brand aria-hidden="true">card</span>
</div>
<p class="hint" id="card-hint">Luhn-checked · brand detected live</p>
</div>
<div class="field field--split">
<div>
<label for="expiry">Expiry</label>
<input id="expiry" name="expiry" type="text" inputmode="numeric" autocomplete="cc-exp"
placeholder="MM/YY" data-mask="expiry" aria-describedby="expiry-hint" />
<p class="hint" id="expiry-hint">Month 01–12</p>
</div>
<div>
<label for="dob">Date of birth</label>
<input id="dob" name="dob" type="text" inputmode="numeric" autocomplete="bday"
placeholder="DD/MM/YYYY" data-mask="date" aria-describedby="dob-hint" />
<p class="hint" id="dob-hint">Calendar-validated</p>
</div>
</div>
<button type="submit" class="submit">Save details</button>
<p class="status" role="status" aria-live="polite" data-status></p>
</form>
<section class="raw" aria-label="Raw unmasked values">
<h2>Raw values (what you would submit)</h2>
<dl data-raw>
<div><dt>phone</dt><dd data-raw-for="phone">—</dd></div>
<div><dt>card</dt><dd data-raw-for="card">—</dd></div>
<div><dt>expiry</dt><dd data-raw-for="expiry">—</dd></div>
<div><dt>dob</dt><dd data-raw-for="dob">—</dd></div>
</dl>
</section>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function FormInputMasks() {
const [value, setValue] = useState("");
return (
<form className="demo" onSubmit={(event) => event.preventDefault()}>
<h2>Input Masks</h2>
<label>
{value.length ? "Ready" : "Enter a value"}
<input value={value} onChange={(event) => setValue(event.target.value)} />
</label>
<button type="submit">Continue</button>
</form>
);
}Input Masks
Phone, card, and date inputs format their values while keeping raw user input easy to edit.
Support notes
Validation is local and progressively enhanced. Keep labels, live messages, and inputmode attributes intact when adapting these patterns to a real backend.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.