How to Fix OTP Input Focus Order When Users Type Out of Sequence in Vue 3
Set the shouldFocusOrder prop to true and replace the focusOrder function in src/components/vue3-otp-input.vue with logic that locates the first empty input index using findIndex, ensuring the cursor always jumps to the next required digit regardless of where the user clicked.
The vue3-otp-input component by ejirocodes provides a multi-digit OTP entry field for Vue 3 applications, but the default focus behavior breaks when users click out of sequence or type into non-consecutive boxes. This guide explains how to fix OTP input focus order when users type out of sequence in Vue 3 by replacing the component's internal focus management logic with a deterministic "first empty slot" algorithm.
Understanding the Focus Order Problem in Vue 3 OTP Inputs
When building OTP inputs in Vue 3, the expected user experience is sequential: type the first digit, auto-focus moves to the second box, and so on. However, users often interact non-linearly—they might click directly into the fourth box, paste a full code, or use arrow keys to navigate.
In the vue3-otp-input repository, the default implementation relies on an activeInput ref to track which input should receive focus. When shouldFocusOrder is enabled, the component attempts to correct the focus after each keystroke, but the original logic uses a setTimeout with a hard-coded length calculation (otp.value.join("").length) that does not account for gaps in the input sequence. This causes the cursor to land on the wrong index when users skip ahead or fill boxes out of order.
How the Current Focus Logic Works in vue3-otp-input
The OTP input consists of two tightly-coupled components: Vue3OtpInput (the orchestrator) and SingleOtpInput (the individual box). Understanding their interaction is key to fixing the focus order.
Active Input Tracking
In src/components/vue3-otp-input.vue, the component maintains a reactive reference to the currently focused index:
const activeInput = ref<number>(0);
This activeInput determines which SingleOtpInput instance receives the cursor. When a user types into a box, the handleOnChange event updates the OTP array and, by default, calls focusNextInput(), which simply increments activeInput by one.
The Broken focusOrder Implementation
The existing focusOrder function attempts to handle out-of-sequence typing but relies on flawed timing logic:
// Current problematic implementation (src/components/vue3-otp-input.vue)
const focusOrder = (index: number) => {
if (!props.shouldFocusOrder) return;
setTimeout(() => {
const length = otp.value.join("").length;
activeInput.value = length;
}, 100);
};
This approach fails because length represents the total count of filled characters, not the position of the first empty slot. If a user types into box 3 while box 1 is empty, the length is 1, so activeInput becomes 1 (pointing to the second box), but the cursor should actually move to box 0 (the first empty one).
Implementing the Fix for Out-of-Sequence Typing
To fix OTP input focus order when users type out of sequence, replace the length-based logic with a deterministic "first empty slot" algorithm.
Replace focusOrder with First-Empty-Slot Logic
Update the focusOrder function in src/components/vue3-otp-input.vue to locate the first unfilled index using findIndex:
const focusOrder = (currentIndex: number) => {
if (!props.shouldFocusOrder) return;
// Locate the first empty cell; if all filled, target the last input
const firstEmpty = otp.value.findIndex((char) => !char);
const target = firstEmpty === -1 ? props.numInputs - 1 : firstEmpty;
// Update the reactive focus index
activeInput.value = target;
// Optional: clear the out-of-order value to prevent duplicates
if (currentIndex !== target) {
otp.value[currentIndex] = "";
}
};
Why this works: findIndex scans the OTP array from the start and returns the exact position of the first missing character. By setting activeInput to this index, the component ensures the cursor always jumps to the logical next position, even if the user clicked ahead or pasted into a later box. Clearing the out-of-order value prevents duplicate characters when the user later returns to that box.
Updating handleOnKeyDown for Immediate Focus
The handleOnKeyDown function in src/components/vue3-otp-input.vue must call focusOrder directly without wrapping it in setTimeout:
const handleOnKeyDown = (event: KeyboardEvent, index: number) => {
switch (event.key) {
case 'Backspace':
// existing backspace logic
break;
case 'Delete':
// existing delete logic
break;
case 'ArrowLeft':
// existing left navigation
break;
case 'ArrowRight':
// existing right navigation
break;
default:
// Immediate focus correction without setTimeout
focusOrder(index);
break;
}
};
Removing the asynchronous delay ensures the focus shift happens synchronously with the keystroke, preventing race conditions where the user might type a second character before the focus updates.
Optional Watcher for Pre-filled Values
If your application loads an OTP from an API or query parameter, add a watcher in src/components/vue3-otp-input.vue to re-calculate the correct focus position when the value changes:
import { watch } from 'vue';
watch(
() => otp.value.join(''),
() => {
if (props.shouldFocusOrder) {
const firstEmpty = otp.value.findIndex((c) => !c);
activeInput.value = firstEmpty === -1 ? props.numInputs - 1 : firstEmpty;
}
}
);
This ensures that when a user pastes a full code or the component receives a pre-filled value, the activeInput updates to the correct position, maintaining synchronization between the data model and the visual focus state.
Complete Working Examples
Basic Usage with Robust Focus Order
Enable the fixed behavior by setting shouldFocusOrder to true:
<template>
<Vue3OtpInput
v-model="otp"
:numInputs="6"
:shouldFocusOrder="true"
placeholder="○"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import Vue3OtpInput from '@/components/vue3-otp-input.vue';
const otp = ref('');
</script>
With this configuration, users can click into any box, type a digit, and the focus immediately jumps to the first empty input, preventing duplicate entries and confusion.
Handling Pre-filled OTP Codes
When loading an existing OTP (e.g., from an API or auto-fill), combine shouldFocusOrder with the watcher logic to ensure proper cursor placement:
<template>
<Vue3OtpInput
v-model="otp"
:numInputs="4"
:shouldFocusOrder="true"
:shouldAutoFocus="true"
/>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import Vue3OtpInput from '@/components/vue3-otp-input.vue';
const otp = ref('');
// Simulate fetching a partial OTP from an API
onMounted(async () => {
const fetchedCode = await Promise.resolve('12'); // User already has "12"
otp.value = fetchedCode;
// The component will automatically focus the third box (index 2)
});
</script>
This pattern ensures that even with incomplete or full pre-filled values, the input focus remains aligned with the first available empty slot.
Summary
- The default focus logic in
vue3-otp-inputincrements the active index linearly, which fails when users click or type into non-consecutive boxes. - The root cause is the
focusOrderfunction's reliance onsetTimeoutand character length rather than the first empty index. - The solution replaces the length-based calculation with
otp.value.findIndex((c) => !c)to locate the first missing digit, updatingactiveInputimmediately. - Implementation requires modifying
src/components/vue3-otp-input.vueto updatefocusOrderandhandleOnKeyDown, and optionally adding a watcher for pre-filled values. - Enable the fix by setting the
shouldFocusOrderprop totruewhen using the component.
Frequently Asked Questions
What causes the focus order to break when typing out of sequence?
The focus order breaks because the original focusOrder function in src/components/vue3-otp-input.vue uses a setTimeout with a hard-coded length calculation (otp.value.join("").length). When a user types into box 3 while box 1 remains empty, the length becomes 1, causing the focus to move to index 1 (the second box) instead of index 0 (the first empty box). This length-based approach cannot account for gaps in the input sequence.
How does the shouldFocusOrder prop work in vue3-otp-input?
The shouldFocusOrder prop acts as a feature flag in src/components/vue3-otp-input.vue. When set to true, it enables the focusOrder function to execute during keyboard input events. In the fixed implementation, this function uses findIndex to locate the first empty character slot in the OTP array and updates the activeInput ref to that index. Without this prop set to true, the component ignores focus order corrections and relies solely on linear incrementing.
Can users still navigate manually with arrow keys after implementing this fix?
Yes, manual navigation remains fully functional. The handleOnKeyDown function in src/components/vue3-otp-input.vue explicitly handles ArrowLeft and ArrowRight keys to move the cursor between inputs. The focusOrder logic only executes for default keystrokes (alphanumeric input) when shouldFocusOrder is enabled. Arrow key events bypass the automatic focus correction, allowing users to move freely between boxes to correct mistakes or review entries without interference from the first-empty-slot algorithm.
Will this fix work with pasted OTP codes?
Yes, the fix handles pasted codes effectively, especially when combined with a watcher for pre-filled values. When a user pastes a complete or partial OTP string, the single-otp-input.vue components emit change events that populate the OTP array. With the updated focusOrder logic, the component immediately identifies the first empty slot after the paste operation. For API-loaded or auto-filled values, adding a watch on the joined OTP string ensures the activeInput updates to the correct position when the component receives external data, maintaining synchronization between the data model and the visual focus state.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →