The only accessible & unstyled & full featured Input OTP component in the Web.
https://github.com/guilhermerodz/input-otp/assets/10366880/753751f5-eda8-4145-a4b9-7ef51ca5e453
Usage
npm install input-otp
Then import the component.
+'use client'
+import { OTPInput } from 'input-otp'
function MyForm() {
return <form>
+ <OTPInput maxLength={6} render={({slots}) => (...)} />
</form>
}
Default example
The example below uses tailwindcss
@shadcn/ui
tailwind-merge
clsx
:
'use client'
import { OTPInput, SlotProps } from 'input-otp'
<OTPInput
maxLength={6}
containerClassName="group flex items-center has-[:disabled]:opacity-30"
render={({ slots }) => (
<>
<div className="flex">
{slots.slice(0, 3).map((slot, idx) => (
<Slot key={idx} {...slot} />
))}
</div>
<FakeDash />
<div className="flex">
{slots.slice(3).map((slot, idx) => (
<Slot key={idx} {...slot} />
))}
</div>
</>
)}
/>
function Slot(props: SlotProps) {
return (
<div
className={cn(
'relative w-10 h-14 text-[2rem]',
'flex items-center justify-center',
'transition-all duration-300',
'border-border border-y border-r first:border-l first:rounded-l-md last:rounded-r-md',
'group-hover:border-accent-foreground/20 group-focus-within:border-accent-foreground/20',
'outline outline-0 outline-accent-foreground/20',
{ 'outline-4 outline-accent-foreground': props.isActive },
)}
>
<div className="group-has-[input[data-input-otp-placeholder-shown]]:opacity-20">
{props.char ?? props.placeholderChar}
</div>
{props.hasFakeCaret && <FakeCaret />}
</div>
)
}
function FakeCaret() {
return (
<div className="absolute pointer-events-none inset-0 flex items-center justify-center animate-caret-blink">
<div className="w-px h-8 bg-white" />
</div>
)
}
function FakeDash() {
return (
<div className="flex w-10 justify-center items-center">
<div className="w-3 h-1 rounded-full bg-border" />
</div>
)
}
const config = {
theme: {
extend: {
keyframes: {
'caret-blink': {
'0%,70%,100%': { opacity: '1' },
'20%,50%': { opacity: '0' },
},
},
animation: {
'caret-blink': 'caret-blink 1.2s ease-out infinite',
},
},
},
}
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import type { ClassValue } from 'clsx'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
How it works
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.
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).
Features
This is the most complete OTP input on the web. It's fully featured
Supports iOS + Android copy-paste-cut
https://github.com/guilhermerodz/input-otp/assets/10366880/bdbdc96a-23da-4e89-bff8-990e6a1c4c23
Automatic OTP code retrieval from transport (e.g SMS)
By default, this input uses autocomplete='one-time-code'
and it works as it's a single input.
https://github.com/guilhermerodz/input-otp/assets/10366880/5705dac6-9159-443b-9c27-b52e93c60ea8
Supports screen readers (a11y)
Stripe was my first inspiration to build this library.
Take a look at Stripe's input. The screen reader does not behave like it normally should on a normal single input.
That's because Stripe's solution is to render a 1-digit input with "clone-divs" rendering a single char per div.
https://github.com/guilhermerodz/input-otp/assets/10366880/3d127aef-147c-4f28-9f6c-57a357a802d0
So we're rendering a single input with invisible/transparent colors instead.
The screen reader now gets to read it, but there is no appearance. Feel free to build whatever UI you want:
https://github.com/guilhermerodz/input-otp/assets/10366880/718710f0-2198-418c-8fa0-46c05ae5475d
Supports all keybindings
Should be able to support all keybindings of a common text input as it's an input.
https://github.com/guilhermerodz/input-otp/assets/10366880/185985c0-af64-48eb-92f9-2e59be9eb78f
Automatically optimizes for password managers
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.
- This feature is optional and it's enabled by default. You can disable this optimization by adding
pushPasswordManagerStrategy="none"
. - This feature does not cause visible layout shift.
Auto tracks if the input has space in the right side for the badge
https://github.com/guilhermerodz/input-otp/assets/10366880/bf01af88-1f82-463e-adf4-54a737a92f59
API Reference
OTPInput
The root container. Define settings for the input via props. Then, use the render
prop to create the slots.
Props
type OTPInputProps = {
maxLength: number
render: (props: RenderProps) => React.ReactElement
containerClassName?: string
value?: string
onChange?: (newValue: string) => unknown
onComplete?: (...args: any[]) => unknown
textAlign?: 'left' | 'center' | 'right'
inputMode?: 'numeric' | 'text' | 'decimal' | 'tel' | 'search' | 'email' | 'url'
pattern?: string
placeholder?: string
pasteTransformer?: (pastedText: string) => string
pushPasswordManagerStrategy?:
| 'increase-width'
| 'none'
noScriptCSSFallback?: string | null
}
Examples
Automatic form submission on OTP completion
export default function Page() {
const formRef = useRef<HTMLFormElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
return (
<form ref={formRef}>
<OTPInput
// ... automatically submit the form
onComplete={() => formRef.current?.submit()}
// ... or focus the button like as you wish
onComplete={() => buttonRef.current?.focus()}
/>
<button ref={buttonRef}>Submit</button>
</form>
)
}
Automatically focus the input when the page loads
export default function Page() {
return (
<form ref={formRef}>
<OTPInput
autoFocus
// Pro tip: accepts all common HTML input props...
/>
</form>
)
}
Usage with react-hook-form
Just use it as a regular text input:
const { register, handleSubmit } = useForm();
<InputOTP {...register("otp")} />
You can also use react-hook-form's Controller if needed:
const { control } = useForm();
<Controller
name="customOTP"
control={control}
defaultValue=""
render={({ field }) => (
<OTPInput
{...field}
label="Custom OTP"
/>
)}
/>
Caveats
[Workaround] If you want to block specific password manager/badges:
By default, input-otp
handles password managers for you.
The password manager badges should be automatically shifted to the right side.
However, if you still want to block password managers, please disable the pushPasswordManagerStrategy
and then manually block each PWM.
<OTPInput
// First, disable library's built-in strategy
// for shifting badges automatically
- pushPasswordManagerStrategy="increase-width"
+ pushPasswordManagerStrategy="none"
// Then, manually add specifics attributes
// your password manager docs
// Example: block LastPass
+ data-lpignore="true"
// Example: block 1Password
+ data-1p-ignore="true"
/>
[Setting] If you want to customize the `noscript` CSS fallback
By default, input-otp
handles cases where JS is not in the page by applying custom CSS styles.
If you do not like the fallback design and want to apply it to your own, just pass a prop:
// This is the default CSS fallback.
// Feel free to change it entirely and apply to your design system.
const NOSCRIPT_CSS_FALLBACK = `
[data-input-otp] {
--nojs-bg: white !important;
--nojs-fg: black !important;
background-color: var(--nojs-bg) !important;
color: var(--nojs-fg) !important;
caret-color: var(--nojs-fg) !important;
letter-spacing: .25em !important;
text-align: center !important;
border: 1px solid var(--nojs-fg) !important;
border-radius: 4px !important;
width: 100% !important;
}
@media (prefers-color-scheme: dark) {
[data-input-otp] {
--nojs-bg: black !important;
--nojs-fg: white !important;
}
}`
<OTPInput
// Pass your own custom styles for when JS is disabled
+ noScriptCSSFallback={NOSCRIPT_CSS_FALLBACK}
/>
[Workaround] If you're experiencing an unwanted border on input focus:
<OTPInput
// Add class to the input itself
+ className="focus-visible:ring-0"
// Not the container
containerClassName="..."
/>
[Not Recommended] If you want to centralize input text/selection, use the `textAlign` prop:
<OTPInput
// customizable but not recommended
+ textAlign="center"
/>
NOTE: this also affects the selected caret position after a touch/click.
textAlign="left"
textAlign="center"
textAlign="right"
If you want to use Context props:
+import { OTPInputContext } from 'input-otp'
function MyForm() {
return (
<OTPInput
- // First remove the `render` prop
- render={...}
>
<OTPInputWrapper />
</OTPInput>
)
}
+function OTPInputWrapper() {
+ const inputContext = React.useContext(OTPInputContext)
+ return (
+ <>
+ {inputContext.slots.map((slot, idx) => (
+ <Slot key={idx} {...slot} />
+ ))}
+ </>
+ )
+}
NOTE: this also affects the selected caret position after a touch/click.
textAlign="left"
textAlign="center"
textAlign="right"
[DX] Add Tailwind autocomplete for `containerClassname` attribute in VS Code.
Add the following setting to your .vscode/settings.json
:
{
"tailwindCSS.classAttributes": [
"class",
"className",
+ ".*ClassName"
]
}