Most people who drop out of a phone sign-up don't mean to. They mistype the number, wait for a code that went somewhere else, or fight a six-box input that won't accept paste. SMS verification UX best practices come down to three things: collect the number cleanly, get the code into the field with as little typing as possible, and say clearly what to do when something goes wrong. Here are ten tips, with code.
1. Use the right input for the phone number
Phone numbers aren't numbers in the maths sense. Use type="tel" so mobile keyboards show a dial pad, and let the browser offer the user's saved number.
<label for="phone">Mobile number</label> <input id="phone" name="phone" type="tel" autocomplete="tel" inputmode="tel"> Pair it with a country selector that defaults to the user's likely country, and normalise to E.164 on the server instead of forcing a format in the field.
2. Accept whatever people type
Spaces, dashes, brackets, a leading 00 or a national 0: people paste numbers from contacts and email signatures. Strip formatting characters before validating, and only show an error for numbers that are actually impossible, never for punctuation.
3. Show where the code went, and make it easy to fix
After sending, echo the number back: "We sent a code to +1 416 555 0199. Wrong number?" The link should return to the previous step with the number pre-filled. Many "code never arrived" tickets are really typos the user never noticed.
4. Let the OS fill in the code
The autocomplete="one-time-code" attribute does more for completion than any custom widget. Safari on iOS and macOS suggests the code from an incoming SMS, and many Android keyboards offer it as a suggestion too.
<label for="otp">6-digit code</label> <input id="otp" name="otp" type="text" autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]*" maxlength="6" required> Use type="text" with inputmode="numeric", not type="number": number inputs can drop leading zeros, show spinner arrows and accept values like 1e5.
5. Bind the code to your domain
Ending the SMS with an origin-bound line tells browsers which site the code belongs to, so it's only offered there, which also makes phishing pages less effective:
Your Example code is 482913. It expires in 10 minutes. @example.com #482913 In Chromium browsers on Android you can go further with the WebOTP API and fill the field after the user consents:
if ('OTPCredential' in window) { const input = document.querySelector('#otp'); const ac = new AbortController(); input.form.addEventListener('submit', () => ac.abort()); navigator.credentials .get({ otp: { transport: ['sms'] }, signal: ac.signal }) .then((otp) => { input.value = otp.code; input.form.requestSubmit(); }) .catch(() => { /* dismissed or timed out: manual entry still works */ }); } Treat this as progressive enhancement. The plain input must always work on its own.
6. One field beats six boxes
Split digit boxes look neat but break paste, autofill and screen readers unless you handle every edge case. A single input with wide letter spacing gives a similar look:
#otp { font-size: 1.5rem; letter-spacing: 0.5em; font-variant-numeric: tabular-nums; } If you must use boxes, support pasting the full code into the first one and moving back on Backspace.
7. Add a resend cooldown with a visible countdown
Users press "Resend" after five seconds if nothing tells them to wait. Disable it briefly, show the remaining time, and lengthen the delay after each resend, which also limits abuse.
function startCooldown(button, seconds) { const label = button.textContent; button.disabled = true; const tick = () => { if (seconds <= 0) { button.disabled = false; button.textContent = label; return; } button.textContent = `Resend in ${seconds--}s`; setTimeout(tick, 1000); }; tick(); } 8. Write error messages that say what to do next
"Invalid OTP" tells the user nothing. Compare:
- Wrong code: "That code doesn't match. Use the latest message; older codes stop working when you request a new one."
- Expired: "This code has expired. We can send you a new one."
- Too many attempts: "Too many tries. You can request a new code in 10 minutes."
Keep the typed value in the field after an error so people can spot a swapped digit.
9. Make it accessible
- Use a visible
<label>; a placeholder vanishes as soon as someone types. - Announce "Code sent", the end of the countdown and errors through a polite live region, e.g.
<p id="otp-status" aria-live="polite"></p>, and connect errors to the field witharia-describedby. - Don't expire codes so fast that slower users can't finish. State how long the code is valid and make a fresh one easy to get (WCAG's "Timing Adjustable" criterion covers this).
10. Test real delivery, not just staging
Mocks and provider test numbers prove you call the API correctly, not that a real handset receives a readable message. Before launch, send to real numbers in each country you support and check timing, sender name and rendering. For quick manual checks, the free public numbers on Temp SMS show incoming texts on a web page with no account. Use them only for disposable test accounts, because anyone can read those messages.
SMS verification UX best practices in short
A clean number input, one autofill-friendly code field, the number echoed back, a visible cooldown, specific errors and real-device tests per country.
FAQ
What does autocomplete="one-time-code" do?
It tells the browser and OS that the field expects a one-time code, so they can suggest the code from a recent SMS instead of making the user switch apps.
How long should an SMS code stay valid?
A few minutes up to around ten is common. Show the expiry in the message and on screen.
Should the OTP field be type="number"?
No. Use type="text" with inputmode="numeric" to get a numeric keyboard without losing leading zeros.