OTP Input
A control that enables users to enter one-time passwords or verification codes with individual character inputs.
Two-step verification
A verification code has been sent to your email. Please enter the code below to verify this device.
import { type PropsOf, Slot, component$ } from "@builder.io/qwik";
import { Checkbox, Otp } from "@kunai-consulting/qwik";
import { LuCheck } from "@qwikest/icons/lucide";
export const MyDiv = component$((props: PropsOf<"div">) => {
return (
<div {...props}>
<Slot />
</div>
);
});
export default component$(() => {
const slots = Array.from({ length: 4 });
return (
<div class="flex flex-col items-center gap-4">
<div class="max-w-80 flex flex-col items-center text-center">
<InformationCircle class="*:stroke-qwik-blue-600" />
<h2 class="py-4 text-lg font-semibold">Two-step verification</h2>
<p class="text-sm">
A verification code has been sent to your email. Please enter the code
below to verify this device.
</p>
</div>
<Otp.Root
class="flex flex-col items-center justify-center"
render={<MyDiv />}
>
<Otp.HiddenInput />
<div class="otp-container flex flex-row justify-center gap-2">
{slots.map((slot) => (
<Otp.Item
key={`otp-item-${slot}`}
class={
"h-9 w-10 border-2 text-center rounded data-[highlighted]:ring-qwik-blue-800 data-[highlighted]:ring-[3px] caret-blue-600"
}
>
<Otp.Caret class="text-blue-500 text-xl animate-blink-caret">
|
</Otp.Caret>
</Otp.Item>
))}
</div>
</Otp.Root>
<TrustedCheckbox />
<button
type="button"
class="rounded-md bg-qwik-blue-700 px-4 py-2 text-white outline-qwik-blue-600"
>
Sign in securely
</button>
</div>
);
});
const InformationCircle = component$((props: PropsOf<"svg">) => {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Information Circle</title>
<path
d="M17.3333 21.3333H16V16H14.6667M16 10.6667H16.0133M28 16C28 17.5759 27.6896 19.1363 27.0866 20.5922C26.4835 22.0481 25.5996 23.371 24.4853 24.4853C23.371 25.5996 22.0481 26.4835 20.5922 27.0866C19.1363 27.6896 17.5759 28 16 28C14.4242 28 12.8637 27.6896 11.4078 27.0866C9.95191 26.4835 8.62904 25.5996 7.51473 24.4853C6.40043 23.371 5.51652 22.0481 4.91346 20.5922C4.3104 19.1363 4.00002 17.5759 4.00002 16C4.00002 12.8174 5.2643 9.76516 7.51473 7.51472C9.76517 5.26428 12.8174 4 16 4C19.1826 4 22.2349 5.26428 24.4853 7.51472C26.7357 9.76516 28 12.8174 28 16Z"
stroke="#2563EB"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);
});
export const TrustedCheckbox = component$(() => {
return (
<Checkbox.Root>
<Checkbox.HiddenInput />
<div class="flex items-center gap-2">
<Checkbox.Trigger
class="size-[25px] rounded-lg relative bg-gray-500
focus-visible:outline focus-visible:outline-1 focus-visible:outline-white
disabled:opacity-50 bg-qwik-neutral-200 data-[checked]:bg-qwik-blue-800 focus-visible:ring-[3px] ring-qwik-blue-600"
>
<Checkbox.Indicator
class="data-[checked]:flex justify-center items-center absolute inset-0
"
>
<LuCheck />
</Checkbox.Indicator>
</Checkbox.Trigger>
<Checkbox.Label class="text-sm">
This is a trusted device, don't ask again
</Checkbox.Label>
</div>
</Checkbox.Root>
);
});
Initially Setting an OTP
To configure an initial state for the OTP, use either the "value" prop or the "bind:value" prop if you already have a signal.
import { type PropsOf, component$ } from "@builder.io/qwik";
import type { DocumentHead } from "@builder.io/qwik-city";
import { Otp } from "@kunai-consulting/qwik";
export const head: DocumentHead = {
title: "Qwik Design System",
meta: [
{
name: "description",
content: "Qwik Design System"
}
]
};
export default component$(() => {
return (
<Otp.Root value="1234" class="flex flex-col items-center justify-center">
<Otp.HiddenInput />
<div class="otp-container flex flex-row justify-center gap-2">
{Array.from({ length: 4 }, (_, index) => {
const uniqueKey = `otp-${index}-${Date.now()}`;
return (
<Otp.Item
key={uniqueKey}
class={
"h-9 w-10 border-2 text-center data-[highlighted]:border-blue-600 rounded data-[highlighted]:ring-blue-100 data-[highlighted]:ring-[3px] data-[highlighted]:pl-1 data-[highlighted]:pr-1 caret-blue-600"
}
>
<Otp.Caret class="text-blue-500 text-xl animate-blink-caret">|</Otp.Caret>
</Otp.Item>
);
})}
</div>
</Otp.Root>
);
});
const InformationCircle = component$((props: PropsOf<"svg">) => {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Information Circle</title>
<path
d="M17.3333 21.3333H16V16H14.6667M16 10.6667H16.0133M28 16C28 17.5759 27.6896 19.1363 27.0866 20.5922C26.4835 22.0481 25.5996 23.371 24.4853 24.4853C23.371 25.5996 22.0481 26.4835 20.5922 27.0866C19.1363 27.6896 17.5759 28 16 28C14.4242 28 12.8637 27.6896 11.4078 27.0866C9.95191 26.4835 8.62904 25.5996 7.51473 24.4853C6.40043 23.371 5.51652 22.0481 4.91346 20.5922C4.3104 19.1363 4.00002 17.5759 4.00002 16C4.00002 12.8174 5.2643 9.76516 7.51473 7.51472C9.76517 5.26428 12.8174 4 16 4C19.1826 4 22.2349 5.26428 24.4853 7.51472C26.7357 9.76516 28 12.8174 28 16Z"
stroke="#2563EB"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);
});
Reactive State
Make the OTP reactive by binding its value to a signal. Any changes to that signal will be reflected, and user input will also update the signal.
import { type PropsOf, component$, useSignal } from "@builder.io/qwik";
import type { DocumentHead } from "@builder.io/qwik-city";
import { Otp } from "@kunai-consulting/qwik";
export const head: DocumentHead = {
title: "Qwik Design System",
meta: [
{
name: "description",
content: "Qwik Design System"
}
]
};
export default component$(() => {
const otpInput = useSignal<string>("");
return (
<>
<Otp.Root bind:value={otpInput} class="flex flex-col items-center justify-center">
<Otp.HiddenInput />
<div class="otp-container flex flex-row justify-center gap-2">
{Array.from({ length: 4 }, (_, index) => {
const uniqueKey = `otp-${index}-${Date.now()}`;
return (
<Otp.Item
key={uniqueKey}
class={
"h-9 w-10 border-2 text-center data-[highlighted]:border-blue-600 rounded data-[highlighted]:ring-blue-100 data-[highlighted]:ring-[3px] data-[highlighted]:pl-1 data-[highlighted]:pr-1 caret-blue-600"
}
>
<Otp.Caret class="text-blue-500 text-xl animate-blink-caret">|</Otp.Caret>
</Otp.Item>
);
})}
</div>
</Otp.Root>
<button type="button" onClick$={() => (otpInput.value = "1234")}>
Change otp value
</button>
</>
);
});
Disabled
Disable user interaction by passing the "disabled" prop to the root component. This will disable the hidden native input and prevent further user input.
import { $, type PropsOf, component$, useSignal } from "@builder.io/qwik";
import type { DocumentHead } from "@builder.io/qwik-city";
import { Otp } from "@kunai-consulting/qwik";
import value from "~/routes/checkbox/examples/value";
export const head: DocumentHead = {
title: "Qwik Design System",
meta: [
{
name: "description",
content: "Qwik Design System"
}
]
};
export default component$(() => {
const isDisabled = useSignal(false);
return (
<>
<Otp.Root
disabled={isDisabled.value}
class="flex flex-col items-center justify-center"
>
<Otp.HiddenInput />
<div class="otp-container flex flex-row justify-center gap-2">
{Array.from({ length: 4 }, (_, index) => {
const uniqueKey = `otp-${index}-${Date.now()}`;
return (
<Otp.Item
key={uniqueKey}
class={
"h-9 w-10 border-2 text-center data-[highlighted]:border-blue-600 rounded data-[highlighted]:ring-blue-100 data-[highlighted]:ring-[3px] data-[highlighted]:pl-1 data-[highlighted]:pr-1 caret-blue-600 data-[disabled]:opacity-50"
}
>
<Otp.Caret class="text-blue-500 text-xl animate-blink-caret">|</Otp.Caret>
</Otp.Item>
);
})}
</div>
</Otp.Root>
<button type="button" onClick$={() => (isDisabled.value = !isDisabled.value)}>
Disable OTP
</button>
</>
);
});
onChange$
Use the "onChange$" prop on the root to listen for changes as the user types. This is called for each keystroke or edit.
import { $, type PropsOf, component$, useSignal } from "@builder.io/qwik";
import type { DocumentHead } from "@builder.io/qwik-city";
import { Otp } from "@kunai-consulting/qwik";
export const head: DocumentHead = {
title: "Qwik Design System",
meta: [
{
name: "description",
content: "Qwik Design System"
}
]
};
export default component$(() => {
const isChange = useSignal(false);
return (
<>
<Otp.Root
onChange$={$(() => {
console.log("onChange$");
isChange.value = true;
})}
class="flex flex-col items-center justify-center"
>
<Otp.HiddenInput />
<div class="otp-container flex flex-row justify-center gap-2">
{Array.from({ length: 4 }, (_, index) => {
const uniqueKey = `otp-${index}-${Date.now()}`;
return (
<Otp.Item
key={uniqueKey}
class={
"h-9 w-10 border-2 text-center data-[highlighted]:border-blue-600 rounded data-[highlighted]:ring-blue-100 data-[highlighted]:ring-[3px] data-[highlighted]:pl-1 data-[highlighted]:pr-1 caret-blue-600"
}
>
<Otp.Caret class="text-blue-500 text-xl animate-blink-caret">|</Otp.Caret>
</Otp.Item>
);
})}
</div>
</Otp.Root>
{isChange.value && <p>onChange$</p>}
</>
);
});
onComplete$
When the user has filled in all required characters, the OTP is considered "complete." The "onComplete$" prop can be used to trigger any final logic (e.g., auto-submitting).
import { $, type PropsOf, component$, useSignal } from "@builder.io/qwik";
import type { DocumentHead } from "@builder.io/qwik-city";
import { Otp } from "@kunai-consulting/qwik";
import value from "~/routes/checkbox/examples/value";
export const head: DocumentHead = {
title: "Qwik Design System",
meta: [
{
name: "description",
content: "Qwik Design System"
}
]
};
export default component$(() => {
const isDisabled = useSignal(false);
const handleComplete = $(() => {
isDisabled.value = true;
});
return (
<>
<Otp.Root
disabled={isDisabled.value}
onComplete$={handleComplete}
class="flex flex-col items-center justify-center"
>
<Otp.HiddenInput />
<div class="otp-container flex flex-row justify-center gap-2">
{Array.from({ length: 4 }, (_, index) => {
const uniqueKey = `otp-${index}-${Date.now()}`;
return (
<Otp.Item
key={uniqueKey}
class={
"h-9 w-10 border-2 text-center data-[highlighted]:border-blue-600 rounded data-[highlighted]:ring-blue-100 data-[highlighted]:ring-[3px] data-[highlighted]:pl-1 data-[highlighted]:pr-1 caret-blue-600 data-[disabled]:opacity-50"
}
>
<Otp.Caret class="text-blue-500 text-xl animate-blink-caret">|</Otp.Caret>
</Otp.Item>
);
})}
</div>
</Otp.Root>
</>
);
});
Adding a Caret
To show a caret (like a blinking cursor) on the active input position, include the Otp.Caret component inside each Otp.Item. The caret appears only on the focused, empty position.
Hidden Native Input
The OTP input features a hidden native input for accessibility and mobile keyboards. Ensure you include it by placing Otp.HiddenInput inside your Otp.Root.
Numeric Input
By default, the hidden input is configured to accept numbers (inputMode="numeric" and pattern="[0-9]*"). This prompts numeric keyboards on mobile devices. You can override this if you need alphanumeric codes.
Custom Length
The length of the OTP is determined by the number of Otp.Item components. Add or remove items to change the length.
Focus Management
The component automatically manages focus. Clicking any Otp.Item focuses the hidden input, and the caret or highlight will track the currently active position.
Full Entry State
When all characters are filled, the component enters a "full" state. You can use this for styling or to trigger further logic, such as validation or auto-submission.
Inside a Form
When using the OTP within a form, include the Otp.HiddenInput with a "name" prop. This links the collected code to form submissions.
Accessibility
• A hidden native input helps ensure screen readers announce the OTP properly.
• Default aria-label of "Enter your OTP" is used (customizable through props).
• Keyboard interactions are preserved, including arrow keys, Shift+arrow range selection, and pasting.
• Disabled state is exposed with proper attributes for assistive technologies.
API Reference
Otp Root
Inherits from: <div />
Props
Prop | Type | Default | Description |
---|---|---|---|
"bind:value" | Signal<string> | - | Reactive value that can be controlled via signal. Describe what passing their signal does for this bind property |
_numItems | number | - | Number of OTP input items to display |
autoComplete | HTMLInputAutocompleteAttribute | - | HTML autocomplete attribute for the input |
onComplete$ | QRL<() => void> | - | Event handler for when all OTP items are filled |
onChange$ | QRL<(value: string) => void> | - | Event handler for when the OTP value changes |
value | string | - | Initial value of the OTP input |
disabled | boolean | false | Whether the OTP input is disabled |
shiftPWManagers | boolean | true | Whether password manager popups should shift to the right of the OTP. By default enabled |
Data Attributes
Attribute | Description |
---|---|
data-disabled | Indicates if the entire OTP input is disabled |
Otp Item
Inherits from: <div />
Data Attributes
Attribute | Description |
---|---|
data-highlighted | Indicates if the OTP item is currently highlighted |
data-disabled | Indicates if the OTP item is disabled |
Otp Hidden Input
Inherits from: <input />
Data Attributes
Attribute | Description |
---|---|
data-shift |
Otp Caret
Inherits from: <span />