{"id":8424,"date":"2025-11-11T08:45:08","date_gmt":"2025-11-11T16:45:08","guid":{"rendered":"https:\/\/cloudfour.com\/?p=8424"},"modified":"2025-12-01T09:31:51","modified_gmt":"2025-12-01T17:31:51","slug":"simple-one-time-passcode-inputs","status":"publish","type":"post","link":"https:\/\/cloudfour.com\/thinks\/simple-one-time-passcode-inputs\/","title":{"rendered":"Simple One-Time Passcode Inputs"},"content":{"rendered":"\n<p>If you&#8217;ve signed into an online service in the last decade, chances are you&#8217;ve been asked to fill a one-time passcode (&#8220;OTP&#8221;) field with a handful of digits from a text, email or authenticator app:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large is-style-outlined\"><img width=\"1024\" height=\"686\" src=\"https:\/\/cloudfour.com\/wp-content\/uploads\/2025\/11\/slack-otp-1024x686.png\" alt=\"Screenshot of the Slack interface after attempting a sign-in and being asked for a verification code from email. The code entry is divided into separate steps per digit.\" class=\"wp-image-8425\" srcset=\"https:\/\/cloudfour.com\/wp-content\/uploads\/2025\/11\/slack-otp-1024x686.png 1024w, https:\/\/cloudfour.com\/wp-content\/uploads\/2025\/11\/slack-otp-300x201.png 300w, https:\/\/cloudfour.com\/wp-content\/uploads\/2025\/11\/slack-otp-768x515.png 768w, https:\/\/cloudfour.com\/wp-content\/uploads\/2025\/11\/slack-otp-1536x1029.png 1536w, https:\/\/cloudfour.com\/wp-content\/uploads\/2025\/11\/slack-otp.png 1636w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">Slack&#8217;s OTP entry form<\/figcaption><\/figure>\n\n\n\n<p>Despite the prevalence of this pattern, it seems to cause plenty of anxiety in otherwise level-headed web developers\u2026 especially if they&#8217;ve fixated on the current trend of <em>segmenting<\/em> the input to convey the passcode&#8217;s length (a new spin on the ol&#8217; <a href=\"https:\/\/css-tricks.com\/input-masking\/\">input mask<\/a>).<\/p>\n\n\n\n<p>Why else would so many tumble down the rabbit hole of building their own <code>&lt;input&gt;<\/code> replacement, stringing multiple <code>&lt;input&gt;<\/code> elements together, or burdening their project with <em>yet another<\/em> third-party dependency?<\/p>\n\n\n\n<p>If you find yourself in a similar situation, I have good news! You can ship a fully functional OTP input today without any CSS hacks or JavaScript frameworks.<\/p>\n\n\n\n<p>All you need is some HTML.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Basic Markup<\/h2>\n\n\n\n<p>A single <code>&lt;input&gt;<\/code> element: That&#8217;s where the OTP magic happens!<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">input<\/span> <span class=\"hljs-attr\">type<\/span>=<span class=\"hljs-string\">\"text\"<\/span>\n  <span class=\"hljs-attr\">inputmode<\/span>=<span class=\"hljs-string\">\"numeric\"<\/span>\n  <span class=\"hljs-attr\">autocomplete<\/span>=<span class=\"hljs-string\">\"one-time-code\"<\/span>\n  <span class=\"hljs-attr\">maxlength<\/span>=<span class=\"hljs-string\">\"6\"<\/span>&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Let&#8217;s break down each of its attributes:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Even though our passcode will consist of numbers, it isn&#8217;t <em>actually<\/em> a number: A value of <code>000004<\/code> should <em>not<\/em> be the considered the same as a value of <code>4<\/code>. For that reason, we follow <a href=\"https:\/\/html.spec.whatwg.org\/multipage\/input.html#when-number-is-not-appropriate\">the HTML spec<\/a> and set <code>type=\"text\"<\/code>.<\/li>\n\n\n\n<li><code>inputmode=\"numeric\"<\/code> enables a numeric virtual keyboard on touch devices.<\/li>\n\n\n\n<li><code>autocomplete=\"one-time-code\"<\/code> adds support for <a href=\"https:\/\/cloudfour.com\/thinks\/autofill-what-web-devs-should-know-but-dont\/\">autofill<\/a> from password managers or <a href=\"https:\/\/web.dev\/articles\/sms-otp-form\">via SMS<\/a>.<\/li>\n\n\n\n<li><code>maxlength=\"6\"<\/code> prevents visitors from typing too many characters.<\/li>\n<\/ul>\n\n\n\n<p>We can opt into <a href=\"https:\/\/cloudfour.com\/thinks\/progressively-enhanced-form-validation-part-1-html-and-css\/\">client-side validation<\/a> by adding two more:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml shcb-code-table\"><span class='shcb-loc'><span><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">input<\/span> <span class=\"hljs-attr\">type<\/span>=<span class=\"hljs-string\">\"text\"<\/span><\/span>\n<\/span><\/span><span class='shcb-loc'><span><span class=\"hljs-tag\">  <span class=\"hljs-attr\">inputmode<\/span>=<span class=\"hljs-string\">\"numeric\"<\/span><\/span>\n<\/span><\/span><span class='shcb-loc'><span><span class=\"hljs-tag\">  <span class=\"hljs-attr\">autocomplete<\/span>=<span class=\"hljs-string\">\"one-time-code\"<\/span><\/span>\n<\/span><\/span><span class='shcb-loc'><span><span class=\"hljs-tag\">  <span class=\"hljs-attr\">maxlength<\/span>=<span class=\"hljs-string\">\"6\"<\/span><\/span>\n<\/span><\/span><mark class='shcb-loc'><span><span class=\"hljs-tag\">  <span class=\"hljs-attr\">pattern<\/span>=<span class=\"hljs-string\">\"\\d{6}\"<\/span><\/span>\n<\/span><\/mark><mark class='shcb-loc'><span><span class=\"hljs-tag\">  <span class=\"hljs-attr\">required<\/span>&gt;<\/span>\n<\/span><\/mark><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p><code>pattern<\/code> defines the code we expect, in this case exactly six (<code>{6}<\/code>) numeric digits (<code>\\d<\/code>). <code>required<\/code> tells the browser this field must have a value that satisfies the <code>pattern<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Example: In a Form<\/h2>\n\n\n\n<p>Now all our OTP-specific features are accounted for, but an input is meaningless without context. Let&#8217;s fix that by building out a full form with a heading, a label, a submit button and a support link in case something goes wrong:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">form<\/span> <span class=\"hljs-attr\">action<\/span>=<span class=\"hljs-string\">\"\u2026\"<\/span> <span class=\"hljs-attr\">method<\/span>=<span class=\"hljs-string\">\"post\"<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">h2<\/span>&gt;<\/span>Verify Account<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">h2<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">label<\/span> <span class=\"hljs-attr\">for<\/span>=<span class=\"hljs-string\">\"otp\"<\/span>&gt;<\/span>\n    Enter the 6-digit numeric code sent to the number ending in 55\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">label<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">input<\/span> <span class=\"hljs-attr\">type<\/span>=<span class=\"hljs-string\">\"text\"<\/span>\n    <span class=\"hljs-attr\">id<\/span>=<span class=\"hljs-string\">\"otp\"<\/span>\n    <span class=\"hljs-attr\">inputmode<\/span>=<span class=\"hljs-string\">\"numeric\"<\/span>\n    <span class=\"hljs-attr\">autocomplete<\/span>=<span class=\"hljs-string\">\"one-time-code\"<\/span>\n    <span class=\"hljs-attr\">maxlength<\/span>=<span class=\"hljs-string\">\"6\"<\/span>\n    <span class=\"hljs-attr\">pattern<\/span>=<span class=\"hljs-string\">\"\\d{6}\"<\/span>\n    <span class=\"hljs-attr\">required<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">button<\/span>&gt;<\/span>\n    Continue\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">button<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">a<\/span> <span class=\"hljs-attr\">href<\/span>=<span class=\"hljs-string\">\"\u2026\"<\/span>&gt;<\/span>\n    Try another way\u2026\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">a<\/span>&gt;<\/span>\n<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">form<\/span>&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Note how the label specifies the intended length and format of the passcode. No input mask, icon or visual affordance can match the accessibility and clarity of straightforward text!<\/p>\n\n\n\n<p>And with that, our OTP pattern is functionally complete!<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Demo: With Styles<\/h2>\n\n\n\n<p>Since we&#8217;ve covered all the critical functionality in our HTML, we&#8217;re free to style our form however the project dictates.<\/p>\n\n\n\n<p>In this example, I&#8217;ve chosen a large, monospaced font with some <code>letter-spacing<\/code> to keep every digit of the code distinct and readable. I&#8217;m also using <a href=\"https:\/\/cloudfour.com\/thinks\/progressively-enhanced-form-validation-part-1-html-and-css\/#validity-pseudo-classes\">the <code>:invalid<\/code> pseudo class<\/a> to reduce the visual prominence of the <code>&lt;button&gt;<\/code> element until the code is valid:<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_ZYQwKey\" src=\"\/\/codepen.io\/anon\/embed\/ZYQwKey?height=450&amp;theme-id=1&amp;slug-hash=ZYQwKey&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed ZYQwKey\" title=\"CodePen Embed ZYQwKey\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<h2 class=\"wp-block-heading\">Demo: Enhanced<\/h2>\n\n\n\n<p>Having a solid foundation in HTML and CSS alone doesn&#8217;t preclude us from leveraging JavaScript, too. <\/p>\n\n\n\n<p>Here&#8217;s the same demo as before, but with a simple input mask <a href=\"https:\/\/cloudfour.com\/topics\/web-components\/\">web component<\/a> to indicate remaining characters:<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_JoGqEpm\" src=\"\/\/codepen.io\/anon\/embed\/JoGqEpm?height=450&amp;theme-id=1&amp;slug-hash=JoGqEpm&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed JoGqEpm\" title=\"CodePen Embed JoGqEpm\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>Because this builds atop existing patterns instead of replacing them outright, the code is <em>tiny<\/em>: Less than a kilobyte without any optimization or compression.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Takeaways<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>All critical features of a one-time passcode input are possible using HTML alone.<\/li>\n\n\n\n<li>Clear labels and instructive text are more important than any visual affordance.<\/li>\n\n\n\n<li>Custom design and behavior can be layered on as <a href=\"https:\/\/cloudfour.com\/topics\/progressive-enhancement\/\">progressive enhancements<\/a>.<\/li>\n\n\n\n<li>This approach is quicker to implement and avoids many common performance and accessibility pitfalls.<\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>Fully functional OTP entry may be easier than you think.<\/p>\n","protected":false},"author":7,"featured_media":8455,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"inline_featured_image":false,"jetpack_post_was_ever_published":false,"_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"highway","default_image_id":0,"font":"","enabled":false},"version":2}},"categories":[232,233,256,240,36,321,22,287],"tags":[],"class_list":["post-8424","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-accessibility","category-css","category-development","category-javascript","category-performance","category-progressive-enhancement","category-standards","category-web-components"],"acf":[],"jetpack_publicize_connections":[],"jetpack_featured_media_url":"https:\/\/cloudfour.com\/wp-content\/uploads\/2025\/11\/otp-feature-r2.png","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/cloudfour.com\/wp-json\/wp\/v2\/posts\/8424","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/cloudfour.com\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/cloudfour.com\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/cloudfour.com\/wp-json\/wp\/v2\/users\/7"}],"replies":[{"embeddable":true,"href":"https:\/\/cloudfour.com\/wp-json\/wp\/v2\/comments?post=8424"}],"version-history":[{"count":0,"href":"https:\/\/cloudfour.com\/wp-json\/wp\/v2\/posts\/8424\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/cloudfour.com\/wp-json\/wp\/v2\/media\/8455"}],"wp:attachment":[{"href":"https:\/\/cloudfour.com\/wp-json\/wp\/v2\/media?parent=8424"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/cloudfour.com\/wp-json\/wp\/v2\/categories?post=8424"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/cloudfour.com\/wp-json\/wp\/v2\/tags?post=8424"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}