Skip to main content

Building a Custom OTP Input Plugin in Oracle APEX

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

PropertyValue
NameOTP Input
Internal NameOTP_INPUT
TypeItem
API InterfaceProcedure
Render Function Namerender

Standard Attributes

Under the Standard Attributes tab, enable the following:

AttributeReason
Is Visible WidgetPlugin renders visible boxes on screen
Standard Form ElementSubmits value to APEX on page submit
Session State ChangeableValue 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 IDLabelTypeDefault
box_countNumber of BoxesText6
input_typeInput TypeSelect Listnumber
active_colorActive / Focus ColorText#4f46e5
error_colorError Border ColorText#ef4444
box_sizeBox Size (px)Text48

Input Type LOV Values

For the Input Type Select List attribute, add these static LOV entries:

Display ValueReturn Value
Numbers Onlynumber
Text and Numberstext

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 || '">&nbsp;</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;
Change :P1_OTP and 6 to match your item name and box count.

Comparison

ApproachObservation
Single Text FieldLimited usability — no individual box UX
Multiple Page ItemsComplex to manage — hard to validate and sync
Custom PluginFully reusable, configurable, and user-friendly

Supported Templates

The plugin works correctly with these APEX item templates:

  • Required — Above
  • Optional — Above

Demo and Source Code

Live Demo: View Live Demo

GitHub Repository: View Source Code

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

Popular posts from this blog

APEX - Tip: Fix Floating Label Issue

Oracle APEX's Universal Theme provides a modern and clean user experience through features like floating (above) labels for page items.  These floating labels work seamlessly when users manually enter data, automatically moving the label above the field on focus or input.  However, a common UI issue appears when page item values are set Dynamically the label and the value overlap, resulting in a broken and confusing user interface. once the user focuses the affected item even once, the label immediately corrects itself and displays properly. When an issue is reported, several values are populated based on a single user input, causing the UI to appear misaligned and confusing for the end user. Here, I'll share a few tips to fix this issue. For example, employee details are populated based on the Employee name. In this case, the first True Action is used to set the values, and in the second True Action, paste the following code setTimeout(function () {   $("#P29_EMAIL,#P29_...

Oracle APEX UI Tip: Display Page Title Next to the APEX Logo

In most Oracle APEX applications, every page has a Page Title displayed at the top. While useful, this title occupies vertical space, especially in apps where screen real estate matters (dashboards, reports, dense forms). So the goal is simple: Show the page title near the APEX logo instead of consuming page content space. This keeps the UI clean, professional, and consistent across all pages. Instead of placing the page title inside the page body:         ✅ Fetch the current page title dynamically         ✅ Display it right after the APEX logo         ✅ Do it globally, so it works for every page All of this is achieved using:         ✅ Global Page (Page 0)         ✅ One Dynamic Action         ✅ PL/SQL + JavaScript Simple, effective, and reusable. 1️⃣ Create a Global Page Item On Page 0 (Global Page), create a hidden item:      P0_PAGE_TITLE This item wi...

Building a Custom Debug Package for Oracle APEX Using PL/SQL

While developing Oracle APEX applications, debugging page processes and backend PL/SQL logic can be challenging—especially when values are lost between processes or execution flow is unclear.  Although DBMS_OUTPUT is useful, it doesn’t work well inside APEX runtime. To solve this, I built a custom PL/SQL debug Package that logs execution flow and variable values into a database table.  This approach helps trace exactly where the code reached, what values were passed, and whether a block executed or not - even inside page-level processes and packaged procedures Why a Custom Debug Package? Works seamlessly inside Oracle APEX page processes Persists debug information even after session ends Helps trace execution flow Captures runtime values Can be turned ON/OFF dynamically Does not interrupt business logic The Package consists of:- Debug Table                         -  Stores debug messages Sequence ...