Qure UI
Components

OTP Field

A fixed-length code, one character per box, with paste and autofill handled.

'use client'import { OtpField, OtpFieldInput } from '@/registry/qure/ui/otp-field'export default function OtpFieldDemo() {  return (    <OtpField length={6} aria-label="Six-digit sign-in code">      {Array.from({ length: 6 }, (_, i) => (        <OtpFieldInput key={i} />      ))}    </OtpField>  )}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/otp-field.json

Usage

import { OtpField, OtpFieldInput, OtpFieldSeparator } from '@/components/ui/otp-field'
<OtpField length={6} aria-label="Six-digit sign-in code">
  {Array.from({ length: 6 }, (_, i) => <OtpFieldInput key={i} />)}
</OtpField>

Use it for a code the user is copying from somewhere else — an SMS sign-in code, a release code on a report, an override PIN. For anything the user composes themselves, boxes are worse than a single Input: they break selection, they break the caret, and they make a typo halfway through expensive to fix.

Composition

<OtpField length={6}>   {/* owns the value; length is required, not counted */}
  <OtpFieldInput />     {/* one character; its index is its position */}
  <OtpFieldSeparator /> {/* role=separator, not typed into */}
</OtpField>

length is required and is not inferred from the children. The root has to know it before the slots hydrate so that a pasted code can be clamped and completion detected against the server-rendered markup. If it disagrees with the number of OtpFieldInputs you rendered, the root wins.

Each input takes its index from its position in the DOM, so there is no index prop to pass and no way to get it wrong.

Grouped

'use client'import { OtpField, OtpFieldInput, OtpFieldSeparator } from '@/registry/qure/ui/otp-field'export default function OtpFieldGrouped() {  return (    <OtpField length={6} size="sm" aria-label="Study release code">      <OtpFieldInput />      <OtpFieldInput />      <OtpFieldInput />      <OtpFieldSeparator />      <OtpFieldInput />      <OtpFieldInput />      <OtpFieldInput />    </OtpField>  )}

A separator after every third box makes a six-digit code readable in one glance instead of two. It is announced as a separator rather than skipped, and it is not in the tab order.

Letters as well as digits

'use client'import { OtpField, OtpFieldInput } from '@/registry/qure/ui/otp-field'export default function OtpFieldAlphanumeric() {  return (    <OtpField      length={8}      size="sm"      validationType="alphanumeric"      normalizeValue={(value) => value.toUpperCase()}      defaultValue="MRN7"      aria-label="Patient access code"    >      {Array.from({ length: 8 }, (_, i) => (        <OtpFieldInput key={i} />      ))}    </OtpField>  )}

validationType is 'numeric' by default and also picks the virtual keyboard — 'alphanumeric' gives a text keyboard on mobile, 'none' accepts anything. normalizeValue runs on every value the field sees, including the default and any pasted text, which is what makes an upper-casing normaliser safe: it has to be idempotent, and casing is.

Masked

Four-digit override PIN.

'use client'import * as React from 'react'import { OtpField, OtpFieldInput } from '@/registry/qure/ui/otp-field'export default function OtpFieldMasked() {  const [complete, setComplete] = React.useState(false)  return (    <div className="flex flex-col items-center gap-3">      <OtpField        length={4}        mask        aria-label="Override PIN"        onValueComplete={() => setComplete(true)}        onValueChange={() => setComplete(false)}      >        {Array.from({ length: 4 }, (_, i) => (          <OtpFieldInput key={i} />        ))}      </OtpField>      <p className="text-sm text-muted-foreground">        {complete ? 'PIN entered — release the study to the referrer.' : 'Four-digit override PIN.'}      </p>    </div>  )}

mask hides the characters. onValueComplete fires when the last slot fills, including on a paste that completes it — which is where you kick off verification rather than waiting for a submit button nobody is going to press.

Inside a Field

Texted to the number on file when the report was signed.

'use client'import { Field, FieldDescription, FieldError, FieldLabel } from '@/registry/qure/ui/field'import { OtpField, OtpFieldInput } from '@/registry/qure/ui/otp-field'export default function OtpFieldField() {  return (    <Field      name="release-code"      className="max-w-sm"      validationMode="onChange"      validate={(value) =>        String(value ?? '').length === 6 ? null : 'Enter all six digits of the release code.'      }    >      <FieldLabel>Release code</FieldLabel>      <OtpField length={6}>        {Array.from({ length: 6 }, (_, i) => (          <OtpFieldInput key={i} />        ))}      </OtpField>      <FieldDescription>Texted to the number on file when the report was signed.</FieldDescription>      <FieldError />    </Field>  )}

The root exposes data-complete once every slot is filled; the stylesheet uses it for a success boundary, and Field supplies the label, description and error.

Keyboard

Typing advances, backspace retreats, arrows move between slots, and a paste anywhere fills from the first slot. Autofill from an SMS works because the root sets autocomplete="one-time-code" on the first slot.

API Reference

Everything Base UI's OTP Field accepts, plus:

Prop

Type

The ones you will reach for on the root:

Prop

Type

On this page