├── .editorconfig ├── .eslintignore ├── .github ├── ISSUE_TEMPLATE │ ├── bad-design-choice.md │ ├── bug-report.md │ └── feature_request.md └── workflows │ └── playwright.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── apps ├── storybook │ ├── .gitignore │ ├── .storybook │ │ ├── main.js │ │ └── preview.js │ ├── LICENSE │ ├── README.md │ ├── components.json │ ├── index.html │ ├── package.json │ ├── public │ │ └── vite.svg │ ├── src │ │ ├── assets │ │ │ ├── font │ │ │ │ ├── OpenSans-Light-webfont.eot │ │ │ │ ├── OpenSans-Light-webfont.svg │ │ │ │ ├── OpenSans-Light-webfont.ttf │ │ │ │ ├── OpenSans-Light-webfont.woff │ │ │ │ ├── OpenSans-Regular-webfont.eot │ │ │ │ ├── OpenSans-Regular-webfont.svg │ │ │ │ ├── OpenSans-Regular-webfont.ttf │ │ │ │ └── OpenSans-Regular-webfont.woff │ │ │ ├── icon │ │ │ │ ├── percolate.eot │ │ │ │ ├── percolate.svg │ │ │ │ ├── percolate.ttf │ │ │ │ └── percolate.woff │ │ │ └── react.svg │ │ ├── components │ │ │ └── ui │ │ │ │ └── input-otp.tsx │ │ ├── globals.css │ │ ├── lib │ │ │ └── utils.ts │ │ └── stories │ │ │ ├── Button.stories.ts │ │ │ ├── Button.tsx │ │ │ ├── InputOTP.stories.tsx │ │ │ ├── Introduction.mdx │ │ │ ├── assets │ │ │ ├── code-brackets.svg │ │ │ ├── colors.svg │ │ │ ├── comments.svg │ │ │ ├── direction.svg │ │ │ ├── flow.svg │ │ │ ├── plugin.svg │ │ │ ├── repo.svg │ │ │ └── stackalt.svg │ │ │ └── button.css │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.js ├── test │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── next.config.mjs │ ├── package.json │ ├── playwright.config.ts │ ├── postcss.config.js │ ├── public │ │ ├── next.svg │ │ └── vercel.svg │ ├── src │ │ ├── app │ │ │ ├── base │ │ │ │ └── page.tsx │ │ │ ├── favicon.ico │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ ├── props │ │ │ │ └── page.tsx │ │ │ ├── with-autofocus │ │ │ │ └── page.tsx │ │ │ └── with-on-complete │ │ │ │ └── page.tsx │ │ ├── components │ │ │ └── base-input.tsx │ │ ├── lib │ │ │ ├── fonts.ts │ │ │ └── utils.ts │ │ └── tests │ │ │ ├── base.delete-word.spec.ts │ │ │ ├── base.props.spec.ts │ │ │ ├── base.render.spec.ts │ │ │ ├── base.selections.spec.ts │ │ │ ├── base.slot.spec.ts │ │ │ ├── base.typing.spec.ts │ │ │ ├── util │ │ │ └── modifier.ts │ │ │ ├── with-autofocus.spec.ts │ │ │ └── with-on-complete.spec.ts │ ├── tailwind.config.ts │ └── tsconfig.json └── website │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── components.json │ ├── next.config.mjs │ ├── package.json │ ├── pnpm-lock.yaml │ ├── postcss.config.js │ ├── public │ ├── next.svg │ ├── og.jpg │ ├── site.webmanifest │ └── vercel.svg │ ├── src │ ├── app │ │ ├── (local-pages) │ │ │ ├── example-auto-submit │ │ │ │ ├── page.tsx │ │ │ │ └── server │ │ │ │ │ └── form-action.ts │ │ │ ├── example-playground │ │ │ │ ├── code.tsx │ │ │ │ ├── component.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── raw-input │ │ │ │ └── page.tsx │ │ │ └── shadcn │ │ │ │ ├── page.tsx │ │ │ │ ├── pwmb │ │ │ │ └── page.tsx │ │ │ │ └── static │ │ │ │ └── page.tsx │ │ ├── (pages) │ │ │ ├── (home) │ │ │ │ ├── _components │ │ │ │ │ ├── confetti.tsx │ │ │ │ │ └── showcase.tsx │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── favicon.ico │ │ └── globals.css │ ├── components │ │ ├── code.tsx │ │ ├── copy-button.tsx │ │ ├── icons.tsx │ │ ├── mode-toggle.tsx │ │ ├── page-header.tsx │ │ ├── provider.tsx │ │ ├── site-footer.tsx │ │ ├── site-header.tsx │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── scroll-area.tsx │ │ │ └── sonner.tsx │ ├── config │ │ └── site.ts │ └── lib │ │ ├── fonts.ts │ │ └── utils.ts │ ├── tailwind.config.ts │ └── tsconfig.json ├── package.json ├── packages └── input-otp │ ├── package.json │ ├── src │ ├── index.ts │ ├── input.tsx │ ├── regexp.ts │ ├── sync-timeouts.ts │ ├── types.ts │ ├── use-previous.ts │ └── use-pwm-badge.tsx │ ├── tsconfig.json │ └── tsup.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── turbo.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc* 2 | *.config.js 3 | /.jest/* 4 | /coverage/* 5 | /dist/* 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bad-design-choice.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bad design choice 3 | about: Describe a preference/default that was either 1. ignored 2. blocked or 3. wrong 4 | from the API perspective. 5 | title: "[DX/UX]" 6 | labels: invalid 7 | assignees: '' 8 | 9 | --- 10 | 11 | Example: 12 | 13 | I'm unhappy with ___ design/API choice because ___. The ___ could be better if you ___. 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Describe your bug. Which version? 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: guilhermerodz 7 | 8 | --- 9 | 10 | Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our slack channel. Please use [stackoverflow](https://stackoverflow.com) for supporting issues. 11 | 12 | 13 | 14 | ## Version of the library: 1.x 15 | 16 | ## Expected Behavior 17 | 18 | 19 | ## Current Behavior 20 | 21 | 22 | ## Possible Solution 23 | 24 | 25 | ## Steps to Reproduce 26 | 27 | 28 | 1. 29 | 2. 30 | 3. 31 | 4. 32 | 33 | ## Context (Environment) 34 | 35 | 36 | 37 | 38 | 39 | ## Detailed Description 40 | 41 | 42 | ## Possible Implementation 43 | 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [main, master] 5 | pull_request: 6 | branches: [main, master] 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 18.17.1 16 | - run: npm install -g pnpm@8.7.1 17 | 18 | - run: pnpm install 19 | - run: npx playwright install --with-deps 20 | - run: pnpm test || exit 1 21 | 22 | - uses: actions/upload-artifact@v3 23 | if: always() 24 | with: 25 | name: playwright-report 26 | path: playwright-report/ 27 | retention-days: 30 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | dist 3 | 4 | # dependencies 5 | node_modules 6 | .pnp 7 | .pnp.js 8 | 9 | # testing 10 | coverage 11 | test-results/ 12 | playwright-report/ 13 | playwright/.cache/ 14 | 15 | # next.js 16 | .next/ 17 | out/ 18 | build 19 | next-env.d.ts 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | .pnpm-debug.log* 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | 37 | # turbo 38 | .turbo 39 | 40 | # post-build 41 | packages/input-otp/README.md -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.2.4] 4 | 5 | - fix(input): prevent single caret selection on deletion/cutting 6 | 7 | ## [1.2.3] 8 | 9 | - fix(input/css): specify `color: transparent !important` for `::selection` modifier 10 | - fix(input/node-env): check for CSS supports api before calling fn 11 | 12 | ## [1.2.2] 13 | 14 | - chore(input): remove experimental flag `pushPasswordManagerStrategy` 15 | 16 | ## [1.2.1] 17 | 18 | - fix(input): use `color` not `text` for autofillStyles 19 | - chore(input): keep support for prop pushPasswordManagerStrategy="experimental-no-flickering" 20 | - fix(input): prevent layout expansion when password managers aren't there and remove "experimental-no-flickering" strategy 21 | 22 | ## [1.2.0] 23 | 24 | - chore(input): don't restrict inputMode typing 25 | 26 | ## [1.2.0-beta.1] 27 | 28 | - fix(input): renderfn typing 29 | 30 | ## [1.2.0-beta.0] 31 | 32 | - feat(input): add context option 33 | - chore(input): remove unused type `SelectionType` 34 | 35 | ## [1.1.0] 36 | 37 | - feat(input/no-js): allow opting out of no-js fallback 38 | - fix(input/no-js): move noscript to the top 39 | - chore(input): optimize use-badge 40 | - fix(input): set no extra width on default noscript css fallback 41 | - fix(input): check window during ssr 42 | - fix(input/ios): add right: 1px to compensate left: -1px 43 | - chore(input/ios): revert paste listener (re-add) 44 | - chore(input): always trigger selection menu on ios 45 | - perf(input): prevent trackPWMBadge when strategy is none 46 | - fix(input): do not skip left slot 47 | - fix(input): do not skip left slot when pressing arrowleft after insert mode 48 | - fix(input): reinforce wrapper to pointerEvents none 49 | - feat(input): add experimental push pwm badge 50 | - chore(input): rename prop to pushPasswordManagerStrategy 51 | - chore(input): move focus logic to _focusListener 52 | - fix(input): reinforce no box shadows 53 | - perf(input): rewrite core in a single event listener 54 | - fix(input): safe insert css rules 55 | - fix(input): prevent layout shift caused by password managers 56 | - feat(input): add pwm badge space detector 57 | - feat(input): add passwordManagerBehavior prop 58 | - fix(input): forcefully remove :autofill 59 | - feat(input): track password managers 60 | 61 | ## [1.0.1] 62 | 63 | - fix(input): immediately update selection after paste 64 | - fix(input): hide selection on iOS webkit 65 | 66 | ## [1.0.0] 67 | 68 | - fix(input/firefox): use setselectionrange direction:backwards 69 | 70 | ## [0.3.31-beta] 71 | 72 | No input scope changes for this version. 73 | 74 | ## [0.3.3-beta] 75 | 76 | No input scope changes for this version. 77 | 78 | ## [0.3.2-beta] 79 | 80 | No input scope changes for this version. 81 | 82 | ## [0.3.11-beta] 83 | 84 | No input scope changes for this version. 85 | 86 | ## [0.3.1-beta] 87 | 88 | No input scope changes for this version. 89 | 90 | ## [0.2.4] 91 | 92 | - chore(input): always focus onContainerClick 93 | 94 | ## [0.2.1] 95 | 96 | - fix(input): do not trigger `onComplete` twice 97 | 98 | ## [0.2] 99 | 100 | No input scope changes for this version. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Guilherme Rodz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The only accessible & unstyled & full featured Input OTP component in the Web. 2 | 3 | ### OTP Input for React 🔐 by [@guilhermerodz](https://twitter.com/guilherme_rodz) 4 | 5 | https://github.com/guilhermerodz/input-otp/assets/10366880/753751f5-eda8-4145-a4b9-7ef51ca5e453 6 | 7 | ## Usage 8 | 9 | ```bash 10 | npm install input-otp 11 | ``` 12 | 13 | Then import the component. 14 | 15 | ```diff 16 | +'use client' 17 | +import { OTPInput } from 'input-otp' 18 | 19 | function MyForm() { 20 | return
21 | + (...)} /> 22 | 23 | } 24 | ``` 25 | 26 | ## Default example 27 | 28 | The example below uses `tailwindcss` `@shadcn/ui` `tailwind-merge` `clsx`: 29 | 30 | ```tsx 31 | 'use client' 32 | import { OTPInput, SlotProps } from 'input-otp' 33 | ( 37 | <> 38 |
39 | {slots.slice(0, 3).map((slot, idx) => ( 40 | 41 | ))} 42 |
43 | 44 | 45 | 46 |
47 | {slots.slice(3).map((slot, idx) => ( 48 | 49 | ))} 50 |
51 | 52 | )} 53 | /> 54 | 55 | // Feel free to copy. Uses @shadcn/ui tailwind colors. 56 | function Slot(props: SlotProps) { 57 | return ( 58 |
69 | {props.char !== null &&
{props.char}
} 70 | {props.hasFakeCaret && } 71 |
72 | ) 73 | } 74 | 75 | // You can emulate a fake textbox caret! 76 | function FakeCaret() { 77 | return ( 78 |
79 |
80 |
81 | ) 82 | } 83 | 84 | // Inspired by Stripe's MFA input. 85 | function FakeDash() { 86 | return ( 87 |
88 |
89 |
90 | ) 91 | } 92 | 93 | // tailwind.config.ts for the blinking caret animation. 94 | const config = { 95 | theme: { 96 | extend: { 97 | keyframes: { 98 | 'caret-blink': { 99 | '0%,70%,100%': { opacity: '1' }, 100 | '20%,50%': { opacity: '0' }, 101 | }, 102 | }, 103 | animation: { 104 | 'caret-blink': 'caret-blink 1.2s ease-out infinite', 105 | }, 106 | }, 107 | }, 108 | } 109 | 110 | // Small utility to merge class names. 111 | import { clsx } from 'clsx' 112 | import { twMerge } from 'tailwind-merge' 113 | 114 | import type { ClassValue } from 'clsx' 115 | 116 | export function cn(...inputs: ClassValue[]) { 117 | return twMerge(clsx(inputs)) 118 | } 119 | ``` 120 | 121 | ## How it works 122 | 123 | There's currently no native OTP/2FA/MFA input in HTML, which means people are either going with 1. a simple input design or 2. custom designs like this one. 124 | This library works by rendering an invisible input as a sibling of the slots, contained by a `relative`ly positioned parent (the container root called _OTPInput_). 125 | 126 | ## Features 127 | 128 | This is the most complete OTP input on the web. It's fully featured 129 | 130 |
131 | Supports iOS + Android copy-paste-cut 132 | 133 | https://github.com/guilhermerodz/input-otp/assets/10366880/bdbdc96a-23da-4e89-bff8-990e6a1c4c23 134 | 135 |
136 | 137 |
138 | Automatic OTP code retrieval from transport (e.g SMS) 139 | 140 | By default, this input uses `autocomplete='one-time-code'` and it works as it's a single input. 141 | 142 | https://github.com/guilhermerodz/input-otp/assets/10366880/5705dac6-9159-443b-9c27-b52e93c60ea8 143 | 144 |
145 | 146 |
147 | Supports screen readers (a11y) 148 | 149 | Stripe was my first inspiration to build this library. 150 | 151 | Take a look at Stripe's input. The screen reader does not behave like it normally should on a normal single input. 152 | That's because Stripe's solution is to render a 1-digit input with "clone-divs" rendering a single char per div. 153 | 154 | https://github.com/guilhermerodz/input-otp/assets/10366880/3d127aef-147c-4f28-9f6c-57a357a802d0 155 | 156 | So we're rendering a single input with invisible/transparent colors instead. 157 | The screen reader now gets to read it, but there is no appearance. Feel free to build whatever UI you want: 158 | 159 | https://github.com/guilhermerodz/input-otp/assets/10366880/718710f0-2198-418c-8fa0-46c05ae5475d 160 | 161 |
162 | 163 |
164 | Supports all keybindings 165 | 166 | Should be able to support all keybindings of a common text input as it's an input. 167 | 168 | https://github.com/guilhermerodz/input-otp/assets/10366880/185985c0-af64-48eb-92f9-2e59be9eb78f 169 | 170 |
171 | 172 |
173 | Automatically optimizes for password managers 174 | 175 | 176 | For password managers such as LastPass, 1Password, Dashlane or Bitwarden, `input-otp` will automatically detect them in the page and increase input width by ~40px to trick the password manager's browser extension and prevent the badge from rendering to the last/right slot of the input. 177 | 178 | image 179 | 180 | - **This feature is optional and it's enabled by default. You can disable this optimization by adding `pushPasswordManagerStrategy="none"`.** 181 | - **This feature does not cause visible layout shift.** 182 | 183 | ### Auto tracks if the input has space in the right side for the badge 184 | 185 | https://github.com/guilhermerodz/input-otp/assets/10366880/bf01af88-1f82-463e-adf4-54a737a92f59 186 | 187 |
188 | 189 | ## API Reference 190 | 191 | ### OTPInput 192 | 193 | The root container. Define settings for the input via props. Then, use the `render` prop to create the slots. 194 | 195 | #### Props 196 | 197 | ```ts 198 | type OTPInputProps = { 199 | // The number of slots 200 | maxLength: number 201 | 202 | // Render function creating the slots 203 | render: (props: RenderProps) => React.ReactElement 204 | // PS: Render prop is mandatory, except in cases 205 | // you'd like to consume the original Context API. 206 | // (search for Context in this docs) 207 | 208 | // The class name for the root container 209 | containerClassName?: string 210 | 211 | // Value state controlling the input 212 | value?: string 213 | // Setter for the controlled value (or callback for uncontrolled value) 214 | onChange?: (newValue: string) => unknown 215 | 216 | // Callback when the input is complete 217 | onComplete?: (...args: any[]) => unknown 218 | 219 | // Where is the text located within the input 220 | // Affects click-holding or long-press behavior 221 | // Default: 'left' 222 | textAlign?: 'left' | 'center' | 'right' 223 | 224 | // Virtual keyboard appearance on mobile 225 | // Default: 'numeric' 226 | inputMode?: 'numeric' | 'text' | 'decimal' | 'tel' | 'search' | 'email' | 'url' 227 | 228 | // Enabled by default, it's an optional 229 | // strategy for detecting Password Managers 230 | // in the page and then shifting their 231 | // badges to the right side, outside the input. 232 | pushPasswordManagerStrategy?: 233 | | 'increase-width' 234 | | 'none' 235 | 236 | // Enabled by default, it's an optional 237 | // fallback for pages without JS. 238 | // This is a CSS string. Write your own 239 | // rules that will be applied as soon as 240 | //