# OTP Autofill

A one-time code field that offers an incoming code, fills it digit by digit, and verifies it automatically.

OTP Autofill is an open-source interaction from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/interactions/otp-autofill

**React**

**Example**

```tsx
import * as React from "react"
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"

const CODE = "482913"

function wait(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

function Status() {
  const { state } = useOtpAutofill()

  return (
    <p
      role="status"
      className="min-h-5 text-sm text-muted-foreground data-[state=invalid]:text-destructive data-[state=verified]:text-green-600 dark:data-[state=verified]:text-green-400"
      data-state={state}
    >
      {state === "verifying" && "Verifying…"}
      {state === "verified" && "Verified"}
      {state === "invalid" && "That code didn't work. Try again."}
    </p>
  )
}

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 (
    <div className="mx-auto flex w-[min(28rem,calc(100vw-2rem))] flex-col items-center gap-2 text-center">
      <h2 className="text-2xl font-bold tracking-tight">Enter the code</h2>
      <p className="text-sm text-muted-foreground">
        We sent a 6-digit code to +1 555 0100.
      </p>
      <OtpAutofill
        maxLength={6}
        onVerify={async (code) => {
          await wait(700)
          return code === CODE
        }}
        className="mt-4"
      >
        <OtpAutofillInput autoFocus aria-label="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" onClick={send} disabled={sending}>
        {sending ? "Sending…" : "Resend code"}
      </Button>
    </div>
  )
}

export default function OtpAutofillDemo() {
  return <VerificationExample incoming={CODE} />
}
```

`OtpAutofill` builds on the suiss/ui `InputOTP` component to recreate the iOS security code autofill flow. When the field is focused and empty, `OtpAutofillSuggestion` offers the received code; choosing it types the digits in with a short pop animation and calls `onVerify` once all slots are filled. A valid code turns the slots green and locks the field; an invalid one shakes the slots, marks them red, then clears the field and refocuses it.

The suggestion adapts to the device through `useIsMobile`. Below `48rem` it docks above the on-screen keyboard like the QuickType bar, tracking the visual viewport; on wider screens it appears as an inline pill under the field. In supporting browsers, the WebOTP API also reads codes from SMS and fills them automatically.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/otp-autofill
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add interactions otp-autofill
```

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

```bash title="Terminal"
npm install @phosphor-icons/react
```

2. Add the pieces it builds on: [Input OTP](https://ui.suiss.app/docs/components/input-otp), `use-mobile`.

3. Copy the source into `components/interactions/otp-autofill.tsx`:

```tsx title="components/interactions/otp-autofill.tsx"
"use client"

import { ChatCircleTextIcon } from "@phosphor-icons/react"
import * as React from "react"
import { InputOTP } from "@/components/ui/input-otp"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"

type OtpAutofillState =
  | "idle"
  | "filling"
  | "verifying"
  | "verified"
  | "invalid"

type OtpAutofillContextProps = {
  state: OtpAutofillState
  value: string
  focused: boolean
  maxLength: number
  inputRef: React.RefObject<HTMLInputElement | null>
  fill: (code: string) => void
  type: (value: string) => void
  setFocused: (focused: boolean) => void
}

const OtpAutofillContext = React.createContext<OtpAutofillContextProps | null>(
  null
)

function useOtpAutofill() {
  const context = React.useContext(OtpAutofillContext)

  if (!context) {
    throw new Error("useOtpAutofill must be used within an <OtpAutofill />")
  }

  return context
}

const FILL_INTERVAL = 70
const INVALID_HOLD = 600

type OTPCredentialLike = Credential & { code: string }

function wait(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

function fieldsOf(root: HTMLElement | null) {
  return root?.querySelector<HTMLElement>("[data-input-otp-container]") ?? null
}

function reducesMotion() {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches
}

function pop(container: HTMLElement | null, index: number) {
  if (reducesMotion()) return
  const slot = container?.querySelectorAll<HTMLElement>(
    "[data-slot=input-otp-slot]"
  )[index]
  slot?.animate([{ scale: 0.8 }, { scale: 1.08, offset: 0.6 }, { scale: 1 }], {
    duration: 240,
    easing: "cubic-bezier(0.32, 0.72, 0, 1)",
  })
}

function shake(container: HTMLElement | null) {
  if (reducesMotion()) return
  container?.animate(
    [
      { translate: "0" },
      { translate: "-8px" },
      { translate: "7px" },
      { translate: "-5px" },
      { translate: "3px" },
      { translate: "0" },
    ],
    { duration: 400, easing: "ease-out" }
  )
}

function OtpAutofill({
  maxLength,
  onVerify,
  webOtp = true,
  className,
  ref,
  ...props
}: React.ComponentProps<"div"> & {
  maxLength: number
  onVerify: (code: string) => Promise<boolean> | boolean
  webOtp?: boolean
}) {
  const [value, setValue] = React.useState("")
  const [state, setState] = React.useState<OtpAutofillState>("idle")
  const [focused, setFocused] = React.useState(false)
  const rootRef = React.useRef<HTMLDivElement>(null)
  const inputRef = React.useRef<HTMLInputElement>(null)
  const runRef = React.useRef(0)
  const onVerifyRef = React.useRef(onVerify)

  React.useEffect(() => {
    onVerifyRef.current = onVerify
  }, [onVerify])

  const verify = React.useCallback(async (code: string, run: number) => {
    setState("verifying")
    let valid = false
    try {
      valid = await onVerifyRef.current(code)
    } catch {
      valid = false
    }
    if (run !== runRef.current) return
    if (valid) {
      setState("verified")
      return
    }
    setState("invalid")
    shake(fieldsOf(rootRef.current))
    await wait(INVALID_HOLD)
    if (run !== runRef.current) return
    setValue("")
    setState("idle")
    inputRef.current?.focus()
  }, [])

  const fill = React.useCallback(
    async (code: string) => {
      const digits = code.replace(/\s/g, "").slice(0, maxLength)
      if (!digits) return
      runRef.current += 1
      const run = runRef.current
      setState("filling")
      for (let index = 1; index <= digits.length; index += 1) {
        setValue(digits.slice(0, index))
        pop(fieldsOf(rootRef.current), index - 1)
        await wait(FILL_INTERVAL)
        if (run !== runRef.current) return
      }
      if (digits.length === maxLength) verify(digits, run)
      else setState("idle")
    },
    [maxLength, verify]
  )

  const type = (next: string) => {
    if (next.length - value.length > 1) {
      fill(next)
      return
    }
    runRef.current += 1
    setState("idle")
    setValue(next)
    if (next.length === maxLength) verify(next, runRef.current)
  }

  React.useEffect(() => {
    if (!webOtp || !("OTPCredential" in window)) return
    const controller = new AbortController()
    navigator.credentials
      .get({
        otp: { transport: ["sms"] },
        signal: controller.signal,
      } as CredentialRequestOptions)
      .then((credential) => {
        const code = (credential as OTPCredentialLike | null)?.code
        if (code) fill(code)
      })
      .catch(() => undefined)
    return () => controller.abort()
  }, [webOtp, fill])

  return (
    <OtpAutofillContext.Provider
      value={{
        state,
        value,
        focused,
        maxLength,
        inputRef,
        fill,
        type,
        setFocused,
      }}
    >
      <div
        ref={(node) => {
          rootRef.current = node
          if (typeof ref === "function") return ref(node)
          if (ref) ref.current = node
        }}
        data-slot="otp-autofill"
        data-state={state}
        className={cn(
          "group/otp-autofill flex flex-col items-center gap-4",
          className
        )}
        {...props}
      />
    </OtpAutofillContext.Provider>
  )
}

function OtpAutofillInput({
  containerClassName,
  onFocus,
  onBlur,
  children,
  ref,
  ...props
}: Omit<
  React.ComponentProps<"input">,
  "value" | "onChange" | "maxLength" | "children"
> & {
  containerClassName?: string
  children: React.ReactNode
}) {
  const { state, value, maxLength, inputRef, type, setFocused } =
    useOtpAutofill()
  const locked =
    state === "filling" || state === "verifying" || state === "verified"

  return (
    <InputOTP
      ref={(node) => {
        inputRef.current = node
        if (typeof ref === "function") return ref(node)
        if (ref) ref.current = node
      }}
      data-slot="otp-autofill-input"
      maxLength={maxLength}
      value={value}
      readOnly={locked}
      aria-invalid={state === "invalid" || undefined}
      onChange={(next) => {
        if (!locked) type(next)
      }}
      onFocus={(event) => {
        setFocused(true)
        onFocus?.(event)
      }}
      onBlur={(event) => {
        setFocused(false)
        onBlur?.(event)
      }}
      containerClassName={cn(
        "[&_[data-slot=input-otp-slot]]:transition-[background-color,border-color,color,opacity] [&_[data-slot=input-otp-slot]]:duration-300 group-data-[state=invalid]/otp-autofill:[&_[data-slot=input-otp-slot]]:border-destructive group-data-[state=invalid]/otp-autofill:[&_[data-slot=input-otp-slot]]:text-destructive group-data-[state=verified]/otp-autofill:[&_[data-slot=input-otp-slot]]:border-green-500 group-data-[state=verified]/otp-autofill:[&_[data-slot=input-otp-slot]]:bg-green-500/10 group-data-[state=verified]/otp-autofill:[&_[data-slot=input-otp-slot]]:text-green-600 dark:group-data-[state=verified]/otp-autofill:[&_[data-slot=input-otp-slot]]:text-green-400 group-data-[state=verifying]/otp-autofill:[&_[data-slot=input-otp-slot]]:opacity-60",
        containerClassName
      )}
      {...props}
    >
      {children}
    </InputOTP>
  )
}

function OtpAutofillSuggestion({
  code,
  source = "From Messages",
  className,
  onPointerDown,
  onClick,
  ...props
}: Omit<React.ComponentProps<"button">, "children"> & {
  code: string | null | undefined
  source?: React.ReactNode
}) {
  const { state, value, focused, fill } = useOtpAutofill()
  const isMobile = useIsMobile()
  const [keyboardInset, setKeyboardInset] = React.useState(0)
  const open = Boolean(code) && focused && value === "" && state === "idle"

  React.useEffect(() => {
    const viewport = window.visualViewport
    if (!isMobile || !viewport) return
    const update = () =>
      setKeyboardInset(
        Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)
      )
    update()
    viewport.addEventListener("resize", update)
    viewport.addEventListener("scroll", update)
    return () => {
      viewport.removeEventListener("resize", update)
      viewport.removeEventListener("scroll", update)
    }
  }, [isMobile])

  return (
    <button
      type="button"
      data-slot="otp-autofill-suggestion"
      data-closed={open ? undefined : ""}
      data-placement={isMobile ? "keyboard" : "inline"}
      inert={!open}
      style={isMobile ? { bottom: keyboardInset } : undefined}
      onPointerDown={(event) => {
        event.preventDefault()
        onPointerDown?.(event)
      }}
      onClick={(event) => {
        onClick?.(event)
        if (!event.defaultPrevented && code) fill(code)
      }}
      className={cn(
        "flex items-center outline-none transition-[opacity,translate,visibility] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] transition-discrete select-none focus-visible:ring-3 focus-visible:ring-ring/50 data-closed:invisible data-closed:opacity-0",
        "data-[placement=inline]:gap-2.5 data-[placement=inline]:rounded-full data-[placement=inline]:bg-popover data-[placement=inline]:py-2 data-[placement=inline]:ps-3 data-[placement=inline]:pe-4 data-[placement=inline]:text-sm data-[placement=inline]:shadow-lg data-[placement=inline]:ring-1 data-[placement=inline]:ring-border data-[placement=inline]:hover:bg-accent motion-safe:data-[placement=inline]:data-closed:-translate-y-1 data-[placement=inline]:hover:text-accent-foreground",
        "data-[placement=keyboard]:fixed data-[placement=keyboard]:inset-x-0 data-[placement=keyboard]:z-50 data-[placement=keyboard]:min-h-11 data-[placement=keyboard]:flex-col data-[placement=keyboard]:justify-center data-[placement=keyboard]:border-t data-[placement=keyboard]:border-border data-[placement=keyboard]:bg-muted/85 data-[placement=keyboard]:pt-1.5 data-[placement=keyboard]:pb-[max(0.375rem,env(safe-area-inset-bottom))] data-[placement=keyboard]:backdrop-blur-xl motion-safe:data-[placement=keyboard]:data-closed:translate-y-2",
        className
      )}
      {...props}
    >
      {isMobile ? (
        <>
          <span className="text-xs text-muted-foreground">{source}</span>
          <span className="text-base font-semibold tracking-wider tabular-nums">
            {code}
          </span>
        </>
      ) : (
        <>
          <ChatCircleTextIcon
            aria-hidden
            className="size-5 shrink-0 text-green-600 dark:text-green-400"
          />
          <span className="text-muted-foreground">{source}</span>
          <span className="font-semibold tracking-wider tabular-nums">
            {code}
          </span>
        </>
      )}
    </button>
  )
}

export { OtpAutofill, OtpAutofillInput, OtpAutofillSuggestion, useOtpAutofill }
```

4. Update the import paths to match your project.

## Usage

```tsx
import { OtpAutofill, OtpAutofillInput, OtpAutofillSuggestion } from "@/components/interactions/otp-autofill"
```

```tsx
<VerificationExample incoming={CODE} />
```

- Place `OtpAutofillInput` and `OtpAutofillSuggestion` inside `OtpAutofill`. Pass `InputOTPGroup`, `InputOTPSlot`, and `InputOTPSeparator` from `input-otp` as the input's children.
- The value is managed internally. Typing or pasting also verifies automatically once the code reaches `maxLength`; a paste of several characters fills with the same animation.
- `onVerify` may be async. Use `useOtpAutofill` to read `state` (`idle`, `filling`, `verifying`, `verified`, `invalid`) for status text or other UI.
- Set `code` on the suggestion to `null` until a code arrives; the suggestion stays hidden without one.

## Examples

### Wrong code

A rejected code shakes the field, shows an error, clears, and accepts a code typed by hand.

**Example**

```tsx
import * as React from "react"
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"

const CODE = "482913"

function wait(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

function Status() {
  const { state } = useOtpAutofill()

  return (
    <p
      role="status"
      className="min-h-5 text-sm text-muted-foreground data-[state=invalid]:text-destructive data-[state=verified]:text-green-600 dark:data-[state=verified]:text-green-400"
      data-state={state}
    >
      {state === "verifying" && "Verifying…"}
      {state === "verified" && "Verified"}
      {state === "invalid" && "That code didn't work. Try again."}
    </p>
  )
}

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 (
    <div className="mx-auto flex w-[min(28rem,calc(100vw-2rem))] flex-col items-center gap-2 text-center">
      <h2 className="text-2xl font-bold tracking-tight">Enter the code</h2>
      <p className="text-sm text-muted-foreground">
        We sent a 6-digit code to +1 555 0100.
      </p>
      <OtpAutofill
        maxLength={6}
        onVerify={async (code) => {
          await wait(700)
          return code === CODE
        }}
        className="mt-4"
      >
        <OtpAutofillInput autoFocus aria-label="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" onClick={send} disabled={sending}>
        {sending ? "Sending…" : "Resend code"}
      </Button>
    </div>
  )
}

export default function OtpAutofillWrongCode() {
  return <VerificationExample incoming="113355" />
}
```

## API Reference

### OtpAutofill

The root that holds the code value and verification state.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `maxLength` | `number` | Required | The number of characters in the code; verification runs once it is reached. |
| `onVerify` | `(code: string) => Promise<boolean> \| boolean` | Required | Called with the complete code; return or resolve `true` to accept it or `false` to reject it. |
| `webOtp` | `boolean` | `true` | Listens for SMS codes through the WebOTP API, where supported. |

Also accepts the props of `React.ComponentProps<"div">`.

### OtpAutofillInput

The `InputOTP` field wired to the autofill state.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `containerClassName` | `string` | - | Classes for the slot container, merged with the state styles. |
| `children` | `React.ReactNode` | Required | The slot groups and separators that make up the field. |

Also accepts the props of `React.ComponentProps<"input">`.

### OtpAutofillSuggestion

The button that offers the received code and fills it when pressed.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `code` | `string \| null \| undefined` | Required | The received code to offer; the suggestion is hidden while it is empty. |
| `source` | `React.ReactNode` | `"From Messages"` | The label that names where the code came from. |

Also accepts the props of `React.ComponentProps<"button">`.

## Accessibility

- Label the field with `aria-label` on `OtpAutofillInput`, which renders a single text input.
- The input is read-only while filling, verifying, or verified, and sets `aria-invalid` when the code is rejected.
- The suggestion is a native button that is `inert` while hidden, and it prevents focus from leaving the field on press.
- After a rejected code, focus returns to the field. Pair the root with a `role="status"` element to announce the result.

## Notes

- The root exposes `data-slot="otp-autofill"` and `data-state`. The suggestion uses `data-slot="otp-autofill-suggestion"`, `data-placement` (`keyboard` or `inline`), and `data-closed` while hidden.
- The pop and shake animations are skipped under `prefers-reduced-motion`.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/interactions-otp-autofill--default.light.webp)

```tsx
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 statusClassName(state: string) {
  if (state === "invalid") return "min-h-5 text-sm text-destructive"
  if (state === "verified")
    return "min-h-5 text-sm text-green-600 dark:text-green-400"
  return "min-h-5 text-sm text-muted-foreground"
}

function statusText(state: string) {
  if (state === "verifying") return "Verifying…"
  if (state === "verified") return "Verified"
  if (state === "invalid") return "That code didn't work. Try again."
  return ""
}

function Status() {
  const { state } = useOtpAutofill()

  return (
    <Text
      role="status"
      accessibilityLiveRegion="polite"
      className={statusClassName(state)}
    >
      {statusText(state)}
    </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-muted-foreground">
        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} />
  )
}
```

```swift
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: Color.adaptive(light: Palette.green600, dark: Palette.green400)
    case .invalid: EsuissColor.destructive
    default: EsuissColor.mutedForeground
    }
  }
}

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(EsuissColor.foreground)
        .multilineTextAlignment(.center)
      Text("We sent a 6-digit code to +1 555 0100.")
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .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
  }
}
```

```kotlin
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.mutedForeground),
            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

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/otp-autofill
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add otp-autofill
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add otp-autofill --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add otp-autofill --platform kotlin
```

The CLI also adds what it builds on: [Input OTP](https://ui.suiss.app/docs/components/input-otp), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn react-native-reanimated react-native-safe-area-context react-native-worklets
```

2. Add the pieces it builds on: [Input OTP](https://ui.suiss.app/docs/components/input-otp), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/keyboard.tsx`, `components/primitives/portal.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/interactions/otp-autofill.tsx`:

```tsx title="uniwind/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"
import { InputOTP, InputOTPSlotClassContext } from "@/components/ui/input-otp"
import { Text } from "@/components/ui/text"

type OtpAutofillState =
  | "idle"
  | "filling"
  | "verifying"
  | "verified"
  | "invalid"

type OtpAutofillContextProps = {
  state: OtpAutofillState
  value: string
  focused: boolean
  maxLength: number
  inputRef: React.RefObject<React.ComponentRef<typeof TextInput> | null>
  fill: (code: string) => void
  type: (value: string) => void
  setFocused: (focused: boolean) => void
}

const OtpAutofillContext = React.createContext<OtpAutofillContextProps | null>(
  null
)

function useOtpAutofill() {
  const context = React.useContext(OtpAutofillContext)

  if (!context) {
    throw new Error("useOtpAutofill must be used within an <OtpAutofill />")
  }

  return context
}

const FILL_INTERVAL = 70
const INVALID_HOLD = 600
const SHAKE_STEP = 400 / 5

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

const ENTERING = new Keyframe({
  0: { opacity: 0, transform: [{ translateY: 8 }] },
  100: { opacity: 1, transform: [{ translateY: 0 }], easing: EASING },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

const EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ translateY: 0 }] },
  100: { opacity: 0, transform: [{ translateY: 8 }], easing: EASING },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

const SLOT_CLASS_NAMES: Partial<
  Record<OtpAutofillState, { className: string; textClassName?: string }>
> = {
  invalid: {
    className: "border-destructive bg-destructive/5 dark:bg-destructive/10",
    textClassName: "text-destructive",
  },
  verified: {
    className: "border-green-500 bg-green-500/10 dark:bg-green-500/20",
    textClassName: "text-green-600 dark:text-green-400",
  },
  verifying: { className: "opacity-60" },
}

function wait(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

function OtpAutofill({
  maxLength,
  onVerify,
  className,
  ...props
}: React.ComponentProps<typeof View> & {
  maxLength: number
  onVerify: (code: string) => Promise<boolean> | boolean
}) {
  const [value, setValue] = React.useState("")
  const [state, setState] = React.useState<OtpAutofillState>("idle")
  const [focused, setFocused] = React.useState(false)
  const inputRef = React.useRef<React.ComponentRef<typeof TextInput>>(null)
  const runRef = React.useRef(0)
  const onVerifyRef = React.useRef(onVerify)

  React.useEffect(() => {
    onVerifyRef.current = onVerify
  }, [onVerify])

  const verify = React.useCallback(async (code: string, run: number) => {
    setState("verifying")
    let valid = false
    try {
      valid = await onVerifyRef.current(code)
    } catch {
      valid = false
    }
    if (run !== runRef.current) return
    if (valid) {
      setState("verified")
      return
    }
    setState("invalid")
    await wait(INVALID_HOLD)
    if (run !== runRef.current) return
    setValue("")
    setState("idle")
    inputRef.current?.focus()
  }, [])

  const fill = React.useCallback(
    async (code: string) => {
      const digits = code.replace(/\s/g, "").slice(0, maxLength)
      if (!digits) return
      runRef.current += 1
      const run = runRef.current
      setState("filling")
      for (let index = 1; index <= digits.length; index += 1) {
        setValue(digits.slice(0, index))
        await wait(FILL_INTERVAL)
        if (run !== runRef.current) return
      }
      if (digits.length === maxLength) verify(digits, run)
      else setState("idle")
    },
    [maxLength, verify]
  )

  const type = React.useCallback(
    (next: string) => {
      if (next.length - value.length > 1) {
        fill(next)
        return
      }
      runRef.current += 1
      setState("idle")
      setValue(next)
      if (next.length === maxLength) verify(next, runRef.current)
    },
    [value, maxLength, fill, verify]
  )

  const context = React.useMemo(
    () => ({
      state,
      value,
      focused,
      maxLength,
      inputRef,
      fill,
      type,
      setFocused,
    }),
    [state, value, focused, maxLength, fill, type]
  )

  return (
    <OtpAutofillContext.Provider value={context}>
      <View className={cn("items-center gap-4", className)} {...props} />
    </OtpAutofillContext.Provider>
  )
}

function OtpAutofillInput({
  containerClassName,
  accessibilityLabel,
  inputProps,
  children,
  ...props
}: Omit<
  React.ComponentProps<typeof InputOTP>,
  | "length"
  | "value"
  | "defaultValue"
  | "onValueChange"
  | "readOnly"
  | "inputRef"
  | "children"
> & {
  containerClassName?: string
  children: React.ReactNode
}) {
  const { state, value, maxLength, inputRef, type, setFocused } =
    useOtpAutofill()
  const offset = useSharedValue(0)
  const locked =
    state === "filling" || state === "verifying" || state === "verified"

  React.useEffect(() => {
    if (state !== "invalid") return
    const step = { duration: SHAKE_STEP, reduceMotion: ReduceMotion.System }
    offset.value = withSequence(
      withTiming(-8, step),
      withTiming(7, step),
      withTiming(-5, step),
      withTiming(3, step),
      withTiming(0, step)
    )
  }, [state, offset])

  const shakeStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: offset.value }],
  }))

  return (
    <AnimatedView style={shakeStyle} className={containerClassName}>
      <InputOTPSlotClassContext.Provider value={SLOT_CLASS_NAMES[state]}>
        <InputOTP
          {...props}
          length={maxLength}
          value={value}
          readOnly={locked}
          inputRef={inputRef}
          inputProps={{
            textContentType: "oneTimeCode",
            autoComplete: Platform.OS === "ios" ? "one-time-code" : "sms-otp",
            ...inputProps,
            ...(accessibilityLabel ? { accessibilityLabel } : null),
            onFocus: (event) => {
              setFocused(true)
              inputProps?.onFocus?.(event)
            },
            onBlur: (event) => {
              setFocused(false)
              inputProps?.onBlur?.(event)
            },
          }}
          onValueChange={(next) => {
            if (!locked) type(next)
          }}
        >
          {children}
        </InputOTP>
      </InputOTPSlotClassContext.Provider>
    </AnimatedView>
  )
}

function OtpAutofillSuggestion({
  code,
  source = "From Messages",
  portalHost,
  className,
  style,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children" | "onPress"> & {
  code: string | null | undefined
  source?: React.ReactNode
  portalHost?: string
  className?: string
}) {
  const context = useOtpAutofill()
  const { state, value, focused, fill } = context
  const keyboard = useKeyboard()
  const insets = useSafeAreaInsets()
  const id = React.useId()
  const open = Boolean(code) && focused && value === "" && state === "idle"

  return (
    <Portal name={`otp-autofill-${id}`} hostName={portalHost}>
      {open ? (
        <OtpAutofillContext.Provider value={context}>
          <AnimatedView
            entering={ENTERING}
            exiting={EXITING}
            className="absolute inset-x-0 z-50 border-t border-border bg-popover dark:bg-muted"
            style={{ bottom: keyboard.height }}
          >
            <Pressable
              role="button"
              onPress={() => {
                if (code) fill(code)
              }}
              className={cn(
                "min-h-11 items-center justify-center pt-1.5 active:opacity-80",
                className
              )}
              {...props}
              style={(pressState) => [
                {
                  paddingBottom:
                    keyboard.height > 0 ? 6 : Math.max(6, insets.bottom),
                },
                typeof style === "function" ? style(pressState) : style,
              ]}
            >
              {typeof source === "string" || typeof source === "number" ? (
                <Text className="text-xs text-muted-foreground">{source}</Text>
              ) : (
                source
              )}
              <Text className="text-base font-semibold tracking-wider tabular-nums">
                {code}
              </Text>
            </Pressable>
          </AnimatedView>
        </OtpAutofillContext.Provider>
      ) : null}
    </Portal>
  )
}

export { OtpAutofill, OtpAutofillInput, OtpAutofillSuggestion, useOtpAutofill }
```

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn react-native-reanimated react-native-safe-area-context react-native-worklets
```

2. Add the pieces it builds on: [Input OTP](https://ui.suiss.app/docs/components/input-otp), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/keyboard.tsx`, `components/primitives/portal.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/interactions/otp-autofill.tsx`:

```tsx title="nativewind/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"
import { InputOTP, InputOTPSlotClassContext } from "@/components/ui/input-otp"
import { Text } from "@/components/ui/text"

type OtpAutofillState =
  | "idle"
  | "filling"
  | "verifying"
  | "verified"
  | "invalid"

type OtpAutofillContextProps = {
  state: OtpAutofillState
  value: string
  focused: boolean
  maxLength: number
  inputRef: React.RefObject<React.ComponentRef<typeof TextInput> | null>
  fill: (code: string) => void
  type: (value: string) => void
  setFocused: (focused: boolean) => void
}

const OtpAutofillContext = React.createContext<OtpAutofillContextProps | null>(
  null
)

function useOtpAutofill() {
  const context = React.useContext(OtpAutofillContext)

  if (!context) {
    throw new Error("useOtpAutofill must be used within an <OtpAutofill />")
  }

  return context
}

const FILL_INTERVAL = 70
const INVALID_HOLD = 600
const SHAKE_STEP = 400 / 5

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

const ENTERING = new Keyframe({
  0: { opacity: 0, transform: [{ translateY: 8 }] },
  100: { opacity: 1, transform: [{ translateY: 0 }], easing: EASING },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

const EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ translateY: 0 }] },
  100: { opacity: 0, transform: [{ translateY: 8 }], easing: EASING },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

const SLOT_CLASS_NAMES: Partial<
  Record<OtpAutofillState, { className: string; textClassName?: string }>
> = {
  invalid: {
    className: "border-destructive bg-destructive-5 dark:bg-destructive-10",
    textClassName: "text-destructive",
  },
  verified: {
    className: "border-green-500 bg-green-500/10 dark:bg-green-500/20",
    textClassName: "text-green-600 dark:text-green-400",
  },
  verifying: { className: "opacity-60" },
}

function wait(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

function OtpAutofill({
  maxLength,
  onVerify,
  className,
  ...props
}: React.ComponentProps<typeof View> & {
  maxLength: number
  onVerify: (code: string) => Promise<boolean> | boolean
}) {
  const [value, setValue] = React.useState("")
  const [state, setState] = React.useState<OtpAutofillState>("idle")
  const [focused, setFocused] = React.useState(false)
  const inputRef = React.useRef<React.ComponentRef<typeof TextInput>>(null)
  const runRef = React.useRef(0)
  const onVerifyRef = React.useRef(onVerify)

  React.useEffect(() => {
    onVerifyRef.current = onVerify
  }, [onVerify])

  const verify = React.useCallback(async (code: string, run: number) => {
    setState("verifying")
    let valid = false
    try {
      valid = await onVerifyRef.current(code)
    } catch {
      valid = false
    }
    if (run !== runRef.current) return
    if (valid) {
      setState("verified")
      return
    }
    setState("invalid")
    await wait(INVALID_HOLD)
    if (run !== runRef.current) return
    setValue("")
    setState("idle")
    inputRef.current?.focus()
  }, [])

  const fill = React.useCallback(
    async (code: string) => {
      const digits = code.replace(/\s/g, "").slice(0, maxLength)
      if (!digits) return
      runRef.current += 1
      const run = runRef.current
      setState("filling")
      for (let index = 1; index <= digits.length; index += 1) {
        setValue(digits.slice(0, index))
        await wait(FILL_INTERVAL)
        if (run !== runRef.current) return
      }
      if (digits.length === maxLength) verify(digits, run)
      else setState("idle")
    },
    [maxLength, verify]
  )

  const type = React.useCallback(
    (next: string) => {
      if (next.length - value.length > 1) {
        fill(next)
        return
      }
      runRef.current += 1
      setState("idle")
      setValue(next)
      if (next.length === maxLength) verify(next, runRef.current)
    },
    [value, maxLength, fill, verify]
  )

  const context = React.useMemo(
    () => ({
      state,
      value,
      focused,
      maxLength,
      inputRef,
      fill,
      type,
      setFocused,
    }),
    [state, value, focused, maxLength, fill, type]
  )

  return (
    <OtpAutofillContext.Provider value={context}>
      <View className={cn("items-center gap-4", className)} {...props} />
    </OtpAutofillContext.Provider>
  )
}

function OtpAutofillInput({
  containerClassName,
  accessibilityLabel,
  inputProps,
  children,
  ...props
}: Omit<
  React.ComponentProps<typeof InputOTP>,
  | "length"
  | "value"
  | "defaultValue"
  | "onValueChange"
  | "readOnly"
  | "inputRef"
  | "children"
> & {
  containerClassName?: string
  children: React.ReactNode
}) {
  const { state, value, maxLength, inputRef, type, setFocused } =
    useOtpAutofill()
  const offset = useSharedValue(0)
  const locked =
    state === "filling" || state === "verifying" || state === "verified"

  React.useEffect(() => {
    if (state !== "invalid") return
    const step = { duration: SHAKE_STEP, reduceMotion: ReduceMotion.System }
    offset.value = withSequence(
      withTiming(-8, step),
      withTiming(7, step),
      withTiming(-5, step),
      withTiming(3, step),
      withTiming(0, step)
    )
  }, [state, offset])

  const shakeStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: offset.value }],
  }))

  return (
    <AnimatedView style={shakeStyle} className={containerClassName}>
      <InputOTPSlotClassContext.Provider value={SLOT_CLASS_NAMES[state]}>
        <InputOTP
          {...props}
          length={maxLength}
          value={value}
          readOnly={locked}
          inputRef={inputRef}
          inputProps={{
            textContentType: "oneTimeCode",
            autoComplete: Platform.OS === "ios" ? "one-time-code" : "sms-otp",
            ...inputProps,
            ...(accessibilityLabel ? { accessibilityLabel } : null),
            onFocus: (event) => {
              setFocused(true)
              inputProps?.onFocus?.(event)
            },
            onBlur: (event) => {
              setFocused(false)
              inputProps?.onBlur?.(event)
            },
          }}
          onValueChange={(next) => {
            if (!locked) type(next)
          }}
        >
          {children}
        </InputOTP>
      </InputOTPSlotClassContext.Provider>
    </AnimatedView>
  )
}

function OtpAutofillSuggestion({
  code,
  source = "From Messages",
  portalHost,
  className,
  style,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children" | "onPress"> & {
  code: string | null | undefined
  source?: React.ReactNode
  portalHost?: string
  className?: string
}) {
  const context = useOtpAutofill()
  const { state, value, focused, fill } = context
  const keyboard = useKeyboard()
  const insets = useSafeAreaInsets()
  const id = React.useId()
  const open = Boolean(code) && focused && value === "" && state === "idle"

  return (
    <Portal name={`otp-autofill-${id}`} hostName={portalHost}>
      {open ? (
        <OtpAutofillContext.Provider value={context}>
          <AnimatedView
            entering={ENTERING}
            exiting={EXITING}
            className="absolute inset-x-0 z-50 border-t border-border bg-popover dark:bg-muted"
            style={{ bottom: keyboard.height }}
          >
            <Pressable
              role="button"
              onPress={() => {
                if (code) fill(code)
              }}
              className={cn(
                "min-h-11 items-center justify-center pt-1.5 active:opacity-80",
                className
              )}
              {...props}
              style={(pressState) => [
                {
                  paddingBottom:
                    keyboard.height > 0 ? 6 : Math.max(6, insets.bottom),
                },
                typeof style === "function" ? style(pressState) : style,
              ]}
            >
              {typeof source === "string" || typeof source === "number" ? (
                <Text className="text-xs text-muted-foreground">{source}</Text>
              ) : (
                source
              )}
              <Text className="text-base font-semibold tracking-wider tabular-nums">
                {code}
              </Text>
            </Pressable>
          </AnimatedView>
        </OtpAutofillContext.Provider>
      ) : null}
    </Portal>
  )
}

export { OtpAutofill, OtpAutofillInput, OtpAutofillSuggestion, useOtpAutofill }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install react-native-reanimated react-native-safe-area-context react-native-worklets
```

2. Add the pieces it builds on: [Input OTP](https://ui.suiss.app/docs/components/input-otp), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/keyboard.tsx`, `components/primitives/portal.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/interactions/otp-autofill.tsx`:

```tsx title="stylesheet/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,
  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"
import {
  InputOTP,
  InputOTPSlotStyleContext,
  type InputOTPSlotStyles,
} from "@/components/ui/input-otp"
import { Text } from "@/components/ui/text"
import {
  fontSize,
  palette,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

type OtpAutofillState =
  | "idle"
  | "filling"
  | "verifying"
  | "verified"
  | "invalid"

type OtpAutofillContextProps = {
  state: OtpAutofillState
  value: string
  focused: boolean
  maxLength: number
  inputRef: React.RefObject<React.ComponentRef<typeof TextInput> | null>
  fill: (code: string) => void
  type: (value: string) => void
  setFocused: (focused: boolean) => void
}

const OtpAutofillContext = React.createContext<OtpAutofillContextProps | null>(
  null
)

function useOtpAutofill() {
  const context = React.useContext(OtpAutofillContext)

  if (!context) {
    throw new Error("useOtpAutofill must be used within an <OtpAutofill />")
  }

  return context
}

const FILL_INTERVAL = 70
const INVALID_HOLD = 600
const SHAKE_STEP = 400 / 5

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

const ENTERING = new Keyframe({
  0: { opacity: 0, transform: [{ translateY: 8 }] },
  100: { opacity: 1, transform: [{ translateY: 0 }], easing: EASING },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

const EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ translateY: 0 }] },
  100: { opacity: 0, transform: [{ translateY: 8 }], easing: EASING },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

const styles = StyleSheet.create({
  root: { alignItems: "center", gap: 16 },
  frame: {
    position: "absolute",
    left: 0,
    right: 0,
    zIndex: 50,
    borderTopWidth: 1,
  },
  suggestion: {
    minHeight: 44,
    alignItems: "center",
    justifyContent: "center",
    paddingTop: 6,
  },
  source: { ...fontSize.xs },
  code: {
    ...fontSize.base,
    fontWeight: "600",
    letterSpacing: 0.85,
    fontVariant: ["tabular-nums"],
  },
})

function withAlpha(color: string, alpha: number) {
  if (!color.startsWith("#") || color.length !== 7) return color
  const r = Number.parseInt(color.slice(1, 3), 16)
  const g = Number.parseInt(color.slice(3, 5), 16)
  const b = Number.parseInt(color.slice(5, 7), 16)
  return `rgba(${r}, ${g}, ${b}, ${alpha})`
}

function slotStyles(
  state: OtpAutofillState,
  colors: ThemeColors,
  isDark: boolean
): InputOTPSlotStyles | undefined {
  switch (state) {
    case "invalid":
      return {
        style: {
          borderColor: colors.destructive,
          backgroundColor: withAlpha(colors.destructive, isDark ? 0.1 : 0.05),
        },
        textStyle: { color: colors.destructive },
      }
    case "verified":
      return {
        style: {
          borderColor: palette.green500,
          backgroundColor: withAlpha(palette.green500, isDark ? 0.2 : 0.1),
        },
        textStyle: { color: isDark ? palette.green400 : palette.green600 },
      }
    case "verifying":
      return { style: { opacity: 0.6 } }
    default:
      return undefined
  }
}

function wait(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

function OtpAutofill({
  maxLength,
  onVerify,
  style,
  ...props
}: React.ComponentProps<typeof View> & {
  maxLength: number
  onVerify: (code: string) => Promise<boolean> | boolean
}) {
  const [value, setValue] = React.useState("")
  const [state, setState] = React.useState<OtpAutofillState>("idle")
  const [focused, setFocused] = React.useState(false)
  const inputRef = React.useRef<React.ComponentRef<typeof TextInput>>(null)
  const runRef = React.useRef(0)
  const onVerifyRef = React.useRef(onVerify)

  React.useEffect(() => {
    onVerifyRef.current = onVerify
  }, [onVerify])

  const verify = React.useCallback(async (code: string, run: number) => {
    setState("verifying")
    let valid = false
    try {
      valid = await onVerifyRef.current(code)
    } catch {
      valid = false
    }
    if (run !== runRef.current) return
    if (valid) {
      setState("verified")
      return
    }
    setState("invalid")
    await wait(INVALID_HOLD)
    if (run !== runRef.current) return
    setValue("")
    setState("idle")
    inputRef.current?.focus()
  }, [])

  const fill = React.useCallback(
    async (code: string) => {
      const digits = code.replace(/\s/g, "").slice(0, maxLength)
      if (!digits) return
      runRef.current += 1
      const run = runRef.current
      setState("filling")
      for (let index = 1; index <= digits.length; index += 1) {
        setValue(digits.slice(0, index))
        await wait(FILL_INTERVAL)
        if (run !== runRef.current) return
      }
      if (digits.length === maxLength) verify(digits, run)
      else setState("idle")
    },
    [maxLength, verify]
  )

  const type = React.useCallback(
    (next: string) => {
      if (next.length - value.length > 1) {
        fill(next)
        return
      }
      runRef.current += 1
      setState("idle")
      setValue(next)
      if (next.length === maxLength) verify(next, runRef.current)
    },
    [value, maxLength, fill, verify]
  )

  const context = React.useMemo(
    () => ({
      state,
      value,
      focused,
      maxLength,
      inputRef,
      fill,
      type,
      setFocused,
    }),
    [state, value, focused, maxLength, fill, type]
  )

  return (
    <OtpAutofillContext.Provider value={context}>
      <View style={[styles.root, style]} {...props} />
    </OtpAutofillContext.Provider>
  )
}

function OtpAutofillInput({
  containerStyle,
  accessibilityLabel,
  inputProps,
  children,
  ...props
}: Omit<
  React.ComponentProps<typeof InputOTP>,
  | "length"
  | "value"
  | "defaultValue"
  | "onValueChange"
  | "readOnly"
  | "inputRef"
  | "children"
> & {
  containerStyle?: StyleProp<ViewStyle>
  children: React.ReactNode
}) {
  const { colors, isDark } = useTheme()
  const { state, value, maxLength, inputRef, type, setFocused } =
    useOtpAutofill()
  const offset = useSharedValue(0)
  const locked =
    state === "filling" || state === "verifying" || state === "verified"

  React.useEffect(() => {
    if (state !== "invalid") return
    const step = { duration: SHAKE_STEP, reduceMotion: ReduceMotion.System }
    offset.value = withSequence(
      withTiming(-8, step),
      withTiming(7, step),
      withTiming(-5, step),
      withTiming(3, step),
      withTiming(0, step)
    )
  }, [state, offset])

  const shakeStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: offset.value }],
  }))

  return (
    <AnimatedView style={[containerStyle, shakeStyle]}>
      <InputOTPSlotStyleContext.Provider
        value={slotStyles(state, colors, isDark)}
      >
        <InputOTP
          {...props}
          length={maxLength}
          value={value}
          readOnly={locked}
          inputRef={inputRef}
          inputProps={{
            textContentType: "oneTimeCode",
            autoComplete: Platform.OS === "ios" ? "one-time-code" : "sms-otp",
            ...inputProps,
            ...(accessibilityLabel ? { accessibilityLabel } : null),
            onFocus: (event) => {
              setFocused(true)
              inputProps?.onFocus?.(event)
            },
            onBlur: (event) => {
              setFocused(false)
              inputProps?.onBlur?.(event)
            },
          }}
          onValueChange={(next) => {
            if (!locked) type(next)
          }}
        >
          {children}
        </InputOTP>
      </InputOTPSlotStyleContext.Provider>
    </AnimatedView>
  )
}

function OtpAutofillSuggestion({
  code,
  source = "From Messages",
  portalHost,
  style,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children" | "onPress"> & {
  code: string | null | undefined
  source?: React.ReactNode
  portalHost?: string
}) {
  const { colors, isDark } = useTheme()
  const context = useOtpAutofill()
  const { state, value, focused, fill } = context
  const keyboard = useKeyboard()
  const insets = useSafeAreaInsets()
  const id = React.useId()
  const open = Boolean(code) && focused && value === "" && state === "idle"

  return (
    <Portal name={`otp-autofill-${id}`} hostName={portalHost}>
      {open ? (
        <OtpAutofillContext.Provider value={context}>
          <AnimatedView
            entering={ENTERING}
            exiting={EXITING}
            style={[
              styles.frame,
              {
                bottom: keyboard.height,
                borderTopColor: colors.border,
                backgroundColor: isDark ? colors.muted : colors.popover,
              },
            ]}
          >
            <Pressable
              role="button"
              onPress={() => {
                if (code) fill(code)
              }}
              {...props}
              style={(pressState) => [
                styles.suggestion,
                pressState.pressed && { opacity: 0.8 },
                {
                  paddingBottom:
                    keyboard.height > 0 ? 6 : Math.max(6, insets.bottom),
                },
                typeof style === "function" ? style(pressState) : style,
              ]}
            >
              {typeof source === "string" || typeof source === "number" ? (
                <Text
                  style={[styles.source, { color: colors.mutedForeground }]}
                >
                  {source}
                </Text>
              ) : (
                source
              )}
              <Text style={styles.code}>{code}</Text>
            </Pressable>
          </AnimatedView>
        </OtpAutofillContext.Provider>
      ) : null}
    </Portal>
  )
}

export { OtpAutofill, OtpAutofillInput, OtpAutofillSuggestion, useOtpAutofill }
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="swift/Interactions/OtpAutofill.swift"
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.idle
  var focused = false
  var pops: [Int] = []
  var shakes = 0

  @ObservationIgnored var length: Int
  @ObservationIgnored var verify: @MainActor (String) async -> Bool
  @ObservationIgnored let interval: Duration
  @ObservationIgnored let hold: Duration
  @ObservationIgnored private var run = 0

  init(
    length: Int,
    interval: Duration = .milliseconds(70),
    hold: Duration = .milliseconds(600),
    verify: @escaping @MainActor (String) async -> Bool
  ) {
    self.length = length
    self.interval = interval
    self.hold = hold
    self.verify = verify
  }

  var locked: Bool {
    state == .filling || state == .verifying || state == .verified
  }

  func fill(_ code: String) {
    let digits = String(code.filter { !$0.isWhitespace }.prefix(length))
    guard !digits.isEmpty else { return }
    run += 1
    let current = run
    state = .filling
    Task {
      for index in 1...digits.count {
        value = String(digits.prefix(index))
        pop(index - 1)
        try? await Task.sleep(for: interval)
        guard current == run else { return }
      }
      if digits.count == length {
        await check(digits, run: current)
      } else {
        state = .idle
      }
    }
  }

  func type(_ next: String) {
    guard !locked else { return }
    if next.count - value.count > 1 {
      fill(next)
      return
    }
    run += 1
    let current = run
    state = .idle
    value = next
    if next.count == length {
      Task { await check(next, run: current) }
    }
  }

  private func pop(_ index: Int) {
    if pops.count < length { pops = Array(repeating: 0, count: length) }
    guard index < pops.count else { return }
    pops[index] += 1
  }

  private func check(_ code: String, run current: Int) async {
    state = .verifying
    let valid = await verify(code)
    guard current == run else { return }
    if valid {
      state = .verified
      return
    }
    state = .invalid
    shakes += 1
    try? await Task.sleep(for: hold)
    guard current == run else { return }
    value = ""
    state = .idle
    focused = true
  }
}

extension EnvironmentValues {
  @Entry var otpAutofill: OtpAutofillModel?
}

public struct OtpAutofill<Content: View>: View {
  let length: Int
  let onVerify: @MainActor (String) async -> Bool
  let content: (OtpAutofillState) -> Content

  @State private var model: OtpAutofillModel

  public init(
    length: Int,
    onVerify: @escaping @MainActor (String) async -> Bool,
    @ViewBuilder content: @escaping (OtpAutofillState) -> Content
  ) {
    self.length = length
    self.onVerify = onVerify
    self.content = content
    _model = State(initialValue: OtpAutofillModel(length: length, verify: onVerify))
  }

  init(model: OtpAutofillModel, @ViewBuilder content: @escaping (OtpAutofillState) -> Content) {
    length = model.length
    onVerify = model.verify
    self.content = content
    _model = State(initialValue: model)
  }

  public var body: some View {
    model.length = length
    model.verify = onVerify
    return VStack(spacing: 16) {
      content(model.state)
    }
    .environment(\.otpAutofill, model)
  }
}

public struct OtpAutofillInput<Content: View>: View {
  let autoFocus: Bool
  let content: Content

  @Environment(\.otpAutofill) private var model

  public init(autoFocus: Bool = false, @ViewBuilder content: () -> Content) {
    self.autoFocus = autoFocus
    self.content = content()
  }

  public var body: some View {
    if let model {
      let state = model.state
      InputOTP(
        text: Binding(get: { model.value }, set: { model.type($0) }),
        length: model.length
      ) {
        content
      }
      .environment(\.inputOTPFocus, Binding(get: { model.focused }, set: { model.focused = $0 }))
      .environment(
        \.inputOTPTone,
        InputOTPTone(
          danger: state == .invalid,
          verified: state == .verified,
          dimmed: state == .verifying,
          pops: model.pops
        )
      )
      .invalid(state == .invalid)
      .shake(trigger: model.shakes)
      .onAppear {
        if autoFocus { model.focused = true }
      }
    }
  }
}

extension OtpAutofillInput where Content == InputOTPGroup<ForEach<Range<Int>, Int, InputOTPSlot>> {
  public init(length: Int, autoFocus: Bool = false) {
    self.init(autoFocus: autoFocus) {
      InputOTPGroup {
        ForEach(0..<length, id: \.self) { InputOTPSlot(index: $0) }
      }
    }
  }
}

public struct OtpAutofillSuggestion: View {
  let code: String?
  let source: LocalizedStringKey

  @Environment(\.otpAutofill) private var model

  public init(code: String?, source: LocalizedStringKey = "From Messages") {
    self.code = code
    self.source = source
  }

  public var body: some View {
    let open = isOpen
    ZStack {
      if open, let code, let model {
        Button {
          model.fill(code)
        } label: {
          EmptyView()
        }
        .buttonStyle(PressReader { pressed in
          VStack(spacing: 0) {
            Text(source)
              .scaledFont(.xs)
              .foregroundStyle(EsuissColor.mutedForeground)
            Text(verbatim: code)
              .scaledFont(.base, weight: .semibold)
              .tracking(1.2)
              .monospacedDigit()
              .foregroundStyle(EsuissColor.foreground)
          }
          .frame(maxWidth: .infinity, minHeight: 44)
          .padding(.vertical, 6)
          .background(EsuissColor.muted.opacity(0.85))
          .background(.ultraThinMaterial)
          .overlay(alignment: .top) {
            EsuissColor.border.frame(height: 1)
          }
          .opacity(pressed ? 0.8 : 1)
          .contentShape(Rectangle())
        })
        .accessibilityLabel(Text(source))
        .accessibilityValue(Text(verbatim: code))
        .transition(.opacity.combined(with: .offset(y: 8)))
      }
    }
    .animation(.timingCurve(0.32, 0.72, 0, 1, duration: 0.3), value: open)
  }

  private var isOpen: Bool {
    guard let model, let code, !code.isEmpty else { return false }
    return model.focused && model.value.isEmpty && model.state == .idle
  }
}
```

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/InputOTP.swift`: see [Input OTP](https://ui.suiss.app/docs/components/input-otp.md)

- `swift/Components/Keypad.swift`: see [Keypad](https://ui.suiss.app/docs/components/keypad.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
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")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/interactions/OtpAutofill.kt"
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.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import com.esuiss.ui.components.FormEasing
import com.esuiss.ui.components.InputOTP
import com.esuiss.ui.components.InputOTPGroup
import com.esuiss.ui.components.InputOTPSlot
import com.esuiss.ui.components.LocalOtpAppearance
import com.esuiss.ui.components.OtpAppearance
import com.esuiss.ui.components.OtpTone
import com.esuiss.ui.components.formReducedMotion
import com.esuiss.ui.components.rememberHaptics
import com.esuiss.ui.components.shake
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

private const val FillInterval = 70L

private const val InvalidHold = 600L

enum class OtpAutofillState { Idle, Filling, Verifying, Verified, Invalid }

@Stable
internal class OtpAutofillController(private val scope: CoroutineScope) {
    var state by mutableStateOf(OtpAutofillState.Idle)
    var value by mutableStateOf("")
    var focused by mutableStateOf(false)
    var maxLength = 0
    var onVerify: suspend (String) -> Boolean = { true }
    var perform: (HapticFeedbackType) -> Unit = {}
    var reduced = false
    val focusRequester = FocusRequester()
    val offset = Animatable(0f)
    private var job: Job? = null

    val locked: Boolean
        get() = state == OtpAutofillState.Filling || state == OtpAutofillState.Verifying || state == OtpAutofillState.Verified

    fun fill(code: String) {
        val digits = code.filterNot { it.isWhitespace() }.take(maxLength)
        if (digits.isEmpty()) return
        job?.cancel()
        job = scope.launch {
            state = OtpAutofillState.Filling
            for (index in 1..digits.length) {
                value = digits.take(index)
                delay(FillInterval)
            }
            if (digits.length == maxLength) verify(digits) else state = OtpAutofillState.Idle
        }
    }

    fun type(next: String) {
        if (next.length - value.length > 1) {
            fill(next)
            return
        }
        job?.cancel()
        state = OtpAutofillState.Idle
        value = next
        if (next.length == maxLength) job = scope.launch { verify(next) }
    }

    private suspend fun verify(code: String) {
        state = OtpAutofillState.Verifying
        if (onVerify(code)) {
            state = OtpAutofillState.Verified
            perform(HapticFeedbackType.Confirm)
            return
        }
        state = OtpAutofillState.Invalid
        perform(HapticFeedbackType.Reject)
        if (!reduced) scope.launch { offset.shake() }
        delay(InvalidHold)
        value = ""
        state = OtpAutofillState.Idle
        runCatching { focusRequester.requestFocus() }
    }
}

@Stable
class OtpAutofillScope internal constructor(
    column: ColumnScope,
    internal val controller: OtpAutofillController,
) : ColumnScope by column {
    val state: OtpAutofillState get() = controller.state

    val value: String get() = controller.value

    val maxLength: Int get() = controller.maxLength

    fun fill(code: String) = controller.fill(code)
}

@Composable
fun OtpAutofill(
    maxLength: Int,
    onVerify: suspend (String) -> Boolean,
    modifier: Modifier = Modifier,
    content: @Composable OtpAutofillScope.() -> Unit,
) {
    val coroutineScope = rememberCoroutineScope()
    val controller = remember { OtpAutofillController(coroutineScope) }
    val haptics = rememberHaptics()
    val reduced = formReducedMotion()
    SideEffect {
        controller.onVerify = onVerify
        controller.perform = haptics::perform
        controller.reduced = reduced
    }
    controller.maxLength = maxLength
    Column(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        val scope = remember(this, controller) { OtpAutofillScope(this, controller) }
        scope.content()
    }
}

@Composable
fun OtpAutofillScope.OtpAutofillInput(
    modifier: Modifier = Modifier,
    contentDescription: String? = null,
    autoFocus: Boolean = false,
    enabled: Boolean = true,
    content: (@Composable RowScope.() -> Unit)? = null,
) {
    val length = controller.maxLength
    val interaction = remember { MutableInteractionSource() }
    val focused by interaction.collectIsFocusedAsState()
    LaunchedEffect(focused) { controller.focused = focused }
    if (autoFocus) {
        LaunchedEffect(Unit) { runCatching { controller.focusRequester.requestFocus() } }
    }
    val tone = when (controller.state) {
        OtpAutofillState.Verifying -> OtpTone.Verifying
        OtpAutofillState.Verified -> OtpTone.Verified
        OtpAutofillState.Invalid -> OtpTone.Invalid
        else -> OtpTone.Default
    }
    CompositionLocalProvider(
        LocalOtpAppearance provides OtpAppearance(tone, pop = controller.state == OtpAutofillState.Filling),
    ) {
        InputOTP(
            value = controller.value,
            onValueChange = { if (!controller.locked) controller.type(it) },
            maxLength = controller.maxLength,
            modifier = modifier
                .graphicsLayer { translationX = controller.offset.value.dp.toPx() }
                .focusRequester(controller.focusRequester)
                .then(
                    if (contentDescription != null) {
                        Modifier.semantics { this.contentDescription = contentDescription }
                    } else {
                        Modifier
                    },
                ),
            enabled = enabled,
            invalid = controller.state == OtpAutofillState.Invalid,
            readOnly = controller.locked,
            interactionSource = interaction,
            content = content ?: {
                InputOTPGroup {
                    repeat(length) { InputOTPSlot(it) }
                }
            },
        )
    }
}

private class OtpAutofillSuggestionPosition(private val bottomLimit: Int) : PopupPositionProvider {
    override fun calculatePosition(
        anchorBounds: IntRect,
        windowSize: IntSize,
        layoutDirection: LayoutDirection,
        popupContentSize: IntSize,
    ): IntOffset = IntOffset(0, (if (bottomLimit == Int.MAX_VALUE) windowSize.height else bottomLimit) - popupContentSize.height)
}

@Composable
fun OtpAutofillScope.OtpAutofillSuggestion(
    code: String?,
    modifier: Modifier = Modifier,
    source: String = "From Messages",
) {
    val open = !code.isNullOrBlank() && controller.focused && controller.value.isEmpty() &&
        controller.state == OtpAutofillState.Idle
    val last = remember { arrayOf("") }
    if (!code.isNullOrBlank()) last[0] = code
    val shown = last[0]
    val transition = remember { MutableTransitionState(false) }
    transition.targetState = open
    if (!transition.currentState && !transition.targetState) return
    val colors = EsuissTheme.colors
    val density = LocalDensity.current
    val reduced = formReducedMotion()
    val container = LocalWindowInfo.current.containerSize
    val keyboard = WindowInsets.ime.getBottom(density)
    val navigation = WindowInsets.navigationBars.getBottom(density)
    val bottomLimit = if (container.height > 0) container.height - maxOf(keyboard, navigation) else Int.MAX_VALUE
    val provider = remember(bottomLimit) { OtpAutofillSuggestionPosition(bottomLimit) }
    val slide = with(density) { 8.dp.roundToPx() }
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Popup(
        popupPositionProvider = provider,
        properties = PopupProperties(focusable = false, dismissOnBackPress = false, dismissOnClickOutside = false),
    ) {
        AnimatedVisibility(
            visibleState = transition,
            enter = if (reduced) {
                fadeIn(tween(300))
            } else {
                fadeIn(tween(300, easing = FormEasing)) + slideInVertically(tween(300, easing = FormEasing)) { slide }
            },
            exit = if (reduced) {
                fadeOut(tween(300))
            } else {
                fadeOut(tween(300, easing = FormEasing)) + slideOutVertically(tween(300, easing = FormEasing)) { slide }
            },
        ) {
            Column(
                modifier = modifier
                    .width(with(density) { container.width.toDp() })
                    .heightIn(min = 44.dp)
                    .background(colors.muted)
                    .alpha(if (pressed) 0.8f else 1f)
                    .drawBehind { drawRect(colors.border, Offset.Zero, Size(size.width, 1.dp.toPx())) }
                    .clickable(
                        interactionSource = interaction,
                        indication = null,
                        enabled = open,
                        role = Role.Button,
                    ) { if (shown.isNotBlank()) fill(shown) }
                    .padding(vertical = 6.dp),
                verticalArrangement = Arrangement.Center,
                horizontalAlignment = Alignment.CenterHorizontally,
            ) {
                BasicText(source, style = Typography.xs.copy(color = colors.mutedForeground))
                BasicText(
                    shown,
                    style = Typography.base.copy(
                        color = colors.foreground,
                        fontWeight = FontWeight.SemiBold,
                        letterSpacing = 0.05.em,
                        fontFeatureSettings = "tnum",
                    ),
                )
            }
        }
    }
}
```

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/InputOTP.kt`: see [Input OTP](https://ui.suiss.app/docs/components/input-otp.md)

- `kotlin/components/Keypad.kt`: see [Keypad](https://ui.suiss.app/docs/components/keypad.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { OtpAutofill, OtpAutofillInput, OtpAutofillSuggestion } from "@/components/interactions/otp-autofill"
```

```tsx
<VerificationExample incoming={CODE} />
```

Pass `maxLength` and `onVerify`, which returns a boolean or a promise of one. Put `InputOTPGroup` and `InputOTPSlot`s 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.

![Preview](https://ui.suiss.app/previews/interactions-otp-autofill--wrong-code.light.webp)

```tsx
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 statusClassName(state: string) {
  if (state === "invalid") return "min-h-5 text-sm text-destructive"
  if (state === "verified")
    return "min-h-5 text-sm text-green-600 dark:text-green-400"
  return "min-h-5 text-sm text-muted-foreground"
}

function statusText(state: string) {
  if (state === "verifying") return "Verifying…"
  if (state === "verified") return "Verified"
  if (state === "invalid") return "That code didn't work. Try again."
  return ""
}

function Status() {
  const { state } = useOtpAutofill()

  return (
    <Text
      role="status"
      accessibilityLiveRegion="polite"
      className={statusClassName(state)}
    >
      {statusText(state)}
    </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-muted-foreground">
        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" />
  )
}
```

```swift
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: Color.adaptive(light: Palette.green600, dark: Palette.green400)
    case .invalid: EsuissColor.destructive
    default: EsuissColor.mutedForeground
    }
  }
}

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(EsuissColor.foreground)
        .multilineTextAlignment(.center)
      Text("We sent a 6-digit code to +1 555 0100.")
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .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
  }
}
```

```kotlin
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.mutedForeground),
            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.

![Preview](https://ui.suiss.app/previews/interactions-otp-autofill--four-digits.light.webp)

```tsx
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 statusClassName(state: string) {
  if (state === "invalid") return "min-h-5 text-sm text-destructive"
  if (state === "verified")
    return "min-h-5 text-sm text-green-600 dark:text-green-400"
  return "min-h-5 text-sm text-muted-foreground"
}

function statusText(state: string) {
  if (state === "verifying") return "Verifying…"
  if (state === "verified") return "Verified"
  if (state === "invalid") return "That code didn't work. Try again."
  return ""
}

function Status() {
  const { state } = useOtpAutofill()

  return (
    <Text
      role="status"
      accessibilityLiveRegion="polite"
      className={statusClassName(state)}
    >
      {statusText(state)}
    </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>
  )
}
```

```swift
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: Color.adaptive(light: Palette.green600, dark: Palette.green400)
    case .invalid: EsuissColor.destructive
    default: EsuissColor.mutedForeground
    }
  }
}
```

```kotlin
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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `maxLength` | `number` | Required | Number of digits in the code. |
| `onVerify` | `(code: string) => Promise<boolean> \| boolean` | Required | Checks a complete code; return `true` when it is valid. |

Also accepts the props of `React.ComponentProps<typeof View>`.

### OtpAutofillInput

The `InputOTP` that shows each state and shakes on an invalid code.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `containerClassName` | `string` | - | Classes for the animated container around the input. |
| `children` | `React.ReactNode` | Required |  |

Also accepts the props of `React.ComponentProps<typeof InputOTP>`.

### OtpAutofillSuggestion

A bar above the keyboard that fills the suggested code on press.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `code` | `string \| null \| undefined` | Required | The code to suggest, or `null` to hide the bar. |
| `source` | `React.ReactNode` | `"From Messages"` | Label above the code. Defaults to `From Messages`. |
| `portalHost` | `string` | - | Name of a portal host to render into. |
| `className` | `string` | - |  |

Also accepts the props of `React.ComponentProps<typeof Pressable>`.

## Accessibility

- `accessibilityLabel` on `OtpAutofillInput` is passed to the underlying text input.
- The suggestion bar has the `button` role.
- The input is read-only while filling, verifying and after a valid code.

## Notes

- Invalid slots use `border-destructive` with a `bg-destructive` tint that is stronger in dark mode; verified slots use `border-green-500` and `bg-green-500/10 dark:bg-green-500/20`.
- The suggestion bar uses `bg-popover`, or `bg-muted` in dark mode, and sits on the keyboard or above the bottom safe area.
- Slide and shake animations respect the system reduce motion setting.

