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 object managing OTP input and navigation. Default length is 6 digits
Whether the OTP field is enabled and can receive input
Whether the OTP field is read-only
Whether to display the field in error state
Whether to automatically focus the first digit on mount
textStyle
TextStyle
default:"AppTheme.typography.h3"
Text style for the digits
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
- 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
- Show clear instructions above the field
- Display where the code was sent (email/SMS)
- Provide a resend option with cooldown timer
- Show loading state during verification
- Clear error messages for invalid codes
- 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