A group of single-character text inputs to handle one-time password verification.
<OneTimePasswordField.root id="otp-primitive" class="DemoOTPRoot" length={6} name="otp">
<OneTimePasswordField.input class="DemoOTPInput" index={0} />
<OneTimePasswordField.input class="DemoOTPInput" index={1} />
<OneTimePasswordField.input class="DemoOTPInput" index={2} />
<OneTimePasswordField.input class="DemoOTPInput" index={3} />
<OneTimePasswordField.input class="DemoOTPInput" index={4} />
<OneTimePasswordField.input class="DemoOTPInput" index={5} />
</OneTimePasswordField.root>
.DemoOTPRoot {
display: flex;
gap: 0.5rem;
flex-wrap: nowrap;
}
.DemoOTPInput {
all: unset;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
border-radius: 4px;
font-size: 15px;
color: #fff;
background-color: var(--black-a2);
box-shadow: 0 0 0 1px var(--black-a6);
padding: 0;
height: 35px;
width: 24px;
line-height: 1;
&:hover {
box-shadow: 0 0 0 1px black;
}
&:focus {
box-shadow: 0 0 0 2px black;
}
&::selection {
background-color: var(--black-a6);
color: white;
}
}
.DemoOTPSeparator {
width: 1px;
height: 20px;
background-color: var(--black-a6);
align-self: center;
}
Anatomy
Import all parts and piece them together.
<OneTimePasswordField.root id="…">
<OneTimePasswordField.input index={0} />
<OneTimePasswordField.input index={1} />
…
</OneTimePasswordField.root>
The root always renders a hidden input for form submission. When no slot content is provided, the root auto-generates one input per character based on length.
Anatomy
-
Root - Contains all the parts of a one-time password field.
-
Input - Renders a text input representing a single character in the value.
-
Hidden Input - A visually hidden input that holds the combined value for form submission. Rendered automatically by `root`.
API Reference
Root
Contains all the parts of a one-time password field.
Use value with on_value_change for controlled state in LiveView:
<OneTimePasswordField.root
id="otp"
value={@otp}
on_value_change="otp_change"
length={6}
>
…
</OneTimePasswordField.root>
def handle_event("otp_change", %{"value" => value}, socket) do
{:noreply, assign(socket, otp: value)}
end
When all inputs are filled, the hook fires on_complete (if set) and optionally submits the associated form when auto_submit is true.
| Prop | Type | Default | Description |
|---|---|---|---|
auto_focus
|
boolean
|
false
|
|
auto_submit
|
boolean
|
false
|
|
default_value
|
string
|
""
|
|
disabled
|
boolean
|
false
|
|
form
|
string
|
nil
|
|
id
|
string
|
—
|
|
length
|
integer
|
nil
|
|
max_length
|
integer
|
6
|
|
name
|
string
|
nil
|
|
on_complete
|
string
|
nil
|
|
on_value_change
|
string
|
nil
|
|
orientation
|
string
|
"horizontal"
|
|
placeholder
|
string
|
nil
|
|
read_only
|
boolean
|
false
|
|
validation_type
|
string
|
"numeric"
|
|
value
|
string
|
nil
|
auto_focus
boolean
Default
false
auto_submit
boolean
Default
false
default_value
string
Default
""
disabled
boolean
Default
false
form
string
Default
nil
id
string
Default
—
length
integer
Default
nil
max_length
integer
Default
6
name
string
Default
nil
on_complete
string
Default
nil
on_value_change
string
Default
nil
orientation
string
Default
"horizontal"
placeholder
string
Default
nil
read_only
boolean
Default
false
validation_type
string
Default
"numeric"
value
string
Default
nil
Data attributes
| Attribute | Values | Description |
|---|---|---|
[data-orientation]
|
horizontal | vertical
|
The orientation of the input elements. |
Input
Renders a text input representing a single character in the value. Each input must have a unique index (0-based).
| Prop | Type | Default | Description |
|---|---|---|---|
autofocus
|
boolean
|
false
|
|
disabled
|
boolean
|
false
|
|
id
|
string
|
nil
|
|
index
|
integer
|
—
|
|
placeholder
|
string
|
nil
|
|
read_only
|
boolean
|
false
|
|
validation_type
|
string
|
"numeric"
|
|
value
|
string
|
nil
|
autofocus
boolean
Default
false
disabled
boolean
Default
false
id
string
Default
nil
index
integer
Default
—
placeholder
string
Default
nil
read_only
boolean
Default
false
validation_type
string
Default
"numeric"
value
string
Default
nil
Data attributes
| Attribute | Values | Description |
|---|---|---|
[data-index]
|
The index of this character in the combined value
|
Present on each input. |
Hidden Input
A hidden input that stores the combined OTP value for form submission. The root part renders this automatically—you do not need to add it manually unless you are building a fully custom layout outside root.
| Prop | Type | Default | Description |
|---|---|---|---|
disabled
|
boolean
|
false
|
|
form
|
string
|
nil
|
|
name
|
string
|
nil
|
|
value
|
string
|
""
|
disabled
boolean
Default
false
form
string
Default
nil
name
string
Default
nil
value
string
Default
""
Examples
Basic usage
Render one input per character, or omit slot content and let the root auto-generate inputs from length:
<OneTimePasswordField.root id="otp-basic" length={6} name="otp" />
For explicit control over each input:
<OneTimePasswordField.root id="otp-explicit" length={6} name="otp">
<OneTimePasswordField.input index={0} />
<OneTimePasswordField.input index={1} />
<OneTimePasswordField.input index={2} />
<OneTimePasswordField.input index={3} />
<OneTimePasswordField.input index={4} />
<OneTimePasswordField.input index={5} />
</OneTimePasswordField.root>
Segmented controls
The root part accepts arbitrary children, so rendering a visually segmented list is as simple as placing separators between inputs. Hide 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.
<OneTimePasswordField.root id="otp-segmented" length={4} name="otp" class="DemoOTPRoot">
<OneTimePasswordField.input class="DemoOTPInput" index={0} />
<Separator.separator class="DemoOTPSeparator" decorative aria-hidden="true" />
<OneTimePasswordField.input class="DemoOTPInput" index={1} />
<Separator.separator class="DemoOTPSeparator" decorative aria-hidden="true" />
<OneTimePasswordField.input class="DemoOTPInput" index={2} />
<Separator.separator class="DemoOTPSeparator" decorative aria-hidden="true" />
<OneTimePasswordField.input class="DemoOTPInput" index={3} />
</OneTimePasswordField.root>
Auto-submit form when password is entered
Use the auto_submit attribute to submit an associated form when all inputs are filled:
<form id="verify-form" phx-submit="verify">
<OneTimePasswordField.root id="otp-auto" name="otp" length={6} auto_submit={true}>
<OneTimePasswordField.input index={0} />
<OneTimePasswordField.input index={1} />
<OneTimePasswordField.input index={2} />
<OneTimePasswordField.input index={3} />
<OneTimePasswordField.input index={4} />
<OneTimePasswordField.input index={5} />
</OneTimePasswordField.root>
<button type="submit">Submit</button>
</form>
def handle_event("verify", %{"otp" => code}, socket) do
if code == socket.assigns.valid_code do
{:noreply, push_navigate(socket, to: "/authenticated")}
else
{:noreply, put_flash(socket, :error, "Invalid code")}
end
end
Controlled value
Use value and on_complete together for controlled verification:
<OneTimePasswordField.root
id="otp-controlled"
value={@otp}
on_value_change="otp_change"
on_complete="otp_complete"
auto_submit={true}
length={6}
>
<OneTimePasswordField.input index={0} />
<OneTimePasswordField.input index={1} />
<OneTimePasswordField.input index={2} />
<OneTimePasswordField.input index={3} />
<OneTimePasswordField.input index={4} />
<OneTimePasswordField.input index={5} />
</OneTimePasswordField.root>
def handle_event("otp_change", %{"value" => value}, socket) do
{:noreply, assign(socket, otp: value)}
end
def handle_event("otp_complete", %{"value" => value}, socket) do
if value == socket.assigns.valid_code do
{:noreply, push_navigate(socket, to: "/authenticated")}
else
{:noreply, put_flash(socket, :error, "Invalid code")}
end
end
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 initial research, testing, and 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 research this seems to align with most user expectations, where values are often pasted from password managers or an email.
Keyboard Interactions
Keyboard Interactions
| Key | Description |
|---|---|
Enter
|
Attempts to submit an associated form if one is found. |
Tab
|
Moves focus to the next focusable element outside of the root. |
Shift + Tab
|
Moves focus to the previous focusable element outside of the root. |
ArrowDown
|
Moves focus to the next input when `orientation` is `vertical`. |
ArrowUp
|
Moves focus to the previous input when `orientation` is `vertical`. |
ArrowRight
|
Moves focus to the next input when `orientation` is `horizontal`. |
ArrowLeft
|
Moves focus to the previous input when `orientation` is `horizontal`. |
Home
|
Moves focus to the first input. |
End
|
Moves focus to the last input. |
Delete
|
Removes the character in the currently focused input and shifts later values back. |
Backspace
|
Removes the character in the currently focused input and moves focus to the previous input. |
Command + Backspace
|
Clears the value of all inputs. |