Modern applications rely on One-Time Password (OTP) verification for secure authentication. These inputs are typically designed as multiple input boxes, each accepting a single character, providing a structured and user-friendly experience.
Oracle APEX does not provide a native OTP input component. This article demonstrates how to build a fully configurable OTP Input Item Plugin from scratch, tested on APEX 24.2.
Overview
- Configurable number of input boxes (1 to 12)
- Customizable size and colors per instance
- Supports numeric-only or alphanumeric input modes
- Auto-advance to next box after each entry
- Backspace moves back and clears the previous box
- Arrow key navigation between boxes
- Paste distributes characters across all boxes automatically
- Blur validation — empty boxes turn red when user leaves the component
- Page submit validation — blocks form submission if any box is empty
Plugin Creation
Navigation: Shared Components → Plug-ins → Create
| Property | Value |
|---|---|
| Name | OTP Input |
| Internal Name | OTP_INPUT |
| Type | Item |
| API Interface | Procedure |
| Render Function Name | render |
Standard Attributes
Under the Standard Attributes tab, enable the following:
| Attribute | Reason |
|---|---|
| Is Visible Widget | Plugin renders visible boxes on screen |
| Standard Form Element | Submits value to APEX on page submit |
| Session State Changeable | Value stored in APEX session state |
Custom Attributes
Create these 5 attributes under the Custom Attributes tab. Use the exact Static IDs shown — the PL/SQL render code reads values by Static ID.
| Static ID | Label | Type | Default |
|---|---|---|---|
| box_count | Number of Boxes | Text | 6 |
| input_type | Input Type | Select List | number |
| active_color | Active / Focus Color | Text | #4f46e5 |
| error_color | Error Border Color | Text | #ef4444 |
| box_size | Box Size (px) | Text | 48 |
Input Type LOV Values
For the Input Type Select List attribute, add these static LOV entries:
| Display Value | Return Value |
|---|---|
| Numbers Only | number |
| Text and Numbers | text |
Reading Attribute Values in PL/SQL
In APEX 24.2, custom attribute values are read using their Static ID via the
attributes.get_varchar2() method — not the old p_item.attribute_01 approach.
l_box_count := nvl(p_item.attributes.get_varchar2('box_count'), '6');
l_input_type_lc := lower(nvl(p_item.attributes.get_varchar2('input_type'), 'number'));
l_active_color := nvl(p_item.attributes.get_varchar2('active_color'),'#4f46e5');
l_error_color := nvl(p_item.attributes.get_varchar2('error_color'), '#ef4444');
l_box_size := nvl(p_item.attributes.get_varchar2('box_size'), '48');
HTML Structure
The render procedure outputs a hidden master input (which APEX reads as the item value)
followed by a wrapper div where JavaScript builds the boxes dynamically.
All plugin configuration values are passed to JavaScript through data-* attributes,
keeping PL/SQL and JavaScript cleanly separated.
-- Hidden master input for APEX session state
htp.p('<input type="hidden" id="' || l_item_id
|| '" name="' || apex_plugin.get_input_name_for_page_item(false)
|| '" value="' || apex_escape.html(l_current_val) || '">');
-- Outer block container — forces message below boxes
htp.p('<div style="display:block;width:100%;">');
-- Boxes container — JS builds individual inputs here
htp.p('<div class="otp-wrapper" id="otp_wrap_' || l_item_id || '"'
|| ' data-count="' || l_box_count || '"'
|| ' data-type="' || l_input_type_lc || '"'
|| ' data-active="' || l_active_color || '"'
|| ' data-error="' || l_error_color || '"'
|| ' data-size="' || l_box_size || '"></div>');
-- Message div — always below boxes
htp.p('<div class="otp-msg" id="otp_msg_' || l_item_id || '"> </div>');
htp.p('</div>');
Key Implementation Concepts
Dynamic Box Creation
Boxes are created in JavaScript — not hardcoded in HTML — so any box_count value works
without changing the render procedure. Box size, font size, and border radius all scale
proportionally from the single box_size setting.
var boxes = [];
for (var i = 0; i < count; i++) {
var inp = document.createElement('input');
inp.type = 'text';
inp.maxLength = 1;
inp.className = 'otp-box';
inp.setAttribute('inputmode', isNumber ? 'numeric' : 'text');
inp.setAttribute('autocomplete', 'off');
inp.style.width = boxSize + 'px';
inp.style.height = boxSize + 'px';
inp.style.fontSize = Math.round(boxSize * 0.42) + 'px';
inp.style.borderRadius = Math.round(boxSize * 0.2) + 'px';
wrap.appendChild(inp);
boxes.push(inp);
}
Why type="text" Instead of type="number"
Using type="number" seems like the obvious choice but causes real problems — browsers
allow e, +, - characters (valid in scientific notation),
and behavior is inconsistent across Chrome, Firefox, and Safari.
The correct approach is type="text" combined with inputmode="numeric"
which shows the numeric keyboard on mobile without any of the side effects.
inp.type = 'text';
inp.setAttribute('inputmode', isNumber ? 'numeric' : 'text');
Three-Layer Number Enforcement
A single event listener is not reliable enough across all browsers and devices. Three layers work together to block non-numeric input completely:
// Layer 1: beforeinput — fires before character renders (Chrome, Edge, Safari)
box.addEventListener('beforeinput', function(e) {
if (isNumber && e.data && !/^[0-9]$/.test(e.data)) {
e.preventDefault();
}
});
// Layer 2: keydown — catches physical keyboard input
box.addEventListener('keydown', function(e) {
if (e.ctrlKey || e.metaKey) return; // allow ctrl+v, ctrl+c
if (isNumber && !/^[0-9]$/.test(e.key)) {
e.preventDefault();
}
});
// Layer 3: input — strips anything that slipped through (mobile IME, autofill)
box.addEventListener('input', function() {
var v = box.value;
if (isNumber) v = v.replace(/[^0-9]/g, '');
box.value = v.slice(-1);
});
Auto-Advance and Keyboard Navigation
// Auto-advance to next box after entry
if (box.value && idx < count - 1) boxes[idx + 1].focus();
// Backspace: clear current box or move back if already empty
if (e.key === 'Backspace') {
e.preventDefault();
if (box.value) {
box.value = '';
} else if (idx > 0) {
boxes[idx - 1].focus();
boxes[idx - 1].value = '';
}
}
// Arrow key navigation
if (e.key === 'ArrowLeft' && idx > 0) boxes[idx - 1].focus();
if (e.key === 'ArrowRight' && idx < count-1) boxes[idx + 1].focus();
Paste Support
Pasting a full OTP string distributes characters across boxes starting from the focused box.
So pasting 483921 into box 1 of a 6-box component fills all six boxes instantly.
box.addEventListener('paste', function(e) {
e.preventDefault();
var p = (e.clipboardData || window.clipboardData).getData('text');
if (isNumber) p = p.replace(/[^0-9]/g, '');
p = p.slice(0, count - idx);
for (var k = 0; k < p.length; k++) {
boxes[idx + k].value = p[k];
}
boxes[Math.min(idx + p.length, count - 1)].focus();
syncValue();
});
Blur Validation — Smart Focus Detection
Tabbing from box 2 to box 3 fires blur on box 2. Validating immediately would
incorrectly mark in-progress entry as an error. A 150ms delay with a focus check solves this —
validation only runs when focus truly leaves the component.
box.addEventListener('blur', function() {
setTimeout(function() {
if (!isFocusInside()) validateAll();
}, 150);
});
function isFocusInside() {
return boxes.some(function(b) {
return b === document.activeElement;
});
}
Syncing to APEX Session State
All individual box values concatenate into the hidden master input which APEX reads as the page item value.
function syncValue() {
var val = boxes.map(function(b) {
return b.value || '';
}).join('');
masterInp.value = val;
apex.item('P1_OTP').setValue(val);
}
Reading the value in PL/SQL: :P1_OTP returns the full concatenated string e.g. 483921
APEX Item API Integration
getValidity is called automatically by APEX during page submit:
apex.item.create('P1_OTP', {
getValue: function() { return masterInp.value; },
getValidity: function() {
return { valid: masterInp.value.length >= count };
},
getValidationMessage: function() {
return 'Please fill all ' + count + ' boxes before submitting.';
}
});
Message Positioning Fix
APEX templates lay out items inline, which causes the validation message to appear beside the boxes
instead of below them. Wrapping everything in a display:block; width:100% container
with !important on the message CSS fixes this.
.otp-msg {
display: block !important;
width: 100% !important;
margin-top: 8px;
}
CSS and JS Injection via PL/SQL
-- CSS injection (p_key prevents duplicate injection on same page)
apex_css.add(
p_css => '.otp-wrapper { display:flex; gap:10px; } ...',
p_key => 'otp-plugin-css'
);
-- JS injection (runs after DOM is ready)
apex_javascript.add_onload_code(p_code => '(function(){ ... }());');
Server-Side Validation
The correct server-side approach is a page-level PL/SQL validation. Add it as a PL/SQL Function Body returning Error Text validation on the page.
BEGIN
IF :P1_OTP IS NULL
OR LENGTH(TRIM(:P1_OTP)) < 6 THEN
RETURN 'Please fill all 6 boxes before submitting.';
END IF;
RETURN NULL;
END;
:P1_OTP and 6 to match your item name and box count.
Comparison
| Approach | Observation |
|---|---|
| Single Text Field | Limited usability — no individual box UX |
| Multiple Page Items | Complex to manage — hard to validate and sync |
| Custom Plugin | Fully reusable, configurable, and user-friendly |
Supported Templates
The plugin works correctly with these APEX item templates:
- Required — Above
- Optional — Above
Conclusion
The OTP Input plugin demonstrates that APEX item plugins can match the quality and behaviour of components found in native mobile apps. The result is a fully reusable component that any developer can drop onto any page with zero custom code required.
Comments
Post a Comment