Capture a short, fixed-length one-time passcode (OTP) — for two-factor codes,
email verification codes, magic-link codes, and similar flows. Built on top of
input-otp, it renders a single
hidden input that handles paste, autofill, and IME correctly while displaying
each character in its own styled slot.
Pair with Field.* for label/description/error wiring in forms:
Enter the 6-digit code we emailed you.
1import { Field } from "@ngrok/mantle/field";2import { OtpInput } from "@ngrok/mantle/otp-input";3 4<Field.Item name="otp">5 <Field.Label>Verification code</Field.Label>6 <Field.Control>7 <OtpInput.Root maxLength={6}>8 <OtpInput.Group>9 <OtpInput.Slot index={0} />10 <OtpInput.Slot index={1} />11 <OtpInput.Slot index={2} />12 <OtpInput.Slot index={3} />13 <OtpInput.Slot index={4} />14 <OtpInput.Slot index={5} />15 </OtpInput.Group>16 </OtpInput.Root>17 </Field.Control>18 <Field.Description>Enter the 6-digit code we emailed you.</Field.Description>19</Field.Item>;Or render the control on its own:
1import { OtpInput } from "@ngrok/mantle/otp-input";2 3<OtpInput.Root maxLength={6}>4 <OtpInput.Group>5 <OtpInput.Slot index={0} />6 <OtpInput.Slot index={1} />7 <OtpInput.Slot index={2} />8 </OtpInput.Group>9 <OtpInput.Separator />10 <OtpInput.Group>11 <OtpInput.Slot index={3} />12 <OtpInput.Slot index={4} />13 <OtpInput.Slot index={5} />14 </OtpInput.Group>15</OtpInput.Root>;Use @tanstack/react-form with zod to validate the hidden OTP input while Field.Control handles label, name, and validation ARIA.
1import { Button } from "@ngrok/mantle/button";2import { Field, toErrorMessages } from "@ngrok/mantle/field";3import { OtpInput, REGEXP_ONLY_DIGITS } from "@ngrok/mantle/otp-input";4import { useForm } from "@tanstack/react-form";5import { z } from "zod";6 7export const formSchema = z.object({8 code: z.string().length(6, "Enter the 6-digit code."),9});10function Example() {11 const defaultValues = {12 code: "",13 };14 const form = useForm({15 defaultValues,16 validators: {17 onSubmit: formSchema,18 },19 onSubmit: ({ value }) => {20 // Handle form submission here21 },22 });23 24 return (25 <form26 onSubmit={(event) => {27 event.preventDefault();28 event.stopPropagation();29 void form.handleSubmit();30 }}31 >32 <form.Field name="code">33 {(field) => (34 <Field.Item name={field.name}>35 <Field.Label>Verification code</Field.Label>36 <Field.Control>37 <OtpInput.Root38 value={field.state.value}39 onBlur={field.handleBlur}40 onChange={field.handleChange}41 maxLength={6}42 pattern={REGEXP_ONLY_DIGITS}43 >44 <OtpInput.Group>45 <OtpInput.Slot index={0} />46 <OtpInput.Slot index={1} />47 <OtpInput.Slot index={2} />48 <OtpInput.Slot index={3} />49 <OtpInput.Slot index={4} />50 <OtpInput.Slot index={5} />51 </OtpInput.Group>52 </OtpInput.Root>53 </Field.Control>54 <Field.Errors messages={toErrorMessages(field.state.meta.errors)} />55 </Field.Item>56 )}57 </form.Field>58 <Button type="submit" appearance="filled">59 Submit60 </Button>61 </form>62 );63}Render all slots in a single group with no separator.
1<OtpInput.Root maxLength={4}>2 <OtpInput.Group>3 <OtpInput.Slot index={0} />4 <OtpInput.Slot index={1} />5 <OtpInput.Slot index={2} />6 <OtpInput.Slot index={3} />7 </OtpInput.Group>8</OtpInput.Root>Restrict input to numeric characters using the pattern prop and the
REGEXP_ONLY_DIGITS helper.
1import { OtpInput, REGEXP_ONLY_DIGITS } from "@ngrok/mantle/otp-input";2 3<OtpInput.Root maxLength={6} pattern={REGEXP_ONLY_DIGITS}>4 <OtpInput.Group>5 <OtpInput.Slot index={0} />6 <OtpInput.Slot index={1} />7 <OtpInput.Slot index={2} />8 <OtpInput.Slot index={3} />9 <OtpInput.Slot index={4} />10 <OtpInput.Slot index={5} />11 </OtpInput.Group>12</OtpInput.Root>;Pass disabled to the root to disable the entire input.
1<OtpInput.Root maxLength={6} disabled>2 {/* ... */}3</OtpInput.Root>Pass validation="error", "success", or "warning" on OtpInput.Root to
recolor each group's outer borders and the active focus ring with the matching
validation hue. validation="error" also sets aria-invalid on the
underlying input so assistive tech announces the failure state.
validation may be a literal value or a function that returns a value. Since
the prop also accepts false, form-library state can short-circuit validation
styling, e.g.
validation={field.state.meta.errors.length > 0 && "error"}.
1<OtpInput.Root maxLength={6} validation="error">2 {/* ... */}3</OtpInput.Root>OtpInput.Group and OtpInput.Separator accept an asChild prop. When true,
the part renders its single child instead of its default div, forwarding all
class names, data-* attributes, and the ref onto that child. Use this when you
need a different underlying element — for example, rendering the separator as a
semantic span, or wrapping a group in a styled label.
1<OtpInput.Separator asChild>2 <span aria-hidden>·</span>3</OtpInput.Separator>Compose the parts of an OtpInput together to build your own:
OtpInput.Root├── OtpInput.Group│ └── OtpInput.Slot├── OtpInput.Separator└── OtpInput.Group └── OtpInput.SlotThe root of the OTP input. Wraps the hidden input that captures keystrokes,
paste, and autofill, and provides per-slot state to descendant OtpInput.Slot
parts via context.
All props from the underlying OTPInput component, including standard input props, plus:
Groups one or more OtpInput.Slot parts into a visually-connected segment with shared rounded corners and joined borders.
All props from div, plus:
A single character slot. Must be rendered inside an OtpInput.Root. Reads its character, active state, and fake caret position from the root via context.
All props from div, plus:
A visual separator between two OtpInput.Group segments. Renders a minus icon by default; pass children to override.
All props from div, plus:
Re-exported from input-otp for convenience: