suiss/uim · Interaction
Otp Autofill
A one-time code input that fills digit by digit from a suggestion above the keyboard, then verifies and shows the result.
Otp Autofill is an open-source mobile interaction from suiss/uim for React Native and Expo, with SwiftUI and Jetpack Compose versions built from the same design tokens. The @esuiss/uim CLI copies its source into your project, so you own the code.


import * as React from "react"
import { View } from "react-native"
import { OtpAutofill, OtpAutofillInput, OtpAutofillSuggestion, useOtpAutofill } from "@/components/interactions/otp-autofill"
import { Button } from "@/components/ui/button"
import { InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ui/input-otp"
import { Text } from "@/components/ui/text"
const CODE = "482913"
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function Status() {
const { state } = useOtpAutofill()
return (
<Text
role="status"
accessibilityLiveRegion="polite"
className={
state === "invalid"
? "min-h-5 text-sm text-danger"
: state === "verified"
? "min-h-5 text-sm text-success"
: "min-h-5 text-sm text-label-secondary"
}
>
{state === "verifying"
? "Verifying…"
: state === "verified"
? "Verified"
: state === "invalid"
? "That code didn't work. Try again."
: ""}
</Text>
)
}
function VerificationExample({ incoming }: { incoming: string }) {
const [suggestion, setSuggestion] = React.useState<string | null>(null)
const [sending, setSending] = React.useState(false)
const send = React.useCallback(async () => {
setSending(true)
setSuggestion(null)
await wait(1200)
setSuggestion(incoming)
setSending(false)
}, [incoming])
React.useEffect(() => {
send()
}, [send])
return (
<View className="w-full items-center gap-2">
<Text className="text-center text-2xl font-bold">Enter the code</Text>
<Text className="text-center text-sm text-label-secondary">
We sent a 6-digit code to +1 555 0100.
</Text>
<OtpAutofill
maxLength={6}
onVerify={async (code) => {
await wait(700)
return code === CODE
}}
className="mt-4"
>
<OtpAutofillInput autoFocus accessibilityLabel="Verification code">
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</OtpAutofillInput>
<Status />
<OtpAutofillSuggestion code={suggestion} />
</OtpAutofill>
<Button variant="plain" onPress={send} disabled={sending}>
<Text>{sending ? "Sending…" : "Resend code"}</Text>
</Button>
</View>
)
}
export function OtpAutofillExample() {
return (
<VerificationExample incoming={CODE} />
)
}import EsuissUI
import SwiftUI
struct OTPAutofillExample: View {
var body: some View {
OtpAutofillVerification(incoming: otpAutofillCode)
}
}
let otpAutofillCode = "482913"
struct OtpAutofillStatus: View {
let state: OtpAutofillState
var body: some View {
Text(text)
.textStyle(.sm)
.foregroundStyle(color)
.frame(minHeight: 20)
.accessibilityAddTraits(.updatesFrequently)
}
var text: LocalizedStringKey {
switch state {
case .verifying: "Verifying…"
case .verified: "Verified"
case .invalid: "That code didn't work. Try again."
case .idle, .filling: ""
}
}
var color: Color {
switch state {
case .verified: .success
case .invalid: .danger
default: .labelSecondary
}
}
}
struct OtpAutofillVerification: View {
let incoming: String
@State private var suggestion: String?
@State private var sending = false
var body: some View {
VStack(spacing: 8) {
Text("Enter the code")
.textStyle(.xl2, weight: .bold)
.foregroundStyle(Color.label)
.multilineTextAlignment(.center)
Text("We sent a 6-digit code to +1 555 0100.")
.textStyle(.sm)
.foregroundStyle(Color.labelSecondary)
.multilineTextAlignment(.center)
OtpAutofill(length: 6, onVerify: { code in
try? await Task.sleep(for: .milliseconds(700))
return code == otpAutofillCode
}) { state in
OtpAutofillInput(autoFocus: true) {
InputOTPGroup {
InputOTPSlot(index: 0)
InputOTPSlot(index: 1)
InputOTPSlot(index: 2)
}
InputOTPSeparator()
InputOTPGroup {
InputOTPSlot(index: 3)
InputOTPSlot(index: 4)
InputOTPSlot(index: 5)
}
}
.accessibilityLabel(Text("Verification code"))
OtpAutofillStatus(state: state)
OtpAutofillSuggestion(code: suggestion)
}
.padding(.top, 16)
Button {
Task { await send() }
} label: {
Text(sending ? "Sending…" : "Resend code")
}
.buttonStyle(.variant(.plain))
.disabled(sending)
}
.frame(maxWidth: .infinity)
.task {
await send()
}
}
func send() async {
sending = true
suggestion = nil
try? await Task.sleep(for: .milliseconds(1200))
suggestion = incoming
sending = false
}
}import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.InputOTPGroup
import com.esuiss.ui.components.InputOTPSeparator
import com.esuiss.ui.components.InputOTPSlot
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.OtpAutofill
import com.esuiss.ui.interactions.OtpAutofillInput
import com.esuiss.ui.interactions.OtpAutofillSuggestion
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.delay
@Composable
fun OTPAutofillExample() {
OtpAutofillVerification(incoming = OtpAutofillCode)
}
@Composable
fun OtpAutofillVerification(incoming: String) {
val colors = EsuissTheme.colors
var suggestion by rememberSaveable { mutableStateOf<String?>(null) }
var sending by rememberSaveable { mutableStateOf(false) }
var request by rememberSaveable { mutableIntStateOf(0) }
LaunchedEffect(request) {
sending = true
suggestion = null
delay(1200)
suggestion = incoming
sending = false
}
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Enter the code", style = Typography.xl2.copy(fontWeight = FontWeight.Bold), textAlign = TextAlign.Center)
Text(
"We sent a 6-digit code to +1 555 0100.",
style = Typography.sm.copy(color = colors.labelSecondary),
textAlign = TextAlign.Center,
)
OtpAutofill(
maxLength = 6,
onVerify = { code ->
delay(700)
code == OtpAutofillCode
},
modifier = Modifier.padding(top = 16.dp),
) {
OtpAutofillInput(contentDescription = "Verification code", autoFocus = true) {
InputOTPGroup {
InputOTPSlot(0)
InputOTPSlot(1)
InputOTPSlot(2)
}
InputOTPSeparator()
InputOTPGroup {
InputOTPSlot(3)
InputOTPSlot(4)
InputOTPSlot(5)
}
}
OtpAutofillStatus()
OtpAutofillSuggestion(code = suggestion)
}
Button(
if (sending) "Sending…" else "Resend code",
onClick = { request += 1 },
variant = ButtonVariant.Plain,
enabled = !sending,
)
}
}OtpAutofill wraps an InputOTP and runs it through idle, filling, verifying, verified and invalid states. When the input is focused and empty, OtpAutofillSuggestion slides up above the keyboard with the incoming code; pressing it types the code one digit at a time.
When all digits are entered, onVerify runs and the slots dim while it is pending. A valid code turns the slots green and locks the input. An invalid code turns them red and shakes the row, then clears the input and refocuses it. Pasting or a system autofill of several digits plays the same digit-by-digit fill.
Installation
$ pnpm dlx @esuiss/uim@latest add otp-autofill$ pnpm dlx @esuiss/uim@latest add otp-autofill --platform swift$ pnpm dlx @esuiss/uim@latest add otp-autofill --platform kotlinThe CLI also adds what it builds on: Input Otp, Text.
Manual
- Install the dependencies:
npx expo install cn react-native-reanimated react-native-safe-area-context react-native-worklets-
Add the pieces it builds on: Input Otp, Text, and the primitives
components/primitives/keyboard.tsx,components/primitives/portal.tsx. -
Copy the source into
components/interactions/otp-autofill.tsx:
import { cn } from "cn"
import * as React from "react"
import { Platform, Pressable, type TextInput, View } from "react-native"
import {
Easing,
Keyframe,
ReduceMotion,
useAnimatedStyle,
useSharedValue,
withSequence,
withTiming,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useKeyboard } from "@/components/primitives/keyboard"
import { Portal } from "@/components/primitives/portal"
import { AnimatedView } from "@/components/ui/animated-view"- Update the import paths to match your project.
- Install the dependencies:
npx expo install cn react-native-reanimated react-native-safe-area-context react-native-worklets-
Add the pieces it builds on: Input Otp, Text, and the primitives
components/primitives/keyboard.tsx,components/primitives/portal.tsx. -
Copy the source into
components/interactions/otp-autofill.tsx:
import { cn } from "cn"
import * as React from "react"
import { Platform, Pressable, type TextInput, View } from "react-native"
import {
Easing,
Keyframe,
ReduceMotion,
useAnimatedStyle,
useSharedValue,
withSequence,
withTiming,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useKeyboard } from "@/components/primitives/keyboard"
import { Portal } from "@/components/primitives/portal"
import { AnimatedView } from "@/components/ui/animated-view"- Update the import paths to match your project.
- Install the dependencies:
npx expo install react-native-reanimated react-native-safe-area-context react-native-worklets-
Add the pieces it builds on: Input Otp, Text, and the primitives
components/primitives/keyboard.tsx,components/primitives/portal.tsx. -
Copy the source into
components/interactions/otp-autofill.tsx:
import * as React from "react"
import {
Platform,
Pressable,
type StyleProp,
StyleSheet,
type TextInput,
View,
type ViewStyle,
} from "react-native"
import {
Easing,
Keyframe,
ReduceMotion,
useAnimatedStyle,
useSharedValue,- Update the import paths to match your project.
-
Target iOS 17 or later.
-
Add the Swift package to your app:
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")- Copy the theme files every view uses:
import SwiftUI
import UIKit
struct RGBA: Sendable {
let red: CGFloat
let green: CGFloat
let blue: CGFloat
let alpha: CGFloat
init(_ hex: UInt32, _ alpha: CGFloat = 1) {
red = CGFloat((hex >> 16) & 0xFF) / 255
green = CGFloat((hex >> 8) & 0xFF) / 255
blue = CGFloat(hex & 0xFF) / 255
self.alpha = alpha
}import SwiftUI
extension Color {
public static let surface = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
public static let surfaceSecondary = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
public static let surfaceTertiary = Color.adaptive(light: .init(0xE8E8ED), dark: .init(0x2C2C2E))
public static let surfaceRaised = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
public static let label = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
public static let labelSecondary = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
public static let labelTertiary = Color.adaptive(light: .init(0x86868B), dark: .init(0x86868B))
public static let labelQuaternary = Color.adaptive(light: .init(0xD2D2D7), dark: .init(0x424245))
public static let control = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
public static let controlPressed = Color.adaptive(light: .init(0x000000, 0.12), dark: .init(0xFFFFFF, 0.22))
public static let controlSelected = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
public static let controlDisabled = Color.adaptive(light: .init(0x000000, 0.03), dark: .init(0xFFFFFF, 0.05))
public static let onControlSelected = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))- Copy the source and every file it uses:
import Observation
import SwiftUI
public enum OtpAutofillState: String, CaseIterable, Sendable {
case idle
case filling
case verifying
case verified
case invalid
}
@MainActor
@Observable
final class OtpAutofillModel {
var value = ""
var state = OtpAutofillState.idleimport PhosphorSwift
import SwiftUI
extension Shadow {
static let control = Shadow(color: .black.opacity(0.08), radius: 0.5, y: 0.5)
static let thumb = Shadow(color: .black.opacity(0.12), radius: 1.5, y: 1)
}
struct SelectionIndicator<Mark: View>: View {
let checked: Bool
let pressed: Bool
let cornerRadius: CGFloat
let mark: Mark
@Environment(\.isEnabled) private var isEnabled
@Environment(\.isInvalid) private var invalidimport SwiftUI
extension EnvironmentValues {
@Entry public var isInvalid = false
@Entry var fieldInset = false
@Entry var fieldGroupInset = false
@Entry var fieldSetInset = false
}
extension View {
public func invalid(_ isInvalid: Bool = true) -> some View {
environment(\.isInvalid, isInvalid)
}
}
public enum FieldOrientation: CaseIterable, Sendable {import SwiftUI
extension EnvironmentValues {
@Entry public var hapticsEnabled = true
}
extension View {
public func haptics(_ enabled: Bool) -> some View {
environment(\.hapticsEnabled, enabled)
}
func hapticFeedback<Trigger: Equatable>(_ feedback: SensoryFeedback, trigger: Trigger) -> some View {
modifier(HapticFeedbackModifier(feedback: feedback, trigger: trigger) { _, _ in true })
}
func hapticFeedback<Trigger: Equatable>(import PhosphorSwift
import SwiftUI
struct InputOTPContext {
var characters: [Character]
var length: Int
var focused: Bool
}
struct InputOTPTone: Equatable {
var danger = false
var verified = false
var dimmed = false
var pops: [Int] = []
}import PhosphorSwift
import SwiftUI
public enum KeypadKey: Hashable, Sendable {
case digit(Int)
case decimal
case delete
case biometric
}
public enum KeypadAccessory {
case none
case decimal(String, label: LocalizedStringKey = "Decimal separator")
case biometric(Image, label: LocalizedStringKey = "Use biometrics")
}import SwiftUI
public struct Separator: View {
let orientation: Axis
public init(_ orientation: Axis = .horizontal) {
self.orientation = orientation
}
public var body: some View {
Rectangle()
.fill(Color.separator)
.frame(width: orientation == .vertical ? 1 : nil, height: orientation == .horizontal ? 1 : nil)
.frame(maxWidth: orientation == .horizontal ? .infinity : nil, maxHeight: orientation == .vertical ? .infinity : nil)
.accessibilityHidden(true)
}import SwiftUI
public enum TextVariant: CaseIterable, Sendable {
case `default`, h1, h2, h3, h4, p, blockquote, code, lead, large, small, muted
var style: TextStyle {
switch self {
case .h1: .xl3
case .h2: .xl2
case .h3: .xl
case .h4, .lead, .large: .lg
case .code, .small, .muted: .sm
default: .base
}
}- Add the files to your app target.
- Set
minSdkto 24 or higher and add the dependencies:
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")- Copy the theme files every view uses:
package com.esuiss.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffsetpackage com.esuiss.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Immutable
data class Colors(
val surface: Color,
val surfaceSecondary: Color,
val surfaceTertiary: Color,
val surfaceRaised: Color,
val label: Color,
val labelSecondary: Color,
val labelTertiary: Color,
val labelQuaternary: Color,- Copy the source and every file it uses:
package com.esuiss.ui.interactions
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangementpackage com.esuiss.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShapepackage com.esuiss.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutablepackage com.esuiss.ui.components
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
val LocalContentStyle = compositionLocalOf { TextStyle.Default }
val LocalContentColor = compositionLocalOf { Color.Unspecified }
val LocalIconSize = compositionLocalOf { 20.dp }package com.esuiss.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.LayoutScopeMarker
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextpackage com.esuiss.ui.components
import android.provider.Settings
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Sizepackage com.esuiss.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
val LocalHapticsEnabled = staticCompositionLocalOf { true }
@Composable
fun ProvideHaptics(enabled: Boolean, content: @Composable () -> Unit) {
CompositionLocalProvider(LocalHapticsEnabled provides enabled, content = content)
}package com.esuiss.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import com.esuiss.ui.theme.EsuissTheme
@Composable
fun Icon(
imageVector: ImageVector,package com.esuiss.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Boxpackage com.esuiss.ui.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.keyframes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextpackage com.esuiss.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
@Composablepackage com.esuiss.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
@Composable
fun Separator(- Update the
com.esuiss.uipackage names to match your project.
Usage
import { OtpAutofill, OtpAutofillInput, OtpAutofillSuggestion } from "@/components/interactions/otp-autofill"<VerificationExample incoming={CODE} />Pass maxLength and onVerify, which returns a boolean or a promise of one. Put InputOTPGroup and InputOTPSlots inside OtpAutofillInput, and render an OtpAutofillSuggestion with the code your app received; it hides when code is empty.
The input sets textContentType="oneTimeCode" and the platform's one-time code autoComplete, so the system can also offer the code. useOtpAutofill returns state, value, fill and inputRef for custom controls.
Examples
Wrong code
An incoming code that fails verification, showing the shake and reset.


import * as React from "react"
import { View } from "react-native"
import { OtpAutofill, OtpAutofillInput, OtpAutofillSuggestion, useOtpAutofill } from "@/components/interactions/otp-autofill"
import { Button } from "@/components/ui/button"
import { InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ui/input-otp"
import { Text } from "@/components/ui/text"
const CODE = "482913"
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function Status() {
const { state } = useOtpAutofill()
return (
<Text
role="status"
accessibilityLiveRegion="polite"
className={
state === "invalid"
? "min-h-5 text-sm text-danger"
: state === "verified"
? "min-h-5 text-sm text-success"
: "min-h-5 text-sm text-label-secondary"
}
>
{state === "verifying"
? "Verifying…"
: state === "verified"
? "Verified"
: state === "invalid"
? "That code didn't work. Try again."
: ""}
</Text>
)
}
function VerificationExample({ incoming }: { incoming: string }) {
const [suggestion, setSuggestion] = React.useState<string | null>(null)
const [sending, setSending] = React.useState(false)
const send = React.useCallback(async () => {
setSending(true)
setSuggestion(null)
await wait(1200)
setSuggestion(incoming)
setSending(false)
}, [incoming])
React.useEffect(() => {
send()
}, [send])
return (
<View className="w-full items-center gap-2">
<Text className="text-center text-2xl font-bold">Enter the code</Text>
<Text className="text-center text-sm text-label-secondary">
We sent a 6-digit code to +1 555 0100.
</Text>
<OtpAutofill
maxLength={6}
onVerify={async (code) => {
await wait(700)
return code === CODE
}}
className="mt-4"
>
<OtpAutofillInput autoFocus accessibilityLabel="Verification code">
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</OtpAutofillInput>
<Status />
<OtpAutofillSuggestion code={suggestion} />
</OtpAutofill>
<Button variant="plain" onPress={send} disabled={sending}>
<Text>{sending ? "Sending…" : "Resend code"}</Text>
</Button>
</View>
)
}
export function OtpAutofillWrongCodeExample() {
return (
<VerificationExample incoming="113355" />
)
}import EsuissUI
import SwiftUI
struct OTPAutofillWrongCodeExample: View {
var body: some View {
OtpAutofillVerification(incoming: "113355")
}
}
let otpAutofillCode = "482913"
struct OtpAutofillStatus: View {
let state: OtpAutofillState
var body: some View {
Text(text)
.textStyle(.sm)
.foregroundStyle(color)
.frame(minHeight: 20)
.accessibilityAddTraits(.updatesFrequently)
}
var text: LocalizedStringKey {
switch state {
case .verifying: "Verifying…"
case .verified: "Verified"
case .invalid: "That code didn't work. Try again."
case .idle, .filling: ""
}
}
var color: Color {
switch state {
case .verified: .success
case .invalid: .danger
default: .labelSecondary
}
}
}
struct OtpAutofillVerification: View {
let incoming: String
@State private var suggestion: String?
@State private var sending = false
var body: some View {
VStack(spacing: 8) {
Text("Enter the code")
.textStyle(.xl2, weight: .bold)
.foregroundStyle(Color.label)
.multilineTextAlignment(.center)
Text("We sent a 6-digit code to +1 555 0100.")
.textStyle(.sm)
.foregroundStyle(Color.labelSecondary)
.multilineTextAlignment(.center)
OtpAutofill(length: 6, onVerify: { code in
try? await Task.sleep(for: .milliseconds(700))
return code == otpAutofillCode
}) { state in
OtpAutofillInput(autoFocus: true) {
InputOTPGroup {
InputOTPSlot(index: 0)
InputOTPSlot(index: 1)
InputOTPSlot(index: 2)
}
InputOTPSeparator()
InputOTPGroup {
InputOTPSlot(index: 3)
InputOTPSlot(index: 4)
InputOTPSlot(index: 5)
}
}
.accessibilityLabel(Text("Verification code"))
OtpAutofillStatus(state: state)
OtpAutofillSuggestion(code: suggestion)
}
.padding(.top, 16)
Button {
Task { await send() }
} label: {
Text(sending ? "Sending…" : "Resend code")
}
.buttonStyle(.variant(.plain))
.disabled(sending)
}
.frame(maxWidth: .infinity)
.task {
await send()
}
}
func send() async {
sending = true
suggestion = nil
try? await Task.sleep(for: .milliseconds(1200))
suggestion = incoming
sending = false
}
}import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.InputOTPGroup
import com.esuiss.ui.components.InputOTPSeparator
import com.esuiss.ui.components.InputOTPSlot
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.OtpAutofill
import com.esuiss.ui.interactions.OtpAutofillInput
import com.esuiss.ui.interactions.OtpAutofillSuggestion
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.delay
@Composable
fun OTPAutofillWrongCodeExample() {
OtpAutofillVerification(incoming = "113355")
}
@Composable
fun OtpAutofillVerification(incoming: String) {
val colors = EsuissTheme.colors
var suggestion by rememberSaveable { mutableStateOf<String?>(null) }
var sending by rememberSaveable { mutableStateOf(false) }
var request by rememberSaveable { mutableIntStateOf(0) }
LaunchedEffect(request) {
sending = true
suggestion = null
delay(1200)
suggestion = incoming
sending = false
}
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Enter the code", style = Typography.xl2.copy(fontWeight = FontWeight.Bold), textAlign = TextAlign.Center)
Text(
"We sent a 6-digit code to +1 555 0100.",
style = Typography.sm.copy(color = colors.labelSecondary),
textAlign = TextAlign.Center,
)
OtpAutofill(
maxLength = 6,
onVerify = { code ->
delay(700)
code == OtpAutofillCode
},
modifier = Modifier.padding(top = 16.dp),
) {
OtpAutofillInput(contentDescription = "Verification code", autoFocus = true) {
InputOTPGroup {
InputOTPSlot(0)
InputOTPSlot(1)
InputOTPSlot(2)
}
InputOTPSeparator()
InputOTPGroup {
InputOTPSlot(3)
InputOTPSlot(4)
InputOTPSlot(5)
}
}
OtpAutofillStatus()
OtpAutofillSuggestion(code = suggestion)
}
Button(
if (sending) "Sending…" else "Resend code",
onClick = { request += 1 },
variant = ButtonVariant.Plain,
enabled = !sending,
)
}
}Four digits
A four-digit code with its own suggestion.


import { OtpAutofill, OtpAutofillInput, OtpAutofillSuggestion, useOtpAutofill } from "@/components/interactions/otp-autofill"
import { InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
import { Text } from "@/components/ui/text"
function Status() {
const { state } = useOtpAutofill()
return (
<Text
role="status"
accessibilityLiveRegion="polite"
className={
state === "invalid"
? "min-h-5 text-sm text-danger"
: state === "verified"
? "min-h-5 text-sm text-success"
: "min-h-5 text-sm text-label-secondary"
}
>
{state === "verifying"
? "Verifying…"
: state === "verified"
? "Verified"
: state === "invalid"
? "That code didn't work. Try again."
: ""}
</Text>
)
}
export function OtpAutofillFourDigitsExample() {
return (
<OtpAutofill maxLength={4} onVerify={(code) => code === "2468"}>
<OtpAutofillInput accessibilityLabel="Verification code">
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
</OtpAutofillInput>
<Status />
<OtpAutofillSuggestion code="2468" source="From Messages" />
</OtpAutofill>
)
}import EsuissUI
import SwiftUI
struct OTPAutofillFourDigitsExample: View {
var body: some View {
OtpAutofill(length: 4, onVerify: { $0 == "2468" }) { state in
OtpAutofillInput {
InputOTPGroup {
InputOTPSlot(index: 0)
InputOTPSlot(index: 1)
InputOTPSlot(index: 2)
InputOTPSlot(index: 3)
}
}
.accessibilityLabel(Text("Verification code"))
OtpAutofillStatus(state: state)
OtpAutofillSuggestion(code: "2468", source: "From Messages")
}
.frame(maxWidth: .infinity)
}
}
struct OtpAutofillStatus: View {
let state: OtpAutofillState
var body: some View {
Text(text)
.textStyle(.sm)
.foregroundStyle(color)
.frame(minHeight: 20)
.accessibilityAddTraits(.updatesFrequently)
}
var text: LocalizedStringKey {
switch state {
case .verifying: "Verifying…"
case .verified: "Verified"
case .invalid: "That code didn't work. Try again."
case .idle, .filling: ""
}
}
var color: Color {
switch state {
case .verified: .success
case .invalid: .danger
default: .labelSecondary
}
}
}import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.esuiss.ui.components.InputOTPGroup
import com.esuiss.ui.components.InputOTPSlot
import com.esuiss.ui.interactions.OtpAutofill
import com.esuiss.ui.interactions.OtpAutofillInput
import com.esuiss.ui.interactions.OtpAutofillSuggestion
@Composable
fun OtpAutofillFourDigits() {
OtpAutofill(maxLength = 4, onVerify = { code -> code == "2468" }, modifier = Modifier.fillMaxWidth()) {
OtpAutofillInput(contentDescription = "Verification code") {
InputOTPGroup {
InputOTPSlot(0)
InputOTPSlot(1)
InputOTPSlot(2)
InputOTPSlot(3)
}
}
OtpAutofillStatus()
OtpAutofillSuggestion(code = "2468", source = "From Messages")
}
}API Reference
OtpAutofill
The root that holds the code and verification state.
React.ComponentProps<typeof View>- About
- Number of digits in the code.
- Type
number- Default
- required
- About
- Checks a complete code; return
truewhen it is valid. - Type
(code: string) => Promise<boolean> | boolean- Default
- required
OtpAutofillInput
The InputOTP that shows each state and shakes on an invalid code.
React.ComponentProps<typeof InputOTP>- About
- Classes for the animated container around the input.
- Type
string- Default
- —
- Type
React.ReactNode- Default
- required
OtpAutofillSuggestion
A bar above the keyboard that fills the suggested code on press.
React.ComponentProps<typeof Pressable>- About
- The code to suggest, or
nullto hide the bar. - Type
stringnullundefined- Default
- required
- About
- Label above the code. Defaults to
From Messages. - Type
React.ReactNode- Default
- "From Messages"
- About
- Name of a portal host to render into.
- Type
string- Default
- —
- Type
string- Default
- —
Accessibility
accessibilityLabelonOtpAutofillInputis passed to the underlying text input.- The suggestion bar has the
buttonrole. - The input is read-only while filling, verifying and after a valid code.
Notes
- Invalid slots use
border-dangerwith abg-dangertint that is stronger in dark mode; verified slots useborder-successandbg-success-surface. - The suggestion bar uses
bg-surface-raised, orbg-surface-secondaryin dark mode, and sits on the keyboard or above the bottom safe area. - Slide and shake animations respect the system reduce motion setting.