Skip to main contentSkip to docs navigation

Individually styled digit fields for one-time password, PIN, and verification code entry, with built-in auto-advance, paste handling, and keyboard navigation.

@layer components
Requires JavaScript
Depends onForm Input
Context tokens

Overview

OTP (One-Time Password) inputs group individual single-character fields into a sequential entry control for verification codes, PINs, and two-factor authentication.

Chassis CSS's OTP input component extends form inputs with automatic focus advancement after each digit, backspace navigation to previous fields, and full-code paste distribution across inputs. The first field receives autocomplete="one-time-code" for SMS and email code autofill on supported browsers. Arrow keys provide additional navigation between fields.

For visually connected inputs, the component integrates with input groups to join the fields into a single bordered control.

Basic example

Wrap inputs in a container with .form-otp and add data-cx-otp to enable the JavaScript behavior. Each input should have .form-input for styling and be a single-character field.

HTML
<div class="form-otp" data-cx-otp role="group" aria-label="Verification code">
  <input type="text" class="form-input" aria-label="Digit 1">
  <input type="text" class="form-input" aria-label="Digit 2">
  <input type="text" class="form-input" aria-label="Digit 3">
  <input type="text" class="form-input" aria-label="Digit 4">
  <input type="text" class="form-input" aria-label="Digit 5">
  <input type="text" class="form-input" aria-label="Digit 6">
</div>

Connected inputs

Add .input-group to visually connect the inputs into a single cohesive field using input group styles.

HTML
<div class="form-otp input-group" data-cx-otp role="group" aria-label="Verification code">
  <input type="text" class="form-input" aria-label="Digit 1">
  <input type="text" class="form-input" aria-label="Digit 2">
  <input type="text" class="form-input" aria-label="Digit 3">
  <input type="text" class="form-input" aria-label="Digit 4">
  <input type="text" class="form-input" aria-label="Digit 5">
  <input type="text" class="form-input" aria-label="Digit 6">
</div>

Four-digit PIN

Use any number of inputs—the component automatically adapts to however many are present. Use fewer inputs for shorter codes like 4-digit PINs.

HTML
<div class="form-otp input-group" data-cx-otp role="group" aria-label="Verification code">
  <input type="text" class="form-input" aria-label="Digit 1">
  <input type="text" class="form-input" aria-label="Digit 2">
  <input type="text" class="form-input" aria-label="Digit 3">
  <input type="text" class="form-input" aria-label="Digit 4">
</div>

With separator

Add a .form-otp-separator element between inputs to create grouped codes like "123-456". The separator is purely visual—the component ignores non-input elements.

HTML
<div class="form-otp" data-cx-otp role="group" aria-label="Verification code">
  <input type="text" class="form-input" aria-label="Digit 1">
  <input type="text" class="form-input" aria-label="Digit 2">
  <input type="text" class="form-input" aria-label="Digit 3">
  <span class="form-otp-separator">–</span>
  <input type="text" class="form-input" aria-label="Digit 4">
  <input type="text" class="form-input" aria-label="Digit 5">
  <input type="text" class="form-input" aria-label="Digit 6">
</div>

It is possible to use separators with connected inputs by wrapping each group in a nested .input-group:

HTML
<div class="form-otp" data-cx-otp role="group" aria-label="Verification code">
  <div class="input-group">
    <input type="text" class="form-input" aria-label="Digit 1">
    <input type="text" class="form-input" aria-label="Digit 2">
    <input type="text" class="form-input" aria-label="Digit 3">
  </div>
  <span class="form-otp-separator">–</span>
  <div class="input-group">
    <input type="text" class="form-input" aria-label="Digit 4">
    <input type="text" class="form-input" aria-label="Digit 5">
    <input type="text" class="form-input" aria-label="Digit 6">
  </div>
</div>

Disabled

Add the disabled attribute to each input to prevent interaction.

HTML
<div class="form-otp input-group" data-cx-otp role="group" aria-label="Verification code">
  <input type="text" class="form-input" aria-label="Digit 1" disabled>
  <input type="text" class="form-input" aria-label="Digit 2" disabled>
  <input type="text" class="form-input" aria-label="Digit 3" disabled>
  <input type="text" class="form-input" aria-label="Digit 4" disabled>
  <input type="text" class="form-input" aria-label="Digit 5" disabled>
  <input type="text" class="form-input" aria-label="Digit 6" disabled>
</div>

Validation

Add .is-valid or .is-invalid to the container to show validation states.

HTML
<div class="form-otp input-group is-valid mb-medium" data-cx-otp role="group" aria-label="Verification code">
  <input type="text" class="form-input" value="1" aria-label="Digit 1">
  <input type="text" class="form-input" value="2" aria-label="Digit 2">
  <input type="text" class="form-input" value="3" aria-label="Digit 3">
  <input type="text" class="form-input" value="4" aria-label="Digit 4">
  <input type="text" class="form-input" value="5" aria-label="Digit 5">
  <input type="text" class="form-input" value="6" aria-label="Digit 6">
</div>
<div class="form-otp input-group is-invalid" data-cx-otp role="group" aria-label="Verification code">
  <input type="text" class="form-input" value="1" aria-label="Digit 1">
  <input type="text" class="form-input" value="2" aria-label="Digit 2">
  <input type="text" class="form-input" value="3" aria-label="Digit 3">
  <input type="text" class="form-input" aria-label="Digit 4">
  <input type="text" class="form-input" aria-label="Digit 5">
  <input type="text" class="form-input" aria-label="Digit 6">
</div>

With form labels

Use a form label and help text for better accessibility.

Enter the 6-digit code sent to your phone.
HTML
<div class="form-field">
  <label class="form-label">Verification code</label>
  <div class="form-otp" data-cx-otp role="group" aria-label="Verification code" aria-describedby="otpHelp">
    <input type="text" class="form-input" aria-label="Digit 1">
    <input type="text" class="form-input" aria-label="Digit 2">
    <input type="text" class="form-input" aria-label="Digit 3">
    <input type="text" class="form-input" aria-label="Digit 4">
    <input type="text" class="form-input" aria-label="Digit 5">
    <input type="text" class="form-input" aria-label="Digit 6">
  </div>
  <div id="otpHelp" class="form-help">Enter the 6-digit code sent to your phone.</div>
</div>

Usage

Via data attributes

Add data-cx-otp to your container element to automatically initialize the OTP input behavior.

AttributeDescription
data-cx-otpInitializes the OTP input on the container wrapping the digit fields.
data-cx-maskWhen true, uses password fields to mask entered digits.
HTML
<div class="form-otp" data-cx-otp role="group" aria-label="Verification code">
  <input type="text" class="form-input">
  <input type="text" class="form-input">
  <input type="text" class="form-input">
  <input type="text" class="form-input">
</div>

Via JavaScript

Initialize manually with JavaScript:

JavaScript
const otpElement = document.querySelector('.form-otp')
const otpInput = new OtpInput(otpElement)

Options

Options can be passed via data attributes or JavaScript:

NameTypeDefaultDescription
maskbooleanfalseIf true, inputs will use type="password" to hide the entered values.

Methods

MethodDescription
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.
JavaScript
const otpElement = document.querySelector('.form-otp')
const otpInput = OtpInput.getOrCreateInstance(otpElement)

// Get the current value
console.log(otpInput.getValue()) // "123456"

// Set a value programmatically
otpInput.setValue('654321')

// Clear all inputs
otpInput.clear()

Events

EventDescription
complete.cx.otp-inputFired when all inputs are filled. The event's value property contains the complete code.
input.cx.otp-inputFired on each input change. Includes value (current combined value) and index (changed input index).
JavaScript
const otpElement = document.querySelector('.form-otp')

otpElement.addEventListener('complete.cx.otp-input', event => {
  console.log('OTP complete:', event.value)
  // Submit the form or validate the code
})

otpElement.addEventListener('input.cx.otp-input', event => {
  console.log('Current value:', event.value, 'Changed index:', event.index)
})

Accessibility

Individual input labels

Each digit input has no visible label, so each one needs an aria-label describing its position — for example aria-label="Digit 1". Without this, screen readers have no way to distinguish between the fields.

Container labeling

Label the OTP group as a whole using a visible <label> linked via aria-labelledby on the container, so screen readers announce the group's purpose before reading the individual inputs:

HTML
<label class="form-label" id="otpLabel">Verification code</label>
<div class="form-otp" data-cx-otp aria-labelledby="otpLabel">
  ...
</div>

If no visible label is appropriate, use aria-label directly on the container instead.

Help text

Associate instructional text with the group using aria-describedby on the container:

HTML
<div class="form-otp" data-cx-otp aria-describedby="otpHelp">
  ...
</div>
<div id="otpHelp" class="form-help">Enter the 6-digit code sent to your phone.</div>

Keyboard and mobile

The component automatically sets inputmode="numeric" and pattern="\d*" on each input, which triggers the numeric keyboard on mobile devices without changing the input type. Arrow keys move focus between fields; backspace in an empty field moves back to the previous one.

CSS

The OTP input component can be customized through Sass variables at compile time.

Custom properties

The OTP input component exposes CSS custom properties to control its appearance at runtime.

--otp-gap: #{$form-otp-gap};
--otp-input-w: #{$form-otp-input-w};
--otp-input-h: #{$form-otp-input-h};
--otp-font-size: #{$form-otp-font-size};
--otp-font-weight: #{$form-otp-font-weight};
--otp-separator-color: #{$form-otp-separator-color};

Sass variables

The OTP input component uses Sass variables in scss/config/_defaults.scss to define its defaults.

$form-otp-input-w: var(--size-medium);
$form-otp-input-h: var(--size-large);
$form-otp-gap: var(--space-xsmall);
$form-otp-font-size: var(--font-size-xlarge);
$form-otp-font-weight: var(--font-weight-strong);
$form-otp-separator-color: var(--fg-subtle);