Components · Inputs
Input OTP
BetaEnters a one-time code during sign-in.
- Source
components/ui/input-otp.tsx - shadcn/ui Input OTP
- Figma: not linked yet (Q13)
We sent a 6-digit code to your phone ending in 34.
When to use
- To enter a short one-time code sent by SMS or email: sign-in, verifying a new phone number, confirming a payout.
- For a short numeric PIN with a fixed length.
When not to use
- For passwords or codes of unknown length: use an Input.
- For long codes people paste rather than type (an API key, a discount code): use an Input.
- For amounts, phone numbers or dates: those have their own formats and fields.
Anatomy
- 1. Label: linked to the hidden input with htmlFor
- 2. Group: slots joined into one box, one character each
- 3. Separator (optional): splits a 6-digit code into 3 + 3
Behind the slots is one real <input>. It receives typing, paste and the phone's code suggestion, and the slots draw its value.
Variants
| Layout | Use for |
|---|---|
| 6 slots in two groups of 3, with a separator | SMS and email codes. Groups make the code easier to read back and check. See the first example. |
| 4 slots in one group | Short PINs. See the states example below. |
Set maxLength to the number of slots and pattern={REGEXP_ONLY_DIGITS} (from input-otp) for numeric codes, so letters are rejected as they are typed.
States
That PIN is wrong. 2 tries left.
| State | What changes | Token |
|---|---|---|
| Empty | Slot borders | border-input |
| Active slot | Ring on the slot that receives the next character, with a blinking caret | border-ring, ring-ring/50, animate-caret-blink |
| Filled | The digit in the slot | text-sm |
| Disabled | Half opacity on the whole field. Wrap label and field in group with data-disabled="true" to dim the label too | opacity-(--opacity-disabled) |
| Invalid | Red slot borders, error text below. Set aria-invalid on the InputOTP (for assistive tech) and on each slot (for the style) | border-destructive, text-destructive-text |
Usage guidelines
- Say where the code was sent and how long it is: "We sent a 6-digit code to your phone ending in 34."
- Submit automatically when the last slot is filled (
onComplete), but keep a visible button for people who paste or use a keyboard. - Offer "Resend code" with a countdown. On a wrong code, show the error below the field and keep focus in it.
- Don't split one code across several inputs. This component is one input, so paste and the phone's code suggestion work.
Content guidelines
- Label: "Verification code" (رمز التحقق), "Payout PIN". Not "OTP"; many clients don't know the term.
- Errors say what happened and what's next: "That code has expired. We sent a new one."
- Codes use Western digits (0–9) in both languages (Q22). Arabic copy around them is natural: "أرسلنا رمزًا من 6 أرقام".
Accessibility
| Key | Does |
|---|---|
| Tab | Moves focus to the field (one tab stop for all slots) |
| 0–9 | Fills the active slot and moves to the next |
| Backspace | Deletes the last character |
| Arrow left / right | Moves the caret between slots |
| Ctrl or Cmd+V | Pastes the whole code |
- One
<input>withautocomplete="one-time-code"andinputmode="numeric", so iOS and Android offer the code from SMS. Label it withhtmlForon theInputOTPid, oraria-label. - Announced as "Verification code, edit text", then the help text from
aria-describedby. Each character is read as it is typed. - The slots are drawn from the input's value. They are not separate fields, so there is one name, one value and one error to announce.
- Give people enough time: don't expire a code in less than a few minutes, and say how long it lasts (WCAG 2.2.1).
RTL
- Codes read left to right in Arabic too, so the component renders its slots in a
dir="ltr"container. In a right-to-left page the field still sits at the start (right), but the first digit stays on the left. - Upstream, the slots reversed in RTL: a code typed as 482915 showed as 519284 from left to right. This version fixes it in the component.
- The label, help text and errors follow the page direction as usual.
Tokens used
Read from components/ui/input-otp.tsx at build time, so this list changes when the component does. Opacity modifiers such as /90 are shadcn hover and focus tints on top of the token.
| Token | Utilities | SuperCoach | Coach |
|---|---|---|---|
| --destructive | border-destructive ring-destructive/20 | red-600 | red-600 |
| --foreground | bg-foreground | neutral-950 | neutral-950 |
| --input | border-input | neutral-200 | neutral-200 |
| --ring | border-ring ring-ring/50 | brand-600 | orange-500 |
- Layer
z-(--z-raised) - Opacity
opacity-(--opacity-disabled) - Radius
rounded-s-md rounded-e-md - Shadow
shadow-xs - Type
text-sm
Props
<InputOTP>
Also takes every prop of <OTPInput>
| Prop | Type | Default |
|---|---|---|
| containerClassName? | string | — |
<InputOTPGroup>
Also takes every prop of <div>
No props of its own.
<InputOTPSlot>
Also takes every prop of <div>
| Prop | Type | Default |
|---|---|---|
| index | number | — |
<InputOTPSeparator>
Also takes every prop of <div>
No props of its own.
Replaces in coach-admin
From the read-only audit of coach-admin: 6 style values, 7 uses.
The shared OtpField component, including its hint, error and resend styles. Its help text and error map to the muted-foreground and destructive-text tokens.
Classes and selectors
.otp-field__hint2.otp-field__error1.otp-field__resend1.otp-field1
Files
src/reusableComponents/OtpField/OtpField.scss7
The inventory lists the first 12 locations of each value, so file counts are a lower bound.
SCSS variables in these styles
$otp-field-fontnot a color: spacing, size or layout$invoice-ink-secondary→--foreground-secondary$danger-text-color→--destructive-text$spacing-xsnot a color: spacing, size or layout$spacing-smnot a color: spacing, size or layout