Yo folks, I’m tuning a checkout form and I keep noticing tiny layout shifts when an input mask/validation message kicks in, especially on mobile where the placeholder width changes and the range slider below gets pushed.
// Keeps the input's rendered width stable by reserving space for the mask.
// Uses a hidden "mirror" span to measure text, then writes width once per frame.
export function stableMaskedInput(input, { mask = v => v, minCh = 1 } = {}) {
const mirror = document.createElement("span");
mirror.setAttribute("aria-hidden", "true");
mirror.style.cssText = `
position: absolute;
top: -9999px;
left: -9999px;
white-space: pre;
visibility: hidden;
`;
document.body.appendChild(mirror);
let raf = 0;
const syncFont = () => {
const cs = getComputedStyle(input);
mirror.style.font = cs.font;
mirror.style.letterSpacing = cs.letterSpacing;
mirror.style.textTransform = cs.textTransform;
};
const reserve = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
syncFont();
const raw = input.value;
const masked = mask(raw);
// Prefer current value, but fall back to placeholder so empty state doesn't shrink.
const text = masked || input.placeholder || "";
mirror.textContent = text.padEnd(minCh, " ");
// +2ch-ish padding so caret doesn't feel cramped.
const px = mirror.getBoundingClientRect().width;
input.style.inlineSize = `calc(${Math.ceil(px)}px + 1.5ch)`;
});
};
input.addEventListener("input", reserve, { passive: true });
input.addEventListener("blur", reserve, { passive: true });
window.addEventListener("resize", reserve, { passive: true });
reserve();
return () => {
cancelAnimationFrame(raf);
mirror.remove();
input.removeEventListener("input", reserve);
input.removeEventListener("blur", reserve);
window.removeEventListener("resize", reserve);
};
}
Neat part is it batches DOM reads/writes so the render pipeline stays calm, and it stops the validation + placeholder swap from nudging nearby form rows (my range control stays put).