},
{
"path": "./dist/js/bootstrap.bundle.js",
- "maxSize": "79.5 kB"
+ "maxSize": "80.0 kB"
},
{
"path": "./dist/js/bootstrap.bundle.min.js",
- "maxSize": "51.75 kB"
+ "maxSize": "52.0 kB"
},
{
"path": "./dist/js/bootstrap.js",
- "maxSize": "50.75 kB"
+ "maxSize": "51.25 kB"
},
{
"path": "./dist/js/bootstrap.min.js",
- "maxSize": "29.75 kB"
+ "maxSize": "30.0 kB"
}
],
"ci": {
"affordance",
"allowfullscreen",
"Analyser",
+ "autofilled",
"autohide",
"autohiding",
"autoplay",
*/
const NAME = 'otpInput'
-const DATA_KEY = 'bs.otp-input'
+const DATA_KEY = 'bs.otpInput'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const EVENT_COMPLETE = `complete${EVENT_KEY}`
const EVENT_INPUT = `input${EVENT_KEY}`
+const EVENT_DOMCONTENT_LOADED = `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`
const SELECTOR_DATA_OTP = '[data-bs-otp]'
const SELECTOR_INPUT = 'input'
+// Events that should refresh the active-slot highlight as the caret moves
+const SYNC_EVENTS = ['blur', 'keyup', 'click', 'select']
+
+const CLASS_NAME_INPUT = 'otp-input'
+const CLASS_NAME_RENDERED = 'otp-rendered'
+const CLASS_NAME_SLOTS = 'otp-slots'
+const CLASS_NAME_SLOT = 'otp-slot'
+const CLASS_NAME_SLOT_FILLED = 'otp-slot-filled'
+const CLASS_NAME_SLOT_ACTIVE = 'otp-slot-active'
+const CLASS_NAME_SEPARATOR = 'otp-separator'
+
+const MASK_CHARACTER = '•'
+
+// Per-type input mode, validation pattern, and a filter that strips disallowed characters
+const TYPES = {
+ numeric: { inputmode: 'numeric', pattern: '[0-9]*', filter: /[^0-9]/g },
+ alphanumeric: { inputmode: 'text', pattern: '[A-Za-z0-9]*', filter: /[^A-Za-z0-9]/g },
+ alpha: { inputmode: 'text', pattern: '[A-Za-z]*', filter: /[^A-Za-z]/g }
+}
+
const Default = {
- length: 6,
- mask: false
+ groups: null,
+ length: null,
+ mask: false,
+ separator: '·',
+ type: 'numeric'
}
const DefaultType = {
- length: 'number',
- mask: 'boolean'
+ groups: '(array|null)',
+ length: '(number|null)',
+ mask: 'boolean',
+ separator: 'string',
+ type: 'string'
}
/**
constructor(element, config) {
super(element, config)
- this._inputs = SelectorEngine.find(SELECTOR_INPUT, this._element)
- this._setupInputs()
+ this._input = SelectorEngine.findOne(SELECTOR_INPUT, this._element)
+ if (!this._input) {
+ return
+ }
+
+ this._type = TYPES[this._config.type] || TYPES.numeric
+ this._length = this._resolveLength()
+ this._slots = []
+
+ this._setupInput()
+ this._renderSlots()
this._addEventListeners()
+ this._render()
}
// Getters
// Public
getValue() {
- return this._inputs.map(input => input.value).join('')
+ return this._input.value
}
setValue(value) {
- const chars = [...String(value)]
- for (const [index, input] of this._inputs.entries()) {
- input.value = chars[index] || ''
- }
-
+ this._input.value = this._sanitize(String(value))
+ this._render()
this._checkComplete()
}
clear() {
- for (const input of this._inputs) {
- input.value = ''
- }
-
- this._inputs[0]?.focus()
+ this._input.value = ''
+ this._render()
+ this._input.focus()
}
focus() {
- // Focus first empty input, or last input if all filled
- const emptyInput = this._inputs.find(input => !input.value)
- if (emptyInput) {
- emptyInput.focus()
- } else {
- this._inputs.at(-1)?.focus()
- }
+ this._input.focus()
+ // Place the caret after the last entered character
+ const end = this._input.value.length
+ this._input.setSelectionRange(end, end)
+ this._render()
}
- // Private
- _setupInputs() {
- for (const input of this._inputs) {
- // Set attributes for proper OTP handling
- input.setAttribute('maxlength', '1')
- input.setAttribute('inputmode', 'numeric')
- input.setAttribute('pattern', '\\d*')
-
- // First input gets autocomplete for browser OTP autofill
- if (input === this._inputs[0]) {
- input.setAttribute('autocomplete', 'one-time-code')
- } else {
- input.setAttribute('autocomplete', 'off')
- }
-
- // Mask input if configured
- if (this._config.mask) {
- input.setAttribute('type', 'password')
- }
+ dispose() {
+ EventHandler.off(this._input, 'input', this._onInput)
+ EventHandler.off(this._input, 'focus', this._onFocus)
+ for (const type of SYNC_EVENTS) {
+ EventHandler.off(this._input, type, this._onSync)
}
+
+ this._slotsContainer?.remove()
+ this._element.classList.remove(CLASS_NAME_RENDERED)
+ super.dispose()
}
- _addEventListeners() {
- for (const [index, input] of this._inputs.entries()) {
- EventHandler.on(input, 'input', event => this._handleInput(event, index))
- EventHandler.on(input, 'keydown', event => this._handleKeydown(event, index))
- EventHandler.on(input, 'paste', event => this._handlePaste(event))
- EventHandler.on(input, 'focus', event => this._handleFocus(event))
+ // Private
+ _resolveLength() {
+ if (this._config.length) {
+ return this._config.length
}
+
+ const maxLength = Number.parseInt(this._input.getAttribute('maxlength'), 10)
+ return Number.isNaN(maxLength) || maxLength < 1 ? 6 : maxLength
}
- _handleInput(event, index) {
- const input = event.target
+ _setupInput() {
+ const input = this._input
- // Only allow digits
- if (!/^\d*$/.test(input.value)) {
- input.value = input.value.replace(/\D/g, '')
+ // A single text field backs the whole control so screen readers, password
+ // managers, and SMS autofill treat it like any other input.
+ if (input.type === 'number' || input.type === 'password') {
+ input.type = 'text'
}
- const { value } = input
-
- // Handle multi-character input (some browsers/autofill)
- if (value.length > 1) {
- // Distribute characters across inputs
- const chars = [...value]
- input.value = chars[0] || ''
+ input.classList.add(CLASS_NAME_INPUT)
+ input.setAttribute('maxlength', String(this._length))
+ input.setAttribute('inputmode', this._type.inputmode)
+ input.setAttribute('pattern', this._type.pattern)
- for (let i = 1; i < chars.length && index + i < this._inputs.length; i++) {
- this._inputs[index + i].value = chars[i]
- }
-
- // Focus appropriate input
- const nextIndex = Math.min(index + chars.length, this._inputs.length - 1)
- this._inputs[nextIndex].focus()
- } else if (value && index < this._inputs.length - 1) {
- // Auto-advance to next input
- this._inputs[index + 1].focus()
+ if (!input.getAttribute('autocomplete')) {
+ input.setAttribute('autocomplete', 'one-time-code')
}
- EventHandler.trigger(this._element, EVENT_INPUT, {
- value: this.getValue(),
- index
- })
-
- this._checkComplete()
+ // Filter any pre-filled value through the configured type
+ if (input.value) {
+ input.value = this._sanitize(input.value)
+ }
}
- _handleKeydown(event, index) {
- const { key } = event
-
- switch (key) {
- case 'Backspace': {
- if (!this._inputs[index].value && index > 0) {
- // Move to previous input and clear it
- event.preventDefault()
- this._inputs[index - 1].value = ''
- this._inputs[index - 1].focus()
+ _renderSlots() {
+ const container = document.createElement('div')
+ container.className = CLASS_NAME_SLOTS
+ container.setAttribute('aria-hidden', 'true')
+
+ const { groups } = this._config
+ let groupIndex = 0
+ let inGroup = 0
+
+ for (let i = 0; i < this._length; i++) {
+ const slot = document.createElement('div')
+ slot.className = CLASS_NAME_SLOT
+ container.append(slot)
+ this._slots.push(slot)
+
+ // Insert a visual separator between configured groups
+ if (Array.isArray(groups) && groups.length > 0) {
+ inGroup++
+ if (inGroup === groups[groupIndex] && i < this._length - 1) {
+ const separator = document.createElement('div')
+ separator.className = CLASS_NAME_SEPARATOR
+ separator.textContent = this._config.separator
+ container.append(separator)
+ groupIndex = Math.min(groupIndex + 1, groups.length - 1)
+ inGroup = 0
}
-
- break
}
+ }
- case 'Delete': {
- // Clear current and shift remaining values left
- event.preventDefault()
- for (let i = index; i < this._inputs.length - 1; i++) {
- this._inputs[i].value = this._inputs[i + 1].value
- }
-
- this._inputs.at(-1).value = ''
- break
- }
+ this._slotsContainer = container
+ this._element.append(container)
+ this._element.classList.add(CLASS_NAME_RENDERED)
+ }
- case 'ArrowLeft': {
- if (index > 0) {
- event.preventDefault()
- this._inputs[index - 1].focus()
- }
+ _addEventListeners() {
+ // Listeners are attached with bare event names (not namespaced) because
+ // `input` is not in EventHandler's native-events list; we keep references
+ // so they can be removed on dispose.
+ this._onInput = () => this._handleInput()
+ this._onFocus = () => this.focus()
+ this._onSync = () => this._render()
+
+ EventHandler.on(this._input, 'input', this._onInput)
+ EventHandler.on(this._input, 'focus', this._onFocus)
+
+ // Keep the active-slot highlight in sync with the caret
+ for (const type of SYNC_EVENTS) {
+ EventHandler.on(this._input, type, this._onSync)
+ }
+ }
- break
- }
+ _handleInput() {
+ const sanitized = this._sanitize(this._input.value)
+ if (sanitized !== this._input.value) {
+ this._input.value = sanitized
+ }
- case 'ArrowRight': {
- if (index < this._inputs.length - 1) {
- event.preventDefault()
- this._inputs[index + 1].focus()
- }
+ this._render()
- break
- }
+ EventHandler.trigger(this._element, EVENT_INPUT, { value: this._input.value })
- // No default
- }
+ this._checkComplete()
}
- _handlePaste(event) {
- event.preventDefault()
- const pastedData = (event.clipboardData || window.clipboardData).getData('text')
- const digits = pastedData.replace(/\D/g, '').slice(0, this._inputs.length)
-
- if (digits) {
- this.setValue(digits)
-
- // Focus last filled input or last input
- const lastIndex = Math.min(digits.length, this._inputs.length) - 1
- this._inputs[lastIndex].focus()
- }
+ _sanitize(value) {
+ return value.replace(this._type.filter, '').slice(0, this._length)
}
- _handleFocus(event) {
- // Select the content on focus for easy replacement
- event.target.select()
+ _render() {
+ const { value } = this._input
+ const isFocused = document.activeElement === this._input
+ // The active slot follows the caret, clamped to the last slot when the value is full
+ const caret = Math.min(this._input.selectionStart ?? value.length, this._length - 1)
+
+ for (const [index, slot] of this._slots.entries()) {
+ const char = value[index] ?? ''
+ slot.textContent = char && this._config.mask ? MASK_CHARACTER : char
+ slot.classList.toggle(CLASS_NAME_SLOT_FILLED, Boolean(char))
+ slot.classList.toggle(CLASS_NAME_SLOT_ACTIVE, isFocused && index === caret)
+ }
}
_checkComplete() {
- const value = this.getValue()
- const isComplete = value.length === this._inputs.length &&
- this._inputs.every(input => input.value !== '')
-
- if (isComplete) {
+ const { value } = this._input
+ if (value.length === this._length) {
EventHandler.trigger(this._element, EVENT_COMPLETE, { value })
}
}
* Data API implementation
*/
-EventHandler.on(document, `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`, () => {
+EventHandler.on(document, EVENT_DOMCONTENT_LOADED, () => {
for (const element of SelectorEngine.find(SELECTOR_DATA_OTP)) {
OtpInput.getOrCreateInstance(element)
}
clearFixture()
})
- const getOtpHtml = (inputCount = 6, attributes = '') => {
- const inputs = Array.from({ length: inputCount })
- .map(() => '<input type="text" class="form-control">')
- .join('')
- return `<div class="otp-input" ${attributes}>${inputs}</div>`
+ const getOtpHtml = (attributes = '', maxlength = 6) =>
+ `<div class="otp" ${attributes}><input type="text" class="otp-input" maxlength="${maxlength}"></div>`
+
+ const typeInto = (input, value) => {
+ input.value = value
+ input.dispatchEvent(createEvent('input'))
}
describe('VERSION', () => {
describe('Default', () => {
it('should return default config', () => {
expect(OtpInput.Default).toEqual(jasmine.any(Object))
- expect(OtpInput.Default.length).toEqual(6)
- expect(OtpInput.Default.mask).toEqual(false)
+ expect(OtpInput.Default.mask).toBeFalse()
+ expect(OtpInput.Default.type).toEqual('numeric')
})
})
it('should take care of element either passed as a CSS selector or DOM element', () => {
fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
- const otpBySelector = new OtpInput('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
+ const otpBySelector = new OtpInput('.otp')
const otpByElement = new OtpInput(otpEl)
expect(otpBySelector._element).toEqual(otpEl)
expect(otpByElement._element).toEqual(otpEl)
})
- it('should set up inputs with correct attributes', () => {
+ it('should set up the input with correct attributes', () => {
fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
- const inputs = otpEl.querySelectorAll('input')
-
- for (const input of inputs) {
- expect(input.getAttribute('maxlength')).toEqual('1')
- expect(input.getAttribute('inputmode')).toEqual('numeric')
- expect(input.getAttribute('pattern')).toEqual('\\d*')
- }
+ const input = otpEl.querySelector('input')
- // First input should have autocomplete for OTP autofill
- expect(inputs[0].getAttribute('autocomplete')).toEqual('one-time-code')
-
- // Other inputs should have autocomplete off
- for (let i = 1; i < inputs.length; i++) {
- expect(inputs[i].getAttribute('autocomplete')).toEqual('off')
- }
+ expect(input.getAttribute('maxlength')).toEqual('6')
+ expect(input.getAttribute('inputmode')).toEqual('numeric')
+ expect(input.getAttribute('pattern')).toEqual('[0-9]*')
+ expect(input.getAttribute('autocomplete')).toEqual('one-time-code')
})
- it('should set input type to password when mask is true', () => {
- fixtureEl.innerHTML = getOtpHtml(6, 'data-bs-mask="true"')
+ it('should render one slot per character and mark the container as rendered', () => {
+ fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
- const inputs = otpEl.querySelectorAll('input')
-
- for (const input of inputs) {
- expect(input.getAttribute('type')).toEqual('password')
- }
+ expect(otpEl).toHaveClass('otp-rendered')
+ expect(otpEl.querySelectorAll('.otp-slot').length).toEqual(6)
})
- })
- describe('getValue', () => {
- it('should return empty string when no values entered', () => {
- fixtureEl.innerHTML = getOtpHtml()
+ it('should derive length from the maxlength attribute', () => {
+ fixtureEl.innerHTML = getOtpHtml('', 4)
- const otpEl = fixtureEl.querySelector('.otp-input')
- const otp = new OtpInput(otpEl)
+ const otpEl = fixtureEl.querySelector('.otp')
+ new OtpInput(otpEl) // eslint-disable-line no-new
- expect(otp.getValue()).toEqual('')
+ expect(otpEl.querySelectorAll('.otp-slot').length).toEqual(4)
})
- it('should return concatenated values from all inputs', () => {
+ it('should let the length option override the maxlength attribute', () => {
fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
- const otp = new OtpInput(otpEl)
- const inputs = otpEl.querySelectorAll('input')
-
- inputs[0].value = '1'
- inputs[1].value = '2'
- inputs[2].value = '3'
+ const otpEl = fixtureEl.querySelector('.otp')
+ const otp = new OtpInput(otpEl, { length: 4 })
+ expect(otp).toBeInstanceOf(OtpInput)
- expect(otp.getValue()).toEqual('123')
+ const input = otpEl.querySelector('input')
+ expect(input.getAttribute('maxlength')).toEqual('4')
+ expect(otpEl.querySelectorAll('.otp-slot').length).toEqual(4)
})
- })
-
- describe('setValue', () => {
- it('should set values across all inputs', () => {
- fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
- const otp = new OtpInput(otpEl)
- const inputs = otpEl.querySelectorAll('input')
+ it('should set a text inputmode and pattern for the alphanumeric type', () => {
+ fixtureEl.innerHTML = getOtpHtml('data-bs-type="alphanumeric"')
- otp.setValue('123456')
+ const otpEl = fixtureEl.querySelector('.otp')
+ new OtpInput(otpEl) // eslint-disable-line no-new
- expect(inputs[0].value).toEqual('1')
- expect(inputs[1].value).toEqual('2')
- expect(inputs[2].value).toEqual('3')
- expect(inputs[3].value).toEqual('4')
- expect(inputs[4].value).toEqual('5')
- expect(inputs[5].value).toEqual('6')
+ const input = otpEl.querySelector('input')
+ expect(input.getAttribute('inputmode')).toEqual('text')
+ expect(input.getAttribute('pattern')).toEqual('[A-Za-z0-9]*')
})
+ })
- it('should handle partial values', () => {
+ describe('value handling', () => {
+ it('should return the input value from getValue', () => {
fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
- const inputs = otpEl.querySelectorAll('input')
- otp.setValue('123')
+ expect(otp.getValue()).toEqual('')
- expect(inputs[0].value).toEqual('1')
- expect(inputs[1].value).toEqual('2')
- expect(inputs[2].value).toEqual('3')
- expect(inputs[3].value).toEqual('')
- expect(inputs[4].value).toEqual('')
- expect(inputs[5].value).toEqual('')
+ otpEl.querySelector('input').value = '123'
+ expect(otp.getValue()).toEqual('123')
})
- it('should handle numeric values', () => {
+ it('should set the value and render it into the slots', () => {
fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
- const inputs = otpEl.querySelectorAll('input')
- otp.setValue(123456)
+ otp.setValue('123456')
- expect(inputs[0].value).toEqual('1')
- expect(inputs[5].value).toEqual('6')
+ expect(otp.getValue()).toEqual('123456')
+ const slots = otpEl.querySelectorAll('.otp-slot')
+ expect(slots[0].textContent).toEqual('1')
+ expect(slots[5].textContent).toEqual('6')
})
- })
- describe('clear', () => {
- it('should clear all input values', () => {
+ it('should sanitize disallowed characters and respect the length', () => {
fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
- const inputs = otpEl.querySelectorAll('input')
- otp.setValue('123456')
- otp.clear()
+ otp.setValue('12ab34xy567890')
- for (const input of inputs) {
- expect(input.value).toEqual('')
- }
+ expect(otp.getValue()).toEqual('123456')
})
- })
- describe('focus', () => {
- it('should focus first empty input', () => {
+ it('should accept numeric values', () => {
fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
- const inputs = otpEl.querySelectorAll('input')
-
- inputs[0].value = '1'
- inputs[1].value = '2'
- otp.focus()
+ otp.setValue(123456)
- expect(document.activeElement).toEqual(inputs[2])
+ expect(otp.getValue()).toEqual('123456')
})
- it('should focus last input when all filled', () => {
+ it('should clear the value', () => {
fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
- const inputs = otpEl.querySelectorAll('input')
otp.setValue('123456')
- otp.focus()
+ otp.clear()
- expect(document.activeElement).toEqual(inputs[5])
+ expect(otp.getValue()).toEqual('')
+ expect(document.activeElement).toEqual(otpEl.querySelector('input'))
})
})
describe('input handling', () => {
- it('should auto-advance to next input on digit entry', () => {
+ it('should strip disallowed characters as they are typed', () => {
fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
- const inputs = otpEl.querySelectorAll('input')
+ const input = otpEl.querySelector('input')
- inputs[0].focus()
- inputs[0].value = '1'
- inputs[0].dispatchEvent(createEvent('input'))
+ typeInto(input, 'a1b2')
- expect(document.activeElement).toEqual(inputs[1])
+ expect(input.value).toEqual('12')
})
- it('should strip non-digit characters', () => {
+ it('should distribute a pasted/autofilled value across the slots', () => {
fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
- const inputs = otpEl.querySelectorAll('input')
+ const input = otpEl.querySelector('input')
- inputs[0].focus()
- inputs[0].value = 'a1b'
- inputs[0].dispatchEvent(createEvent('input'))
+ // A paste or SMS autofill lands as a single multi-character input event
+ typeInto(input, '123-456')
- expect(inputs[0].value).toEqual('1')
+ expect(input.value).toEqual('123456')
+ const slots = otpEl.querySelectorAll('.otp-slot')
+ expect([...slots].map(slot => slot.textContent).join('')).toEqual('123456')
})
- it('should distribute multi-character input across inputs', () => {
- fixtureEl.innerHTML = getOtpHtml()
-
- const otpEl = fixtureEl.querySelector('.otp-input')
- new OtpInput(otpEl) // eslint-disable-line no-new
- const inputs = otpEl.querySelectorAll('input')
-
- inputs[0].focus()
- // Simulate autofill that puts multiple characters in first input
- inputs[0].value = '1234'
- inputs[0].dispatchEvent(createEvent('input'))
-
- expect(inputs[0].value).toEqual('1')
- expect(inputs[1].value).toEqual('2')
- expect(inputs[2].value).toEqual('3')
- expect(inputs[3].value).toEqual('4')
- expect(document.activeElement).toEqual(inputs[3])
- })
-
- it('should not advance when entering digit in last input', () => {
- fixtureEl.innerHTML = getOtpHtml()
+ it('should keep letters for the alphanumeric type', () => {
+ fixtureEl.innerHTML = getOtpHtml('data-bs-type="alphanumeric"')
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
- const inputs = otpEl.querySelectorAll('input')
+ const input = otpEl.querySelector('input')
- inputs[5].focus()
- inputs[5].value = '9'
- inputs[5].dispatchEvent(createEvent('input'))
+ typeInto(input, 'a1B2c3')
- expect(inputs[5].value).toEqual('9')
- expect(document.activeElement).toEqual(inputs[5])
+ expect(input.value).toEqual('a1B2c3')
})
})
- describe('keydown handling', () => {
- it('should move focus to previous input on backspace when current is empty', () => {
- fixtureEl.innerHTML = getOtpHtml()
-
- const otpEl = fixtureEl.querySelector('.otp-input')
- new OtpInput(otpEl) // eslint-disable-line no-new
- const inputs = otpEl.querySelectorAll('input')
-
- inputs[0].value = '1'
- inputs[1].focus()
-
- const backspaceEvent = new KeyboardEvent('keydown', {
- key: 'Backspace',
- bubbles: true
- })
- inputs[1].dispatchEvent(backspaceEvent)
-
- expect(document.activeElement).toEqual(inputs[0])
- expect(inputs[0].value).toEqual('')
- })
-
- it('should navigate left with ArrowLeft', () => {
- fixtureEl.innerHTML = getOtpHtml()
-
- const otpEl = fixtureEl.querySelector('.otp-input')
- new OtpInput(otpEl) // eslint-disable-line no-new
- const inputs = otpEl.querySelectorAll('input')
-
- inputs[2].focus()
-
- const arrowEvent = new KeyboardEvent('keydown', {
- key: 'ArrowLeft',
- bubbles: true
- })
- inputs[2].dispatchEvent(arrowEvent)
-
- expect(document.activeElement).toEqual(inputs[1])
- })
-
- it('should navigate right with ArrowRight', () => {
- fixtureEl.innerHTML = getOtpHtml()
-
- const otpEl = fixtureEl.querySelector('.otp-input')
- new OtpInput(otpEl) // eslint-disable-line no-new
- const inputs = otpEl.querySelectorAll('input')
-
- inputs[2].focus()
-
- const arrowEvent = new KeyboardEvent('keydown', {
- key: 'ArrowRight',
- bubbles: true
- })
- inputs[2].dispatchEvent(arrowEvent)
-
- expect(document.activeElement).toEqual(inputs[3])
- })
-
- it('should not navigate left when at first input', () => {
- fixtureEl.innerHTML = getOtpHtml()
-
- const otpEl = fixtureEl.querySelector('.otp-input')
- new OtpInput(otpEl) // eslint-disable-line no-new
- const inputs = otpEl.querySelectorAll('input')
-
- inputs[0].focus()
-
- const arrowEvent = new KeyboardEvent('keydown', {
- key: 'ArrowLeft',
- bubbles: true
- })
- inputs[0].dispatchEvent(arrowEvent)
-
- expect(document.activeElement).toEqual(inputs[0])
- })
-
- it('should not navigate right when at last input', () => {
- fixtureEl.innerHTML = getOtpHtml()
-
- const otpEl = fixtureEl.querySelector('.otp-input')
- new OtpInput(otpEl) // eslint-disable-line no-new
- const inputs = otpEl.querySelectorAll('input')
-
- inputs[5].focus()
-
- const arrowEvent = new KeyboardEvent('keydown', {
- key: 'ArrowRight',
- bubbles: true
- })
- inputs[5].dispatchEvent(arrowEvent)
-
- expect(document.activeElement).toEqual(inputs[5])
- })
+ describe('mask', () => {
+ it('should render the mask character but keep the real value', () => {
+ fixtureEl.innerHTML = getOtpHtml('data-bs-mask="true"')
- it('should shift values left on Delete key', () => {
- fixtureEl.innerHTML = getOtpHtml()
-
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
- const inputs = otpEl.querySelectorAll('input')
otp.setValue('123456')
- inputs[2].focus()
- const deleteEvent = new KeyboardEvent('keydown', {
- key: 'Delete',
- bubbles: true
- })
- inputs[2].dispatchEvent(deleteEvent)
-
- expect(inputs[0].value).toEqual('1')
- expect(inputs[1].value).toEqual('2')
- expect(inputs[2].value).toEqual('4')
- expect(inputs[3].value).toEqual('5')
- expect(inputs[4].value).toEqual('6')
- expect(inputs[5].value).toEqual('')
+ expect(otp.getValue()).toEqual('123456')
+ const slots = otpEl.querySelectorAll('.otp-slot')
+ expect([...slots].every(slot => slot.textContent === '•')).toBeTrue()
})
- it('should not move focus on backspace when current input has value', () => {
- fixtureEl.innerHTML = getOtpHtml()
+ it('should not turn the input into a password field', () => {
+ fixtureEl.innerHTML = getOtpHtml('data-bs-mask="true"')
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
- const inputs = otpEl.querySelectorAll('input')
-
- inputs[1].value = '5'
- inputs[1].focus()
- const backspaceEvent = new KeyboardEvent('keydown', {
- key: 'Backspace',
- bubbles: true
- })
- inputs[1].dispatchEvent(backspaceEvent)
-
- // Should stay on same input (browser handles clearing the value)
- expect(document.activeElement).toEqual(inputs[1])
+ expect(otpEl.querySelector('input').type).toEqual('text')
})
})
- describe('paste handling', () => {
- it('should distribute pasted digits across inputs', () => {
- fixtureEl.innerHTML = getOtpHtml()
-
- const otpEl = fixtureEl.querySelector('.otp-input')
- new OtpInput(otpEl) // eslint-disable-line no-new
- const inputs = otpEl.querySelectorAll('input')
-
- inputs[0].focus()
-
- const pasteEvent = new ClipboardEvent('paste', {
- bubbles: true,
- clipboardData: new DataTransfer()
- })
- pasteEvent.clipboardData.setData('text', '123456')
- inputs[0].dispatchEvent(pasteEvent)
-
- expect(inputs[0].value).toEqual('1')
- expect(inputs[1].value).toEqual('2')
- expect(inputs[2].value).toEqual('3')
- expect(inputs[3].value).toEqual('4')
- expect(inputs[4].value).toEqual('5')
- expect(inputs[5].value).toEqual('6')
- })
-
- it('should strip non-digits from pasted content', () => {
- fixtureEl.innerHTML = getOtpHtml()
+ describe('separators', () => {
+ it('should render separators between configured groups', () => {
+ fixtureEl.innerHTML = getOtpHtml('data-bs-groups="[3,3]"')
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
- const inputs = otpEl.querySelectorAll('input')
-
- inputs[0].focus()
- const pasteEvent = new ClipboardEvent('paste', {
- bubbles: true,
- clipboardData: new DataTransfer()
- })
- pasteEvent.clipboardData.setData('text', 'abc123def456')
- inputs[0].dispatchEvent(pasteEvent)
-
- expect(inputs[0].value).toEqual('1')
- expect(inputs[1].value).toEqual('2')
- expect(inputs[2].value).toEqual('3')
- expect(inputs[3].value).toEqual('4')
- expect(inputs[4].value).toEqual('5')
- expect(inputs[5].value).toEqual('6')
+ expect(otpEl.querySelectorAll('.otp-separator').length).toEqual(1)
+ expect(otpEl.querySelectorAll('.otp-slot').length).toEqual(6)
})
})
describe('events', () => {
- it('should trigger complete event when all inputs filled', () => {
+ it('should trigger complete event when the value is full', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
- otpEl.addEventListener('complete.bs.otp-input', event => {
+ otpEl.addEventListener('complete.bs.otpInput', event => {
expect(event.value).toEqual('123456')
resolve()
})
})
})
- it('should trigger input event on each input change', () => {
+ it('should trigger input event with the current value on each change', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
new OtpInput(otpEl) // eslint-disable-line no-new
- const inputs = otpEl.querySelectorAll('input')
+ const input = otpEl.querySelector('input')
- otpEl.addEventListener('input.bs.otp-input', event => {
- expect(event.value).toEqual('1')
- expect(event.index).toEqual(0)
+ otpEl.addEventListener('input.bs.otpInput', event => {
+ expect(event.value).toEqual('12')
resolve()
})
- inputs[0].value = '1'
- inputs[0].dispatchEvent(createEvent('input'))
+ typeInto(input, '12')
})
})
})
describe('dispose', () => {
- it('should dispose the instance', () => {
+ it('should dispose the instance and remove generated markup', () => {
fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
expect(OtpInput.getInstance(otpEl)).not.toBeNull()
otp.dispose()
expect(OtpInput.getInstance(otpEl)).toBeNull()
+ expect(fixtureEl.querySelector('.otp-slots')).toBeNull()
+ expect(fixtureEl.querySelector('.otp').classList.contains('otp-rendered')).toBeFalse()
})
})
it('should return otp-input instance', () => {
fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
expect(OtpInput.getInstance(otpEl)).toEqual(otp)
it('should return existing instance', () => {
fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
const otp = new OtpInput(otpEl)
expect(OtpInput.getOrCreateInstance(otpEl)).toEqual(otp)
it('should create new instance when none exists', () => {
fixtureEl.innerHTML = getOtpHtml()
- const otpEl = fixtureEl.querySelector('.otp-input')
+ const otpEl = fixtureEl.querySelector('.otp')
expect(OtpInput.getInstance(otpEl)).toBeNull()
expect(OtpInput.getOrCreateInstance(otpEl)).toBeInstanceOf(OtpInput)
@use "../functions" as *;
+@use "../mixins/border-radius" as *;
+@use "../mixins/box-shadow" as *;
+@use "../mixins/focus-ring" as *;
@use "../mixins/tokens" as *;
+@use "../mixins/transition" as *;
$otp-tokens: () !default;
// scss-docs-start otp-tokens
+// stylelint-disable custom-property-no-missing-var-function
// stylelint-disable-next-line scss/dollar-variable-default
$otp-tokens: defaults(
(
--otp-size: var(--btn-input-lg-min-height),
--otp-font-size: var(--btn-input-font-size),
--otp-gap: .5rem,
+ --otp-slot-fg: var(--btn-input-fg),
+ --otp-slot-bg: var(--btn-input-bg),
+ --otp-slot-border-width: var(--border-width),
+ --otp-slot-border-color: var(--border-color),
+ --otp-slot-border-radius: var(--radius-5),
),
$otp-tokens
);
// scss-docs-end otp-tokens
+// stylelint-enable custom-property-no-missing-var-function
// scss-docs-start otp-sizes
$otp-sizes: () !default;
.otp {
@include tokens($otp-tokens);
+ position: relative;
+ display: flex;
+ }
+
+ // A single real input backs the whole control. Once the JS renders the
+ // visual slots (`.otp-rendered`), the input becomes a transparent overlay
+ // that captures all interaction while the slots display the value.
+ .otp-rendered .otp-input {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ color: transparent;
+ text-align: center;
+ cursor: default;
+ caret-color: transparent;
+ background-color: transparent;
+ border: 0;
+ outline: 0;
+ box-shadow: none;
+
+ // Never reveal the underlying characters, even on selection
+ &::selection {
+ color: transparent;
+ background-color: transparent;
+ }
+ }
+
+ .otp-slots {
display: inline-flex;
gap: var(--otp-gap);
+ pointer-events: none; // let clicks fall through to the input overlay
+ }
+
+ .otp-slot {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--otp-size);
+ min-height: var(--otp-size);
+ font-size: var(--otp-font-size);
+ font-weight: 500;
+ line-height: 1;
+ color: var(--otp-slot-fg);
+ background-color: var(--otp-slot-bg);
+ border: var(--otp-slot-border-width) solid var(--otp-slot-border-color);
+ @include border-radius(var(--otp-slot-border-radius));
+ @include box-shadow(var(--box-shadow-inset));
+ @include transition(border-color .15s ease-in-out, box-shadow .15s ease-in-out);
+ }
- .form-control {
- width: var(--otp-size);
- min-height: var(--otp-size);
- padding: 0;
- font-size: var(--otp-font-size);
- font-weight: 500;
- line-height: 1;
- text-align: center;
-
- // Remove default number spinners
- &::-webkit-outer-spin-button,
- &::-webkit-inner-spin-button {
- margin: 0;
- appearance: none;
- }
-
- &[type="number"] {
- appearance: textfield;
- }
-
- &:focus,
- &:focus-visible {
- z-index: 1;
- }
+ // The slot at the caret gets the focus ring; empty active slots show a
+ // blinking caret so the entry point is obvious.
+ .otp-slot-active {
+ --focus-ring-offset: -1px;
+ z-index: 1;
+ @include focus-ring(true);
+
+ &:not(.otp-slot-filled)::after {
+ width: 1px;
+ height: 50%;
+ content: "";
+ background-color: var(--otp-slot-fg);
+ animation: otp-caret-blink 1s step-end infinite;
}
}
- // When used with .input-group, disable the gap and prevent inputs from stretching
- .otp.input-group {
+ // Disabled state mirrors disabled form controls
+ .otp-input:disabled ~ .otp-slots .otp-slot {
+ background-color: var(--bg-2);
+ }
+
+ // Connected slots share borders for a single cohesive field
+ .otp-connected .otp-slots {
gap: 0;
- width: auto; // Override input-group's width: 100%
+ }
+ .otp-connected .otp-slot {
+ border-radius: 0; // stylelint-disable-line property-disallowed-list
- .form-control {
- flex: 0 0 auto; // Don't grow or shrink, use fixed width
+ &:not(:first-child) {
+ margin-inline-start: calc(var(--otp-slot-border-width) * -1);
+ }
+ &:first-child {
+ @include border-start-radius(var(--otp-slot-border-radius));
+ }
+ &:last-child {
+ @include border-end-radius(var(--otp-slot-border-radius));
}
}
color: var(--fg-4);
user-select: none;
}
+
+ // OTP input sizing — keep in sync with `$form-control-sizes`.
+ @each $size, $_ in $otp-sizes {
+ .otp-#{$size} {
+ --otp-size: var(--btn-input-#{$size}-min-height);
+ --otp-font-size: var(--btn-input-#{$size}-font-size);
+ }
+ }
+}
+
+@keyframes otp-caret-blink {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0;
+ }
}
}
}
- // OTP — validation applies to the wrapper; inner .form-control inherits.
+ // OTP — validation applies to the wrapper; the visual slots inherit the state.
.otp {
@include form-validation-state-selector($state) {
- .form-control {
- --control-border-color: var(--#{$theme}-border);
+ .otp-slot {
+ --otp-slot-border-color: var(--#{$theme}-border);
}
- .form-control:focus {
+ .otp-slot-active {
@include focus-ring(true, $color: var(--#{$theme}-focus-ring));
- --control-border-color: var(--#{$theme}-border);
}
}
}
---
title: OTP input
-description: Create connected input fields for one-time passwords, PIN codes, and verification codes with automatic focus advancement.
+description: Collect one-time passwords, PIN codes, and verification codes with a single accessible input rendered as separate digit slots.
toc: true
css_layer: components
js: required
## Overview
-OTP (One-Time Password) inputs are a common pattern for two-factor authentication, verification codes, and PIN entry. Bootstrap's OTP input component provides:
+OTP (One-Time Password) inputs are a common pattern for two-factor authentication, verification codes, and PIN entry. Bootstrap's OTP input is built around a **single real text field** that is visually rendered as separate digit slots. This keeps the control simple and fully accessible—screen readers, password managers, and SMS autofill all treat it like any other text input—while still giving you the familiar multi-box appearance.
-- **Auto-advance**: Focus moves to the next input after entering a digit
-- **Backspace navigation**: Pressing backspace in an empty field moves to the previous field
-- **Paste support**: Paste a full code and it distributes across all inputs
-- **Browser autofill**: Supports the `autocomplete="one-time-code"` attribute for SMS/email code autofill
-- **Keyboard navigation**: Use arrow keys to move between inputs
+- **One accessible control**: a single `<input>` backs the whole field, so assistive technology announces one field, not one per digit
+- **Browser autofill**: supports `autocomplete="one-time-code"` for SMS/email code autofill
+- **Paste support**: paste a full code—even a formatted one like `123-456`—and the extra characters are stripped automatically
+- **Keyboard navigation**: all native text editing (typing, arrows, backspace, delete, selection) works out of the box
+- **Masking and types**: optionally mask the value, and restrict input to numeric, alphanumeric, or alphabetic characters
-OTP input is built on [form controls]([[docsref:/forms/form-control]]) and, when using connected inputs, leverages our [input group]([[docsref:/forms/input-group]]) styles.
+OTP input is built on [form controls]([[docsref:/forms/form-control]]) and shares its sizing tokens.
-## Basic example
+## How it works
-Wrap your inputs in a container with `.otp` and add `data-bs-otp` to enable the JavaScript behavior. Each input should have `.form-control` for styling and be a single-character field.
+Wrap a single `<input>` in a container with `.otp` and add `data-bs-otp` to enable the JavaScript. The plugin reads the input's `maxlength` to determine how many slots to render, hides the real input behind transparent overlay styling, and draws one `.otp-slot` per character. Give the input an accessible name with `aria-label` or an associated `<label>`.
<Example code={`<div class="otp" data-bs-otp>
- <input type="text" class="form-control" aria-label="Digit 1">
- <input type="text" class="form-control" aria-label="Digit 2">
- <input type="text" class="form-control" aria-label="Digit 3">
- <input type="text" class="form-control" aria-label="Digit 4">
- <input type="text" class="form-control" aria-label="Digit 5">
- <input type="text" class="form-control" aria-label="Digit 6">
+ <input type="text" class="otp-input" maxlength="6" aria-label="Verification code">
</div>`} />
## Connected inputs
-Add `.input-group` to visually connect the inputs into a single cohesive field, leveraging Bootstrap's [input group]([[docsref:/forms/input-group]]) styles. We override the `width` to prevent stretching the inputs.
+Add `.otp-connected` to merge the slots into a single cohesive field with shared borders.
-<Example code={`<div class="otp input-group" data-bs-otp>
- <input type="text" class="form-control" aria-label="Digit 1">
- <input type="text" class="form-control" aria-label="Digit 2">
- <input type="text" class="form-control" aria-label="Digit 3">
- <input type="text" class="form-control" aria-label="Digit 4">
- <input type="text" class="form-control" aria-label="Digit 5">
- <input type="text" class="form-control" aria-label="Digit 6">
+<Example code={`<div class="otp otp-connected" data-bs-otp>
+ <input type="text" class="otp-input" maxlength="6" aria-label="Verification code">
</div>`} />
## Four-digit PIN
-You can use any number of inputs you want—the plugin will automatically detect the number of inputs and adjust accordingly. For example, you can use fewer inputs for shorter codes like 4-digit PINs.
+Set the input's `maxlength` to control the number of slots—use fewer for shorter codes like a 4-digit PIN.
-<Example code={`<div class="otp input-group" data-bs-otp>
- <input type="text" class="form-control" aria-label="Digit 1">
- <input type="text" class="form-control" aria-label="Digit 2">
- <input type="text" class="form-control" aria-label="Digit 3">
- <input type="text" class="form-control" aria-label="Digit 4">
+<Example code={`<div class="otp otp-connected" data-bs-otp>
+ <input type="text" class="otp-input" maxlength="4" aria-label="PIN">
</div>`} />
## With separator
-Add a `.otp-separator` element between inputs to create grouped codes like "123-456". The separator is purely visual—the plugin ignores non-input elements.
+Use `data-bs-groups` with an array of group sizes to split the slots into groups separated by a visual divider—handy for codes like "123-456". The separator is decorative and never becomes part of the value.
-<Example code={`<div class="otp" data-bs-otp>
- <input type="text" class="form-control" aria-label="Digit 1">
- <input type="text" class="form-control" aria-label="Digit 2">
- <input type="text" class="form-control" aria-label="Digit 3">
- <span class="otp-separator">–</span>
- <input type="text" class="form-control" aria-label="Digit 4">
- <input type="text" class="form-control" aria-label="Digit 5">
- <input type="text" class="form-control" aria-label="Digit 6">
+<Example code={`<div class="otp" data-bs-otp data-bs-groups="[3,3]">
+ <input type="text" class="otp-input" maxlength="6" aria-label="Verification code">
</div>`} />
-You can also use separators with connected inputs by wrapping each group in a nested `.input-group`:
+## Sizes
-<Example code={`<div class="otp" data-bs-otp>
- <div class="input-group">
- <input type="text" class="form-control" aria-label="Digit 1">
- <input type="text" class="form-control" aria-label="Digit 2">
- <input type="text" class="form-control" aria-label="Digit 3">
- </div>
- <span class="otp-separator">–</span>
- <div class="input-group">
- <input type="text" class="form-control" aria-label="Digit 4">
- <input type="text" class="form-control" aria-label="Digit 5">
- <input type="text" class="form-control" aria-label="Digit 6">
- </div>
+Add `.otp-sm` or `.otp-lg` to the container to change the slot size.
+
+<Example code={`<div class="otp otp-sm mb-3" data-bs-otp>
+ <input type="text" class="otp-input" maxlength="6" aria-label="Small verification code">
+ </div>
+ <div class="otp otp-lg" data-bs-otp>
+ <input type="text" class="otp-input" maxlength="6" aria-label="Large verification code">
+ </div>`} />
+
+## Alphanumeric and masking
+
+By default only digits are accepted. Set `data-bs-type` to `alphanumeric` or `alpha` to accept letters, and `data-bs-mask="true"` to obscure the entered value while keeping it a normal text field (no `type="password"`, so the numeric keyboard and autofill still work).
+
+<Example code={`<div class="otp otp-connected mb-3" data-bs-otp data-bs-type="alphanumeric">
+ <input type="text" class="otp-input" maxlength="6" aria-label="Alphanumeric code">
+ </div>
+ <div class="otp otp-connected" data-bs-otp data-bs-mask="true">
+ <input type="text" class="otp-input" maxlength="6" aria-label="Masked code">
</div>`} />
## Disabled
-Add the `disabled` attribute to each input to prevent interaction.
+Add the `disabled` attribute to the input to prevent interaction.
-<Example code={`<div class="otp input-group" data-bs-otp>
- <input type="text" class="form-control" aria-label="Digit 1" disabled>
- <input type="text" class="form-control" aria-label="Digit 2" disabled>
- <input type="text" class="form-control" aria-label="Digit 3" disabled>
- <input type="text" class="form-control" aria-label="Digit 4" disabled>
- <input type="text" class="form-control" aria-label="Digit 5" disabled>
- <input type="text" class="form-control" aria-label="Digit 6" disabled>
+<Example code={`<div class="otp otp-connected" data-bs-otp>
+ <input type="text" class="otp-input" maxlength="6" aria-label="Verification code" disabled>
</div>`} />
## Validation
Add `.is-valid` or `.is-invalid` to the container to show validation states.
-<Example code={`<div class="otp input-group is-valid mb-3" data-bs-otp>
- <input type="text" class="form-control" value="1" aria-label="Digit 1">
- <input type="text" class="form-control" value="2" aria-label="Digit 2">
- <input type="text" class="form-control" value="3" aria-label="Digit 3">
- <input type="text" class="form-control" value="4" aria-label="Digit 4">
- <input type="text" class="form-control" value="5" aria-label="Digit 5">
- <input type="text" class="form-control" value="6" aria-label="Digit 6">
+<Example code={`<div class="otp otp-connected is-valid mb-3" data-bs-otp>
+ <input type="text" class="otp-input" maxlength="6" value="123456" aria-label="Verification code">
</div>
- <div class="otp input-group is-invalid" data-bs-otp>
- <input type="text" class="form-control" value="1" aria-label="Digit 1">
- <input type="text" class="form-control" value="2" aria-label="Digit 2">
- <input type="text" class="form-control" value="3" aria-label="Digit 3">
- <input type="text" class="form-control" aria-label="Digit 4">
- <input type="text" class="form-control" aria-label="Digit 5">
- <input type="text" class="form-control" aria-label="Digit 6">
+ <div class="otp otp-connected is-invalid" data-bs-otp>
+ <input type="text" class="otp-input" maxlength="6" value="123" aria-label="Verification code">
</div>`} />
-## With form labels
+## With form label
-Use a form label and help text for better accessibility.
+Associate a `<label>` with the input and add help text with `aria-describedby` for better accessibility.
<Example code={`<div class="vstack gap-2">
- <label class="form-label" id="otpLabel">Verification code</label>
- <div class="otp" data-bs-otp aria-labelledby="otpLabel" aria-describedby="otpHelp">
- <input type="text" class="form-control" aria-label="Digit 1">
- <input type="text" class="form-control" aria-label="Digit 2">
- <input type="text" class="form-control" aria-label="Digit 3">
- <input type="text" class="form-control" aria-label="Digit 4">
- <input type="text" class="form-control" aria-label="Digit 5">
- <input type="text" class="form-control" aria-label="Digit 6">
+ <label class="form-label" for="otpCode">Verification code</label>
+ <div class="otp" data-bs-otp>
+ <input type="text" class="otp-input" id="otpCode" maxlength="6" aria-describedby="otpHelp">
</div>
<div id="otpHelp" class="form-text">Enter the 6-digit code sent to your phone.</div>
</div>`} />
### Via data attributes
-Add `data-bs-otp` to your container element to automatically initialize the OTP input behavior.
+Add `data-bs-otp` to a container wrapping a single `<input>` to automatically initialize the OTP input behavior.
<BsTable>
| Attribute | Description |
| --- | --- |
-| `data-bs-otp` | Initializes the OTP input on the container wrapping the digit fields. |
-| `data-bs-mask` | When `true`, uses password fields to mask entered digits. |
+| `data-bs-otp` | Initializes the OTP input on the container. |
+| `data-bs-length` | Number of slots. Defaults to the input's `maxlength`. |
+| `data-bs-type` | `numeric` (default), `alphanumeric`, or `alpha`. |
+| `data-bs-mask` | When `true`, masks the entered value with a dot in each slot. |
+| `data-bs-groups` | Array of group sizes (e.g. `[3,3]`) to insert separators between groups. |
+| `data-bs-separator` | Character used for the separator between groups. |
</BsTable>
```html
<div class="otp" data-bs-otp>
- <input type="text" class="form-control">
- <input type="text" class="form-control">
- <input type="text" class="form-control">
- <input type="text" class="form-control">
+ <input type="text" class="otp-input" maxlength="6" aria-label="Verification code">
</div>
```
<BsTable>
| Name | Type | Default | Description |
| --- | --- | --- | --- |
-| `mask` | boolean | `false` | If `true`, inputs will use `type="password"` to hide the entered values. |
+| `length` | number, null | `null` | Number of slots. When `null`, it is read from the input's `maxlength`. |
+| `type` | string | `'numeric'` | Allowed characters: `numeric`, `alphanumeric`, or `alpha`. Sets `inputmode` and `pattern` accordingly. |
+| `mask` | boolean | `false` | If `true`, slots display a dot (`•`) instead of the entered character. |
+| `groups` | array, null | `null` | Group sizes used to insert separators (e.g. `[3, 3]`). |
+| `separator` | string | `'·'` | Character rendered between groups. |
</BsTable>
### Methods
| Method | Description |
| --- | --- |
| `getValue()` | Returns the complete OTP value as a string. |
-| `setValue(value)` | Sets the OTP value, distributing characters across inputs. |
-| `clear()` | Clears all inputs and focuses the first one. |
-| `focus()` | Focuses the first empty input, or the last input if all are filled. |
-| `dispose()` | Destroys the component instance. |
+| `setValue(value)` | Sets the value, sanitizing it against the configured type. |
+| `clear()` | Clears the value and focuses the input. |
+| `focus()` | Focuses the input and places the caret after the last character. |
+| `dispose()` | Destroys the component instance and removes the generated slots. |
</BsTable>
```js
// Set a value programmatically
otpInput.setValue('654321')
-// Clear all inputs
+// Clear the value
otpInput.clear()
```
<BsTable>
| Event | Description |
| --- | --- |
-| `complete.bs.otp` | Fired when all inputs are filled. The event's `value` property contains the complete code. |
-| `input.bs.otp` | Fired on each input change. Includes `value` (current combined value) and `index` (changed input index). |
+| `complete.bs.otpInput` | Fired when every slot is filled. The event's `value` property contains the complete code. |
+| `input.bs.otpInput` | Fired on each change. The event's `value` property contains the current value. |
</BsTable>
```js
const otpElement = document.querySelector('.otp')
-otpElement.addEventListener('complete.bs.otp', event => {
+otpElement.addEventListener('complete.bs.otpInput', event => {
console.log('OTP complete:', event.value)
// Submit the form or validate the code
})
-otpElement.addEventListener('input.bs.otp', event => {
- console.log('Current value:', event.value, 'Changed index:', event.index)
+otpElement.addEventListener('input.bs.otpInput', event => {
+ console.log('Current value:', event.value)
})
```
## Accessibility
-- Each input should have an `aria-label` describing its position (e.g., "Digit 1")
-- Use a form label with `aria-labelledby` on the container
-- Add help text with `aria-describedby` for additional context
-- The component automatically sets `inputmode="numeric"` for mobile keyboards
-- Arrow keys allow navigation between inputs
+Because the control is a single text input, it exposes one accessible name, role, and value—so assistive technology announces it as one ordinary text field instead of one edit field per digit. The visual slots are decorative and marked `aria-hidden`, and no per-digit `aria-label`s are needed.
+
+- Give the input a single accessible name with `aria-label` or an associated `<label>`, and add context with `aria-describedby`.
+- The component sets `autocomplete="one-time-code"` and an appropriate `inputmode` so codes can be autofilled and the right mobile keyboard appears. This supports [WCAG 1.3.5 Identify Input Purpose](https://www.w3.org/WAI/WCAG22/Understanding/identify-input-purpose.html).
+- Copy, paste, and autofill are never blocked, which keeps the control compliant with [WCAG 3.3.8 Accessible Authentication (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html)—a single input is also what password managers and SMS autofill target reliably.
+- For the verification flow itself, give codes a generous expiry ([WCAG 2.2.1 Timing Adjustable](https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html)) and avoid forcing users to re-enter a code already provided in the same process ([WCAG 3.3.7 Redundant Entry](https://www.w3.org/WAI/WCAG22/Understanding/redundant-entry.html)).
## CSS
- **Stepper** — new `.stepper` component for multi-step workflows with `.stepper-item` and `.stepper-horizontal` variant. CSS-only.
- **Avatar** — new `.avatar` component with sizes (`.avatar-xs` through `.avatar-xl`), status indicators (`.avatar-status .status-online|offline|busy|away`), subtle variant, and `.avatar-stack` for grouped avatars.
- **Chip and Chip Input** — new `.chip` component for tags/tokens and `.chip-input` for interactive chip entry. `Chips` JavaScript plugin with events: `add.bs.chips`, `remove.bs.chips`, `change.bs.chips`.
-- **OTP Input** — new `.otp` component for one-time password fields. `OtpInput` JavaScript plugin with events: `input.bs.otp-input`, `complete.bs.otp-input`.
+- **OTP Input** — new `.otp` component for one-time password fields. Built on a single `<input>` rendered as separate digit slots for full accessibility. `OtpInput` JavaScript plugin with events: `input.bs.otpInput`, `complete.bs.otpInput` (both expose `event.value`).
- **Password Strength** — `Strength` JavaScript plugin for password strength metering with `strengthChange.bs.strength` event.
- **Toggler** — `Toggler` JavaScript plugin for toggling classes or attributes on elements via `data-bs-toggle="toggler"`.
- **Datepicker** — `Datepicker` JavaScript plugin built on Vanilla Calendar Pro, with events: `change.bs.datepicker`, `show.bs.datepicker`, `hide.bs.datepicker`.