OTP Input
A segmented verification-code input — a single invisible input sits over the visual cells, with paste support, an error shake, and a success ripple that turns the cells green.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add otp-input.enOTP Input is a React one-time password and SMS verification code input built with Tailwind CSS and Motion. The cells are purely visual; underneath sits a single native input, so pasting, autofill and deleting all follow normal browser behaviour.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<OTPInput/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- The verification-code step of a sign-in or sign-up flow (SMS or email)
- Two-factor authentication, entering the 6-digit code from an authenticator app
- A confirmation step before a payment or password change
- Alphanumeric invite or redemption codes (with
type="alphanumeric") - Not a good fit: promo codes of variable length; use Coupon Code Input instead
Installation
npx webberui@latest add https://webberui.com/r/otp-input.en.jsonThe install also writes the --wb-duration-fast and --wb-ease-out CSS variables into your global stylesheet.
Usage
import { OTPInput } from "@/components/ui/otp-input";
<OTPInput
onComplete={(code) => verify(code)}
error={isError}
success={isSuccess}
/>When value is omitted, the component manages it internally; when you need to clear and start over after a failed verification, use controlled mode instead (value + onChange).
Examples
Clear and retry after a failed check
Controlled mode wired to an API: the cells turn green on success, and on failure the row shakes, then clears after 0.6 seconds so the user can try again.
"use client";
import * as React from "react";
import { OTPInput } from "@/components/ui/otp-input";
export function VerifyCodeForm() {
const [code, setCode] = React.useState("");
const [error, setError] = React.useState(false);
const [success, setSuccess] = React.useState(false);
async function handleComplete(value: string) {
const res = await fetch("/api/verify-code", {
method: "POST",
body: JSON.stringify({ code: value }),
});
if (res.ok) {
setSuccess(true);
return;
}
setError(true);
setTimeout(() => {
setCode("");
setError(false);
}, 600);
}
return (
<OTPInput
value={code}
onChange={(next) => {
setCode(next);
setError(false);
}}
onComplete={handleComplete}
error={error}
success={success}
/>
);
}8-character alphanumeric invite code
type="alphanumeric" strips symbols and upper-cases input; length sets 8 cells.
<OTPInput
length={8}
type="alphanumeric"
onComplete={(code) => redeemInvite(code)}
/>Disable while submitting
Pass disabled while the verification request is in flight to prevent double submits; className applies to the outer container, which is handy for alignment.
<OTPInput
length={4}
disabled={isVerifying}
onComplete={(code) => startVerify(code)}
className="mx-auto"
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
length | number | 6 | Number of cells |
value | string | — | Controlled value; when omitted, the component manages it internally |
onChange | (value: string) => void | — | Fires when the value changes |
onComplete | (code: string) => void | — | Fires once length characters have been entered, returning the complete code |
error | boolean | false | Error state: the whole row shakes on the x axis and the borders turn red |
success | boolean | false | Success state: the cells ripple green in sequence |
type | 'numeric' | 'alphanumeric' | 'numeric' | Allowed character type; alphanumeric upper-cases automatically |
disabled | boolean | false | Disables input |
Accessibility
It uses a "single invisible input + visual cells" pattern rather than N separate inputs:
- Screen readers only ever see one standard text field (
aria-label="Verification code"), with no jumping between six fields; the visual cells are allaria-hidden autocomplete="one-time-code": iOS/Android can autofill the code in one tap when the SMS arrives- Pasting a complete code works directly, with no need to distribute it cell by cell
- Keyboard behaviour is entirely native input behaviour: Backspace deletes, and
inputMode="numeric"brings up the numeric keypad on mobile - On
errorit setsaria-invalid, so assistive technology can tell the field's state
How it works
- The active cell shows a breathing caret and a focus ring, and characters pop into the cell from scale 0.5 → 1
- The success ripple staggers by 60ms per cell, turning the border and text green in sequence
- When the user has "reduce motion" enabled at the system level, the shake and pop animations are disabled and characters and states appear directly
FAQ
Does it work with the Next.js App Router?
Yes. The file already starts with "use client", so you can import it from a Server Component page as usual.
Does it support SMS autofill on phones?
Yes. The hidden input sets autocomplete="one-time-code", so iOS and Android can fill the code in one tap when the SMS arrives. Pasting a full code also fills every cell at once, with non-digit characters filtered out.
Does onComplete fire in controlled mode?
Yes. It fires whenever the value's length goes from below length to exactly length, whether the user typed it or you filled value from outside.
How do I change the cell colours and size?
The cell size (h-12 w-10), borders and error/success colours are Tailwind classes in the source, so edit otp-input.tsx directly. className only applies to the outer container.
Does it respect reduced motion?
Yes. When the system "reduce motion" setting is on, the error shake, the character pop and the success ripple scale are disabled, and the caret stops blinking.
Smooth Caret Input
An input with a spring-physics smooth caret — the custom caret glides to the text position on a spring, in bar, block, or underline shapes.
Tag Input
A tag input — Enter or comma adds a pill chip (trimmed and deduplicated), Backspace on an empty input deletes the last one; chips spring in, and the rest close ranks with FLIP on removal, with no accidental adds mid-IME-composition.