CleanShot.2024-02-19.at.14.38.54-converted.mp4
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>
}
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>
</>
)}
/>
// Feel free to copy. Uses @shadcn/ui tailwind colors.
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 },
)}
>
{props.char !== null && <div>{props.char}</div>}
{props.hasFakeCaret && <FakeCaret />}
</div>
)
}
// You can emulate a fake textbox caret!
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>
)
}
// Inspired by Stripe's MFA input.
function FakeDash() {
return (
<div className="flex w-10 justify-center items-center">
<div className="w-3 h-1 rounded-full bg-border" />
</div>
)
}
// tailwind.config.ts for the blinking caret animation.
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',
},
},
},
}
// Small utility to merge class names.
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import type { ClassValue } from 'clsx'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
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).
Supports iOS + Android copy-paste-cut
twittervid.com_guilherme_rodz_36bebe.mp4
Automatic OTP code retrieval from transport (e.g SMS)
By default, this input uses autocomplete='one-timecode'
and it works as it's a single input.
twittervid.com_guilherme_rodz_6fafe6.mp4
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.
twittervid.com_guilherme_rodz_aeb513.mp4
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:
twittervid.com_guilherme_rodz_dfce3c.mp4
Supports all keybindings
Should be able to support all keybindings of a common text input as it's an input.
twittervid.com_guilherme_rodz_640d87.mp4
The root container. Define settings for the input via props. Then, use the render
prop to create the slots.
type OTPInputProps = {
// The number of slots
maxLength: number
// Render function creating the slots
render: (props: RenderProps) => React.ReactElement
// The class name for the root container
containerClassName?: string
// Value state controlling the input
value?: string
// Setter for the controlled value (or callback for uncontrolled value)
onChange?: (newValue: string) => unknown
// Callback when the input is complete
onComplete?: (...args: any[]) => unknown
// Where is the text located within the input
// Affects click-holding or long-press behavior
// Default: 'left'
textAlign?: 'left' | 'center' | 'right'
// Virtual keyboard appearance on mobile
// Default: 'numeric'
inputMode?: 'numeric' | 'text'
}
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>
)
}
If you're using experiencing an unwanted border on input focus:
<OTPInput
// Add class to the input itself
+ className="focus-visible:ring-0"
// Not the container
containerClassName="..."
/>
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
Add Tailwind autocomplete for `containerClassname` attribute in VS Code.
Add the following setting to your .vscode/settings.json
:
{
"tailwindCSS.classAttributes": [
"class",
"className",
+ ".*ClassName"
]
}