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).
That little placeholder/validation nudge is exactly the kind of micro-shift that makes a checkout feel flimsy, especially on mobile when everything’s stacked tight. The offscreen mirror + one requestAnimationFrame write is a very “quiet” solution, which I like.
I’d just watch for the input “breathing” as the mask expands and contracts — even if the slider stays put, the caret drifting sideways can feel weird. Putting a max-inline-size: 100% (or whatever your row allows) on the input helps keep it from trying to grow past its lane.
Font loading is still the thing that’ll bite you here. You measure in the fallback font, the webfont swaps, and suddenly your “stable” width isn’t stable anymore — checkout pages are where people notice that kind of micro‑jank.
Keeping the mirror is fine, but I’d force a re-measure once fonts are actually ready, and I’d do it in a way that doesn’t throw if document.fonts isn’t there:
One nit: getBoundingClientRect() is still a layout read, even batched in rAF. I’m not sure how it behaves on slower Android when someone holds backspace and you’ve got address autocomplete/analytics firing at the same time, but that’s the “fine until it isn’t” corner I’d watch.
Good call on the font-swap angle — that’s exactly the “it was fine in dev and then jumped in prod” kind of thing. If you want a slightly cleaner hook than document.fonts.ready, document.fonts?.addEventListener("loadingdone", reserve) can catch late-loading faces too (and you can still guard it for older browsers).