Back to Radix Ui

One-Time Password Field

data/primitives/docs/components/one-time-password-field.mdx

latest12.3 KB
Original Source

One-Time Password Field

<Description> A group of single-character text inputs to handle one-time password verification. </Description> <HeroContainer> <OneTimePasswordFieldDemo /> </HeroContainer> <HeroCodeBlock folder="OneTimePasswordField" />

<Highlights features={[ "Keyboard navigation mimicking the behavior of a single input field", "Overriding values on paste", "Password manager autofill support", "Input validation for numeric and alphanumeric values", "Auto-submit on completion", "Hidden input to provide a single value to form data", ]} />

Anatomy

Import all parts and piece them together.

jsx
import { unstable_OneTimePasswordField as OneTimePasswordField } from "radix-ui";

export default () => (
	<OneTimePasswordField.Root>
		<OneTimePasswordField.Input />
		<OneTimePasswordField.HiddenInput />
	</OneTimePasswordField.Root>
);

API Reference

Root

Contains all the parts of a one-time password field.

<PropsTable data={[ { name: "asChild", required: false, type: "boolean", default: "false", description: ( <> Change the default rendered element for the one passed as a child, merging their props and behavior.

				Read our <a href="../guides/composition">Composition</a> guide for
				more details.
			</>
		),
	},
	{
		name: "autoComplete",
		required: false,
		type: '"off" | "one-time-code"',
		typeSimple: "enum",
		default: "one-time-code",
		description: (
			<span>
				Specifies what—if any—permission the user agent has to provide
				automated assistance in filling out form field values, as well as
				guidance to the browser as to the type of information expected in the
				field.
			</span>
		),
	},
	{
		name: "autoFocus",
		required: false,
		type: "boolean",
		description: (
			<span>
				Whether or not the first fillable input should be focused on
				page-load.
			</span>
		),
	},
	{
		name: "value",
		required: false,
		type: "string",
		description: (
			<span>
				The controlled value of the field. Must be used in conjunction with{" "}
				<Code>onValueChange</Code>.
			</span>
		),
	},
	{
		name: "defaultValue",
		required: false,
		type: "string",
		description: (
			<span>
				The value of the field when initially rendered. Use when you do not
				need to control the state of the field.
			</span>
		),
	},
	{
		name: "onValueChange",
		required: false,
		type: "(value: string) => void",
		typeSimple: "function",
		description: (
			<span>Event handler called when the value of the field changes.</span>
		),
	},
	{
		name: "autoSubmit",
		required: false,
		default: "false",
		type: "boolean",
		description: (
			<span>
				Whether the component should attempt to automatically submit when all
				fields are filled. If the field is associated with an HTML{" "}
				<Code>form</Code> element, the form's <Code>requestSubmit</Code>{" "}
				method will be called.
			</span>
		),
	},
	{
		name: "onAutoSubmit",
		required: false,
		type: "(value: string) => void",
		typeSimple: "function",
		description: (
			<span>
				When the <Code>autoSubmit</Code> prop is set to <Code>true</Code>,
				this callback will be fired before attempting to submit the associated
				form. It will be called whether or not a form is located, or if
				submission is not allowed. No-op when <Code>autoSubmit</Code> is set
				to <Code>false</Code>.
			</span>
		),
	},
	{
		name: "disabled",
		required: false,
		type: "boolean",
		default: "false",
		description: (
			<span>Whether or not the the field's input elements are disabled.</span>
		),
	},
	{
		name: "dir",
		required: false,
		type: '"ltr" | "rtl"',
		typeSimple: "enum",
		default: '"ltr"',
		description:
			"The reading direction of the field when applicable. If omitted, assumes LTR (left-to-right) reading mode.",
	},
	{
		name: "orientation",
		required: false,
		type: '"horizontal" | "vertical"',
		typeSimple: "enum",
		default: '"vertical"',
		description: "The vertical orientation of the input elements.",
	},
	{
		name: "form",
		required: false,
		type: "string",
		description: (
			<span>
				A string specifying the <Code>form</Code> element with which the input
				is associated. This string's value, if present, must match the ID of a{" "}
				<Code>form</Code> element in the same document.
			</span>
		),
	},
	{
		name: "name",
		required: false,
		type: "string",
		description:
			"A string specifying a name for the input control. This name is submitted along with the control's value when the form data is submitted.",
	},
	{
		name: "placeholder",
		required: false,
		type: "string",
		description:
			"Defines the text displayed in a form control when the control has no value. Split into single-character placeholders for each Input rendered.",
	},
	{
		name: "readOnly",
		required: false,
		type: "boolean",
		default: "false",
		description:
			"Whether or not the input elements can be updated by the user.",
	},
	{
		name: "sanitizeValue",
		required: false,
		type: "(value: string) => string",
		typeSimple: "function",
		description: (
			<span>
				Function for custom sanitization when <Code>validationType</Code> is
				set to <Code>"none"</Code>. This function will be called before
				updating values in response to user interactions.
			</span>
		),
	},
	{
		name: "type",
		required: false,
		type: '"text" | "password"',
		typeSimple: "enum",
		default: '"text"',
		description: "The input type of the field's input elements.",
	},
	{
		name: "validationType",
		required: false,
		type: '"none" | "numeric" | "alpha" | "alphanumeric"',
		typeSimple: "enum",
		default: '"numeric"',
		description: "Specifies the type of input validation to be used.",
	},
]}

/>

<DataAttributesTable data={[ { attribute: "[data-orientation]", values: ["vertical", "horizontal"], }, ]} />

Input

Renders a text input representing a single character in the value.

<PropsTable data={[ { name: "asChild", required: false, type: "boolean", default: "false", description: ( <> Change the default rendered element for the one passed as a child, merging their props and behavior.

				Read our <a href="../guides/composition">Composition</a> guide for
				more details.
			</>
		),
	},
]}

/>

<DataAttributesTable data={[ { attribute: "[data-index]", values: "The index corresponding with the index of the character relative to the root field value", }, ]} />

HiddenInput

<PropsTable data={[ { name: "asChild", required: false, type: "boolean", default: "false", description: ( <> Change the default rendered element for the one passed as a child, merging their props and behavior.

				Read our <a href="../guides/composition">Composition</a> guide for
				more details.
			</>
		),
	},
]}

/>

Examples

Basic usage

jsx
// This will render a field with 6 inputs, for use with
// 6-character passwords. Render an Input component for
// each character of accepted password's length.
<OneTimePasswordField.Root>
	<OneTimePasswordField.Input />
	<OneTimePasswordField.Input />
	<OneTimePasswordField.Input />
	<OneTimePasswordField.Input />
	<OneTimePasswordField.Input />
	<OneTimePasswordField.Input />
	<OneTimePasswordField.HiddenInput />
</OneTimePasswordField.Root>

Segmented controls

The Root component accepts arbitrary children, so rendering a visually segmented list is as simple as rendering separators between inputs. We recommend hiding decorative elements from assistive tech with aria-hidden and avoid rendering other meaningful content within Root since each child element is expected to belong to the parent with the group role.

jsx
<OneTimePasswordField.Root>
	<OneTimePasswordField.Input />
	<Separator.Root aria-hidden />
	<OneTimePasswordField.Input />
	<Separator.Root aria-hidden />
	<OneTimePasswordField.Input />
	<Separator.Root aria-hidden />
	<OneTimePasswordField.Input />
	<OneTimePasswordField.HiddenInput />
</OneTimePasswordField.Root>

Auto-submit form when password is entered

Use the autoSubmit prop to submit an associated form when all inputs are filled.

jsx
function Verify({ validCode }) {
	const PASSWORD_LENGTH = 6;
	function handleSubmit(event) {
		event.preventDefault();
		const formData = event.formData;
		if (formData.get("otp") === validCode) {
			redirect("/authenticated");
		} else {
			window.alert("Invalid code");
		}
	}
	return (
		<form onSubmit={handleSubmit}>
			<OneTimePasswordField.Root name="otp" autoSubmit>
				{PASSWORD_LENGTH.map((_, i) => (
					<OneTimePasswordField.Input key={i} />
				))}
				<OneTimePasswordField.HiddenInput />
			</OneTimePasswordField.Root>
			<button>Submit</button>
		</form>
	);
}

Controlled value

jsx
function Verify({ validCode }) {
	const [value, setValue] = React.useState("");
	const PASSWORD_LENGTH = 6;
	function handleSubmit() {
		if (value === validCode) {
			redirect("/authenticated");
		} else {
			window.alert("Invalid code");
		}
	}
	return (
		<OneTimePasswordField.Root
			autoSubmit
			value={value}
			onAutoSubmit={handleSubmit}
			onValueChange={setValue}
		>
			{PASSWORD_LENGTH.map((_, i) => (
				<OneTimePasswordField.Input key={i} />
			))}
		</OneTimePasswordField.Root>
	);
}

Accessibility

At the time of writing, there is no singular established pattern in WCAG guidelines for implementing one-time password fields as separate inputs. The behavior aims to get as close as possible to having the field act as a single input, with a few exceptions to match user expectations based on our initial research, testing, and gathering feedback.

This component is implemented as input elements within a container with a role of group to indicate that child inputs are related. Inputs can be navigated and focused using direction keys, and typing input will move focus to the next input until the last input is reached.

Pasting a value into the field will replace the contents of all inputs, regardless of the currently focused input. Based on our research this seems to align with most user expectations, where values are often pasted from password-managers or an email.

Keyboard Interactions

<KeyboardTable data={[ { keys: ["Enter"], description: ( <span> Attempts to submit an associated <Code>form</Code> if one is found </span> ), }, { keys: ["Tab"], description: ( <span> Moves focus to the next focusable element outside of the{" "} <Code>Root</Code> </span> ), }, { keys: ["Shift + Tab"], description: ( <span> Moves focus to the previous focusable element outside of the{" "} <Code>Root</Code> </span> ), }, { keys: ["ArrowDown"], description: ( <span> Moves focus to the next <Code>Input</Code> when{" "} <Code>orientation</Code> is <Code>vertical</Code>. </span> ), }, { keys: ["ArrowUp"], description: ( <span> Moves focus to the previous <Code>Input</Code> when{" "} <Code>orientation</Code> is <Code>vertical</Code>. </span> ), }, { keys: ["ArrowRight"], description: ( <span> Moves focus to the next <Code>Input</Code> when{" "} <Code>orientation</Code> is <Code>horizontal</Code>. </span> ), }, { keys: ["ArrowLeft"], description: ( <span> Moves focus to the previous <Code>Input</Code> when{" "} <Code>orientation</Code> is <Code>horizontal</Code>. </span> ), }, { keys: ["Home"], description: ( <span> Moves focus to the first <Code>Input</Code>. </span> ), }, { keys: ["End"], description: ( <span> Moves focus to the last <Code>Input</Code>. </span> ), }, { keys: ["Delete"], description: ( <span> Removes the character in the currently focused <Code>Input</Code> and shifts later values back </span> ), }, { keys: ["Backspace"], description: ( <span> Removes the character in the currently focused <Code>Input</Code> and moves focus to the previous <Code>Input</Code> </span> ), }, { keys: ["Command + Backspace"], description: ( <span> Clears the value of all <Code>Input</Code> elements </span> ), }, ]} />