Skip to main content
The OTPTextField component provides a specialized input for one-time passwords (OTP) and verification codes, with support for filled, outlined, and underlined variants.

Import

import com.nomanr.lumo.ui.components.otptextfield.OTPTextField
import com.nomanr.lumo.ui.components.otptextfield.OutlinedOTPTextField
import com.nomanr.lumo.ui.components.otptextfield.UnderlinedOTPTextField
import com.nomanr.lumo.ui.components.otptextfield.OTPState
import com.nomanr.lumo.ui.components.otptextfield.rememberOtpState

OTPTextField (Filled)

A filled variant with background color for each digit.

Component Signature

@Composable
fun OTPTextField(
    modifier: Modifier = Modifier,
    state: OTPState = rememberOtpState(OTPTextFieldDefaults.OTPLength),
    enabled: Boolean = true,
    readOnly: Boolean = false,
    isError: Boolean = false,
    autoFocus: Boolean = true,
    textStyle: TextStyle = AppTheme.typography.h3,
    colors: OTPTextFieldColors = OTPTextFieldDefaults.filledColors(),
    visualTransformation: VisualTransformation = VisualTransformation.None,
    onComplete: (String) -> Unit = {},
)
Source: OTPTextField.kt:48-73

OutlinedOTPTextField

An outlined variant with borders around each digit.

Component Signature

@Composable
fun OutlinedOTPTextField(
    modifier: Modifier = Modifier,
    state: OTPState = rememberOtpState(OTPTextFieldDefaults.OTPLength),
    enabled: Boolean = true,
    readOnly: Boolean = false,
    isError: Boolean = false,
    autoFocus: Boolean = true,
    textStyle: TextStyle = AppTheme.typography.h3,
    colors: OTPTextFieldColors = OTPTextFieldDefaults.outlinedColors(),
    visualTransformation: VisualTransformation = VisualTransformation.None,
    onComplete: (String) -> Unit = {},
)
Source: OTPTextField.kt:76-101

UnderlinedOTPTextField

An underlined variant with lines below each digit.

Component Signature

@Composable
fun UnderlinedOTPTextField(
    modifier: Modifier = Modifier,
    state: OTPState = rememberOtpState(OTPTextFieldDefaults.OTPLength),
    enabled: Boolean = true,
    readOnly: Boolean = false,
    isError: Boolean = false,
    autoFocus: Boolean = true,
    textStyle: TextStyle = AppTheme.typography.h3,
    colors: OTPTextFieldColors = OTPTextFieldDefaults.underlinedColors(),
    visualTransformation: VisualTransformation = VisualTransformation.None,
    onComplete: (String) -> Unit = {},
)
Source: OTPTextField.kt:104-129

Parameters

modifier
Modifier
default:"Modifier"
Modifier to be applied to the OTP text field
state
OTPState
State object managing OTP input and navigation. Default length is 6 digits
enabled
Boolean
default:"true"
Whether the OTP field is enabled and can receive input
readOnly
Boolean
default:"false"
Whether the OTP field is read-only
isError
Boolean
default:"false"
Whether to display the field in error state
autoFocus
Boolean
default:"true"
Whether to automatically focus the first digit on mount
textStyle
TextStyle
default:"AppTheme.typography.h3"
Text style for the digits
colors
OTPTextFieldColors
Colors for different states. Each variant has its own default colors
visualTransformation
VisualTransformation
default:"VisualTransformation.None"
Visual transformation for the digits (e.g., PasswordVisualTransformation for hidden digits)
onComplete
(String) -> Unit
default:"{}"
Callback invoked when all digits are entered. Receives the complete OTP code

OTPState

The OTPState manages the input and navigation logic for the OTP field.

Creating OTPState

// Using rememberOtpState
val otpState = rememberOtpState(length = 6)

// Or create manually
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current

val otpState = remember {
    OTPState(
        length = 6,
        focusManager = focusManager,
        keyboardController = keyboardController
    )
}
Source: OTPTextField.kt:348-360

OTPState Properties and Methods

From OTPState class (OTPTextField.kt:295-346):
class OTPState(
    val length: Int,
    val focusManager: FocusManager,
    private val keyboardController: SoftwareKeyboardController?,
    initialOtp: String = "",
) {
    var code: String // Current OTP code (spaces for empty digits)
    val interactionSources: List<MutableInteractionSource>
    
    fun onDigitEntered(index: Int, value: Char)
    fun onDigitDeleted(index: Int)
    fun onBackspacePressed(index: Int)
    fun isFieldEmpty(index: Int): Boolean
    fun isComplete(): Boolean
}

Examples

Basic OTP Field (4 digits)

From preview (OTPTextField.kt:372-375):
val otpState = rememberOtpState(4)

OTPTextField(
    state = otpState,
    onComplete = { code ->
        println("OTP completed: $code")
        // Verify OTP code
    }
)

6-Digit OTP (Common for 2FA)

var otp by remember { mutableStateOf("") }
var isVerifying by remember { mutableStateOf(false) }
var error by remember { mutableStateOf(false) }

Column {
    Text("Enter verification code", style = AppTheme.typography.h4)
    Spacer(modifier = Modifier.height(8.dp))
    
    OTPTextField(
        state = rememberOtpState(6),
        isError = error,
        enabled = !isVerifying,
        onComplete = { code ->
            otp = code
            isVerifying = true
            // Verify the OTP
            verifyOTP(code) { success ->
                isVerifying = false
                error = !success
            }
        }
    )
    
    if (error) {
        Spacer(modifier = Modifier.height(4.dp))
        Text(
            "Invalid code. Please try again.",
            color = AppTheme.colors.error,
            style = AppTheme.typography.caption
        )
    }
}

Outlined OTP Field

From preview (OTPTextField.kt:376-379):
val otpState = rememberOtpState(4)

OutlinedOTPTextField(
    state = otpState,
    autoFocus = true,
    onComplete = { code ->
        println("OTP completed: $code")
    }
)

Underlined OTP Field

From preview (OTPTextField.kt:381-384):
val otpState = rememberOtpState(4)

UnderlinedOTPTextField(
    state = otpState,
    autoFocus = false,
    onComplete = { code ->
        println("OTP completed: $code")
    }
)

Comparing All Variants

From preview (OTPTextField.kt:363-396):
Column(
    modifier = Modifier
        .fillMaxSize()
        .verticalScroll(rememberScrollState()),
    verticalArrangement = Arrangement.spacedBy(16.dp)
) {
    Text("Filled (4 digits)")
    OTPTextField(
        state = rememberOtpState(4),
        autoFocus = false
    )
    
    Text("Outlined (4 digits)")
    OutlinedOTPTextField(
        state = rememberOtpState(4),
        autoFocus = false
    )
    
    Text("Underlined (4 digits)")
    UnderlinedOTPTextField(
        state = rememberOtpState(4),
        autoFocus = false
    )
    
    Text("6 Digits")
    OTPTextField(
        state = rememberOtpState(6),
        autoFocus = false
    )
}

Hidden Digits (Password Style)

val otpState = rememberOtpState(6)

OTPTextField(
    state = otpState,
    visualTransformation = PasswordVisualTransformation(),
    onComplete = { code ->
        // Verify hidden PIN
    }
)

With Loading State

var isLoading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf(false) }
val otpState = rememberOtpState(6)

Column {
    OTPTextField(
        state = otpState,
        enabled = !isLoading,
        isError = error,
        onComplete = { code ->
            isLoading = true
            error = false
            
            verifyCode(code) { success ->
                isLoading = false
                if (!success) {
                    error = true
                }
            }
        }
    )
    
    if (isLoading) {
        CircularProgressIndicator(modifier = Modifier.padding(top = 16.dp))
    }
}

Resend Code Flow

var timeLeft by remember { mutableStateOf(60) }
val otpState = rememberOtpState(6)

LaunchedEffect(timeLeft) {
    if (timeLeft > 0) {
        delay(1000)
        timeLeft--
    }
}

Column {
    Text("Enter the code sent to your phone")
    Spacer(modifier = Modifier.height(16.dp))
    
    OTPTextField(
        state = otpState,
        onComplete = { code ->
            // Verify code
        }
    )
    
    Spacer(modifier = Modifier.height(16.dp))
    
    if (timeLeft > 0) {
        Text(
            "Resend code in ${timeLeft}s",
            style = AppTheme.typography.caption,
            color = AppTheme.colors.textSecondary
        )
    } else {
        Text(
            "Resend code",
            style = AppTheme.typography.caption,
            color = AppTheme.colors.primary,
            modifier = Modifier.clickable {
                // Resend OTP
                timeLeft = 60
            }
        )
    }
}

Behavior

Keyboard Input

  • Only accepts numeric input (0-9)
  • Automatically moves focus to next field on digit entry
  • Automatically hides keyboard when last digit is entered
  • Backspace moves to previous field when current field is empty
From OTPTextFieldItem (OTPTextField.kt:238-247):
onValueChange = { newValue ->
    when {
        newValue.isNotEmpty() && newValue.last().isDigit() -> {
            state.onDigitEntered(position, newValue.last())
        }
        newValue.isEmpty() -> {
            state.onDigitDeleted(position)
        }
    }
}

Focus Management

  • Auto-focus first field on mount (if autoFocus = true)
  • Automatic focus navigation between fields
  • Smart backspace handling (OTPTextField.kt:257-264)

Completion Detection

From OTPTextFieldLayout (OTPTextField.kt:145-149):
LaunchedEffect(state.code) {
    if (state.isComplete()) {
        onComplete(state.code.trim())
    }
}

Styling

Default Styling

From OTPTextFieldDefaults:
  • Default Length: 6 digits
  • Item Width: Configurable per variant
  • Item Spacing: 8.dp between digits
  • Text Style: AppTheme.typography.h3 (large, centered)

Layout

The component uses a custom Layout composable to position the digit fields with precise spacing (OTPTextField.kt:173-196).

Accessibility

  • Each digit field has semantic content description (OTPTextField.kt:265-267):
    semantics {
        contentDescription = "OTP Digit ${position + 1} of ${state.length}"
    }
    
  • Keyboard type set to NumberPassword for numeric input (OTPTextField.kt:270)
  • Proper focus management for keyboard navigation
  • Error states clearly indicated with color changes

Advanced Usage

Custom Length OTP

// 4-digit PIN
val pinState = rememberOtpState(4)

// 8-digit verification code
val codeState = rememberOtpState(8)

Programmatic Control

val otpState = rememberOtpState(6)

// Clear the OTP
Button(text = "Clear", onClick = {
    // Reset by creating new state or manipulating digits
})

// Pre-fill OTP (for testing)
val otpState = remember {
    OTPState(
        length = 6,
        focusManager = LocalFocusManager.current,
        keyboardController = LocalSoftwareKeyboardController.current,
        initialOtp = "123456" // Pre-filled value
    )
}

Best Practices

Length Selection

  • 4 digits: Simple PINs, quick verification
  • 6 digits: Standard for 2FA, good balance of security and usability
  • 8+ digits: High security scenarios

User Experience

  1. Show clear instructions above the field
  2. Display where the code was sent (email/SMS)
  3. Provide a resend option with cooldown timer
  4. Show loading state during verification
  5. Clear error messages for invalid codes
  6. Consider auto-submit when complete

Error Handling

var error by remember { mutableStateOf<String?>(null) }

Column {
    OTPTextField(
        state = otpState,
        isError = error != null,
        onComplete = { code ->
            verifyCode(code) { result ->
                error = when (result) {
                    is VerificationResult.Success -> null
                    is VerificationResult.InvalidCode -> "Invalid code"
                    is VerificationResult.Expired -> "Code expired. Request a new one"
                    is VerificationResult.NetworkError -> "Network error. Try again"
                }
            }
        }
    )
    
    error?.let { message ->
        Text(
            message,
            color = AppTheme.colors.error,
            style = AppTheme.typography.caption,
            modifier = Modifier.padding(top = 8.dp)
        )
    }
}

Source Reference

Full implementation: com.nomanr.lumo.ui.components.otptextfield.OTPTextField.kt

Build docs developers (and LLMs) love