
Segmented OTP/PIN input: N boxes, auto-advance/backspace, single hidden text field enabling reliable clipboard paste that fills all boxes, customizable styles and error shake.
A polished segmented OTP / PIN input for Compose Multiplatform — N boxes, auto-advancing focus, configurable styling, and the feature most OTP components miss: clipboard paste that works on every platform.
Most OTP components are a row of single-character text fields glued together with focus hacks —
and pasting a code from your SMS or password manager either does nothing or only fills the first
box. OtpField is built on a single underlying text field, so one paste fills every box on
Android, iOS, Desktop and Web — and auto-advance, backspace and hardware keyboards all just work.
| Platform | Supported | Tested |
|---|---|---|
| Android | ✅ | ✅ (unit + UI) |
| iOS | ✅ | ✅ (UI, Skiko) |
| Desktop | ✅ | ✅ (unit + UI) |
| Web | ✅ | ✅ (compile + logic) |
gradle/libs.versions.toml:
[libraries]
otp-field = { module = "io.github.nadeemiqbal:otp-field", version = "0.1.0" }commonMain dependencies:
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.otp.field)
}
}
}OtpField(
length = 6,
onComplete = { otp -> viewModel.verify(otp) },
)onComplete fires exactly once, with the full code, the moment the last box is filled — whether
the user typed it or pasted it.
State-hoisted — read, reset or pre-fill the value yourself:
var otp by remember { mutableStateOf("") }
OtpField(
value = otp,
onValueChange = { otp = it },
length = 6,
onComplete = { viewModel.verify(it) },
)Pick a style
OtpField(
length = 4,
onComplete = { ... },
style = OtpFieldStyle.Boxed, // Boxed | Underlined | Rounded
)PIN / obscure mode — mask digits, with a brief reveal of the last one typed:
OtpField(
length = 4,
onComplete = { ... },
obscureChar = '•',
obscureCharShownDelay = 500.milliseconds,
)Error state with shake
OtpField(
length = 6,
onComplete = { ... },
isError = errorState,
errorMessage = "Invalid code",
)Sizing & keyboard
OtpField(
length = 6,
onComplete = { ... },
boxSize = DpSize(48.dp, 56.dp),
boxSpacing = 8.dp,
keyboardType = KeyboardType.NumberPassword, // Number | NumberPassword | Ascii
)style — Boxed, Underlined or Rounded.shape — overrides the box shape (ignored by Underlined).boxSize / boxSpacing — size of each box and the gap between them.colors — start from OtpFieldDefaults.colors() and .copy(...) what you need (border,
focused border, error border, cursor, text, disabled and error-text colors).keyboardType — numeric types (Number, NumberPassword, Decimal, Phone) restrict
input to digits; other types accept any non-whitespace character. This same filter is what
rejects invalid pastes.obscureChar / obscureCharShownDelay — PIN masking and how long the last digit stays
visible before it's hidden.isError / errorMessage — error styling, shake animation and an optional message.enabled — false makes the field inert and muted.OtpField is backed by one hidden text field rather than N separate ones, so the platform's
own paste action delivers the whole string in a single edit:
length characters → fills every box and fires onComplete.length → truncated to length.KeyboardType.Number) → the whole paste
is rejected; the field is never left half-filled.| OtpField | N separate TextFields |
A single plain TextField
|
|
|---|---|---|---|
| Segmented box visuals | ✅ 3 styles | ❌ | |
| One paste fills all boxes | ✅ every platform | ❌ usually only box 1 | ✅ |
| Auto-advance / auto-back | ✅ | n/a | |
| Obscure / PIN mode | ✅ with brief reveal | ||
| Error shake animation | ✅ | ❌ DIY | ❌ DIY |
| Hardware keyboard / backspace | ✅ | ✅ | |
| Multiplatform | ✅ Android/iOS/Desktop/Web | ✅ | ✅ |
If you just need a code field and don't care about the segmented look, a plain TextField is
genuinely fine. The moment you want the boxed OTP look and reliable paste, hand-rolling N
fields is where people get stuck — that's the gap this library fills.
See CONTRIBUTING.md. Bug reports and feature requests are welcome via GitHub Issues.
Copyright 2026 Nadeem Iqbal
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
See LICENSE for the full text.
A polished segmented OTP / PIN input for Compose Multiplatform — N boxes, auto-advancing focus, configurable styling, and the feature most OTP components miss: clipboard paste that works on every platform.
Most OTP components are a row of single-character text fields glued together with focus hacks —
and pasting a code from your SMS or password manager either does nothing or only fills the first
box. OtpField is built on a single underlying text field, so one paste fills every box on
Android, iOS, Desktop and Web — and auto-advance, backspace and hardware keyboards all just work.
| Platform | Supported | Tested |
|---|---|---|
| Android | ✅ | ✅ (unit + UI) |
| iOS | ✅ | ✅ (UI, Skiko) |
| Desktop | ✅ | ✅ (unit + UI) |
| Web | ✅ | ✅ (compile + logic) |
gradle/libs.versions.toml:
[libraries]
otp-field = { module = "io.github.nadeemiqbal:otp-field", version = "0.1.0" }commonMain dependencies:
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.otp.field)
}
}
}OtpField(
length = 6,
onComplete = { otp -> viewModel.verify(otp) },
)onComplete fires exactly once, with the full code, the moment the last box is filled — whether
the user typed it or pasted it.
State-hoisted — read, reset or pre-fill the value yourself:
var otp by remember { mutableStateOf("") }
OtpField(
value = otp,
onValueChange = { otp = it },
length = 6,
onComplete = { viewModel.verify(it) },
)Pick a style
OtpField(
length = 4,
onComplete = { ... },
style = OtpFieldStyle.Boxed, // Boxed | Underlined | Rounded
)PIN / obscure mode — mask digits, with a brief reveal of the last one typed:
OtpField(
length = 4,
onComplete = { ... },
obscureChar = '•',
obscureCharShownDelay = 500.milliseconds,
)Error state with shake
OtpField(
length = 6,
onComplete = { ... },
isError = errorState,
errorMessage = "Invalid code",
)Sizing & keyboard
OtpField(
length = 6,
onComplete = { ... },
boxSize = DpSize(48.dp, 56.dp),
boxSpacing = 8.dp,
keyboardType = KeyboardType.NumberPassword, // Number | NumberPassword | Ascii
)style — Boxed, Underlined or Rounded.shape — overrides the box shape (ignored by Underlined).boxSize / boxSpacing — size of each box and the gap between them.colors — start from OtpFieldDefaults.colors() and .copy(...) what you need (border,
focused border, error border, cursor, text, disabled and error-text colors).keyboardType — numeric types (Number, NumberPassword, Decimal, Phone) restrict
input to digits; other types accept any non-whitespace character. This same filter is what
rejects invalid pastes.obscureChar / obscureCharShownDelay — PIN masking and how long the last digit stays
visible before it's hidden.isError / errorMessage — error styling, shake animation and an optional message.enabled — false makes the field inert and muted.OtpField is backed by one hidden text field rather than N separate ones, so the platform's
own paste action delivers the whole string in a single edit:
length characters → fills every box and fires onComplete.length → truncated to length.KeyboardType.Number) → the whole paste
is rejected; the field is never left half-filled.| OtpField | N separate TextFields |
A single plain TextField
|
|
|---|---|---|---|
| Segmented box visuals | ✅ 3 styles | ❌ | |
| One paste fills all boxes | ✅ every platform | ❌ usually only box 1 | ✅ |
| Auto-advance / auto-back | ✅ | n/a | |
| Obscure / PIN mode | ✅ with brief reveal | ||
| Error shake animation | ✅ | ❌ DIY | ❌ DIY |
| Hardware keyboard / backspace | ✅ | ✅ | |
| Multiplatform | ✅ Android/iOS/Desktop/Web | ✅ | ✅ |
If you just need a code field and don't care about the segmented look, a plain TextField is
genuinely fine. The moment you want the boxed OTP look and reliable paste, hand-rolling N
fields is where people get stuck — that's the gap this library fills.
See CONTRIBUTING.md. Bug reports and feature requests are welcome via GitHub Issues.
Copyright 2026 Nadeem Iqbal
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
See LICENSE for the full text.