Docs / Components / 03

ComposeOtpInput

A one-time-code field split into cells. Typing fills the cells one by one, backspace clears them, and pasting or SMS autofill drops a whole code in at once. Characters the input doesn't accept, such as the dash in 529-174, are skipped.

  • Value + callback
  • Paste & SMS autofill
  • PIN masking
  • Error shake

Preview

How it works

  • One hidden text field behind the cells. The cells are drawn from a single value string, so you keep the code in your own state and update it from onValueChange. The cursor always sits at the end of the code.
  • Pasting just works. Pasting a full code, or an SMS autofill, replaces what's there. Characters the input doesn't accept, like the dash in 529-174, are dropped, so the code still fills the cells.
  • onComplete fires once when the last cell is filled, and the keyboard closes (clearFocusOnComplete). That's where you verify the code.
  • Errors are visible and felt. Set isError = true and the cells turn red, the row shakes and the phone buzzes once. Reset it in onValueChange when the user edits.
  • Shape it to your screen. Choose Outlined, Filled or Underlined cells, split them into groups with a separator, mask a PIN, accept letters or your own charFilter, or draw each cell yourself with cellContent.

Setup

Add the library once (see Getting started), then import from com.nameisjayant.components.otpinput:

Imports
import com.nameisjayant.components.otpinput.*

Quick start

ComposeOtpInput.kt
var code by rememberSaveable { mutableStateOf("") }

ComposeOtpInput(
    value = code,
    onValueChange = { code = it },
    onComplete = { viewModel.verify(it) },   // called when the last cell is filled; the keyboard closes
)

Error state

ComposeOtpInput.kt
ComposeOtpInput(
    value = code,
    onValueChange = { code = it; wrongCode = false },
    isError = wrongCode,                     // red cells, the row shakes and the phone buzzes when it turns true
)

Styles, groups and sizing

ComposeOtpInput.kt
ComposeOtpInput(
    value = code,
    onValueChange = { code = it },
    length = 6,
    cellStyle = OtpCellStyle.Filled,                                    // Outlined | Filled | Underlined
    colors = ComposeOtpInputDefaults.accentColors(Color(0xFF0F8A66), OtpCellStyle.Filled),
    cellSize = DpSize(Dp.Unspecified, 52.dp),                           // unspecified width = share the row
    groupSize = 3,                                                      // 529 – 174
    separator = { ComposeOtpInputDefaults.Separator() },
)

// Round cells
ComposeOtpInput(code, { code = it }, cellShape = ComposeOtpInputDefaults.CircleCellShape, cellSize = DpSize(46.dp, 46.dp))

Pass the same style to colors(style) / accentColors(accent, style) as to cellStyle; they pick suitable container and border colors for it.

PINs and other input types

ComposeOtpInput.kt
// Masked PIN that shows each digit for a moment
ComposeOtpInput(value = pin, onValueChange = { pin = it }, length = 4, mask = true, revealMillis = 600)

// Letters, uppercased as they're typed or pasted
ComposeOtpInput(value = code, onValueChange = { code = it }, inputType = OtpInputType.Letters, uppercase = true)

// Your own rule, e.g. hex characters
ComposeOtpInput(value = code, onValueChange = { code = it }, inputType = OtpInputType.Any,
    charFilter = { it.isDigit() || it.lowercaseChar() in 'a'..'f' })

OtpInputType is Digits (number pad), Letters, Alphanumeric or Any.

Custom cells

ComposeOtpInput.kt
// PIN dots instead of boxes
ComposeOtpInput(value = pin, onValueChange = { pin = it }, length = 4) { cell: OtpCell ->
    Box(
        Modifier
            .size(16.dp)
            .background(if (cell.isFilled) MaterialTheme.colorScheme.primary else Color.Transparent, CircleShape)
            .border(2.dp, if (cell.isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, CircleShape),
    )
}

OtpCell has index, char, text (masked if needed), isActive, isFilled, isError and isEnabled.

All parameters

ParameterDefaultDescription
value / onValueChangeThe code, and the callback with the new code
length6Number of cells
onCompletenullCalled once with the full code when the last cell is filled
enabled / readOnlytrue / falseDisabled ignores focus; read-only can be focused and copied but not changed
isErrorfalseError colors, plus a shake and a buzz when it turns true
inputTypeDigitsAccepted characters and default keyboard
charFilterinputType::acceptsOverrides which characters are accepted
uppercasefalseCapitalise letters
mask / maskChar / revealMillisfalse / ● / 600Password-style masking, briefly showing the last character
placeholdernullCharacter shown in empty cells, e.g. '0'
cellStyleOutlinedOutlined, Filled or Underlined
cellSize48 × 56 dpDp.Unspecified width shares the row; unspecified height makes cells square
cellSpacing8.dpGap between cells and around separators
cellShapeRoundedCornerShape(12.dp)CircleCellShape for round cells
borderWidth / focusedBorderWidth1.dp / 2.dpIdle and active/error borders (or underlines)
textStyleheadlineSmall, mediumIts color comes from colors
colorscolors(cellStyle)Container, border and text colors for idle, focused, filled, error and disabled cells, plus placeholderColor and cursorColor
groupSize / separator0 / dashSplit into groups with a separator between them
showCursortrueBlinking cursor in the active cell
autoFocusfalseFocus and open the keyboard on first show
clearFocusOnCompletetrueClose the keyboard when complete
keyboardType / imeAction / keyboardActionsfrom inputType / Done / defaultKeyboard options
animationOtpInputAnimation.DefaultOtpInputAnimation(slideInText, popScale, blinkCursor, shakeOnError, shakeDistance) or .None
hapticFeedbackEnabledtrueConfirm tick when complete, buzz on error
interactionSourcenullObserve focus and press interactions
cellContentnull@Composable (OtpCell) -> Unit to draw cells yourself

Cells always run left to right, also in right-to-left layouts, since codes are read that way. The field tells autofill services it holds an SMS one-time code.

API

The public signatures, straight from the library sources:

com.nameisjayant.components.otpinput
@Composable
public fun ComposeOtpInput(
    value: String,
    onValueChange: (String) -> Unit,
    modifier: Modifier = Modifier,
    length: Int = 6,
    onComplete: ((String) -> Unit)? = null,
    enabled: Boolean = true,
    readOnly: Boolean = false,
    isError: Boolean = false,
    inputType: OtpInputType = OtpInputType.Digits,
    charFilter: (Char) -> Boolean = inputType::accepts,
    uppercase: Boolean = false,
    mask: Boolean = false,
    maskChar: Char = ComposeOtpInputDefaults.MaskChar,
    revealMillis: Long = ComposeOtpInputDefaults.RevealMillis,
    placeholder: Char? = null,
    cellStyle: OtpCellStyle = OtpCellStyle.Outlined,
    cellSize: DpSize = ComposeOtpInputDefaults.CellSize,
    cellSpacing: Dp = ComposeOtpInputDefaults.CellSpacing,
    cellShape: Shape = ComposeOtpInputDefaults.CellShape,
    borderWidth: Dp = ComposeOtpInputDefaults.BorderWidth,
    focusedBorderWidth: Dp = ComposeOtpInputDefaults.FocusedBorderWidth,
    textStyle: TextStyle = ComposeOtpInputDefaults.textStyle(),
    colors: ComposeOtpInputColors = ComposeOtpInputDefaults.colors(cellStyle),
    groupSize: Int = 0,
    separator: @Composable () -> Unit = { ComposeOtpInputDefaults.Separator() },
    showCursor: Boolean = true,
    autoFocus: Boolean = false,
    clearFocusOnComplete: Boolean = true,
    keyboardType: KeyboardType = inputType.keyboardType,
    imeAction: ImeAction = ImeAction.Done,
    keyboardActions: KeyboardActions = KeyboardActions.Default,
    animation: OtpInputAnimation = OtpInputAnimation.Default,
    hapticFeedbackEnabled: Boolean = true,
    interactionSource: MutableInteractionSource? = null,
    cellContent: (@Composable (OtpCell) -> Unit)? = null,
)