# Verify Code

A two-factor authentication screen with a six-digit code entry, error feedback, and a resend countdown.

Verify Code is an open-source block 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/blocks/verify-code

**React**

**Example**

```tsx title="components/blocks/verify-code.tsx"
"use client"

import { CheckIcon, LockSimpleIcon } from "@phosphor-icons/react"
import * as React from "react"

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"

const DEMO_CODE = "482913"

const SHAKE = [0, -10, 9, -6, 4, 0].map((x) => ({
  transform: `translateX(${x}px)`,
}))

const slotClass =
  "h-14 w-11 rounded-xl sm:h-15.5 sm:w-12.5 border border-input bg-transparent text-3xl transition-[border-color,box-shadow,scale] duration-250 ease-[cubic-bezier(0.3,1.25,0.5,1)] data-[active=true]:border-primary data-[active=true]:ring-3 data-[active=true]:ring-primary/25 group-data-[status=error]/code:border-destructive group-data-[status=ok]/code:border-green-500"

export function VerifyCode() {
  const idPrefix = React.useId()
  const [code, setCode] = React.useState("")
  const [status, setStatus] = React.useState<"idle" | "error" | "ok">("idle")
  const [left, setLeft] = React.useState(30)
  const groupRef = React.useRef<HTMLDivElement>(null)

  React.useEffect(() => {
    if (left <= 0) return
    const timer = window.setTimeout(() => setLeft(left - 1), 1000)
    return () => window.clearTimeout(timer)
  }, [left])

  const complete = (value: string) => {
    window.setTimeout(() => {
      if (value === DEMO_CODE) return setStatus("ok")
      setStatus("error")
      if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches)
        groupRef.current?.animate(SHAKE, { duration: 420, easing: "ease-out" })
      window.setTimeout(() => {
        setCode("")
        setStatus("idle")
      }, 800)
    }, 200)
  }

  if (status === "ok") {
    return (
      <section className="flex min-h-140 items-center justify-center p-6">
        <div className="flex max-w-105 flex-col items-center gap-3.5 text-center transition-[opacity,translate] duration-1000 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-4.5 starting:opacity-0 motion-reduce:transition-none">
          <span className="flex size-19 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_25%)] text-white dark:bg-[color-mix(in_oklab,var(--color-green-500),black_35%)]">
            <CheckIcon className="size-9.5" />
          </span>
          <h1 className="text-4xl font-semibold tracking-tight">Verified.</h1>
          <p className="text-lg text-muted-foreground">
            This browser is now trusted. You won't be asked for a code next
            time.
          </p>
          <button
            type="button"
            onClick={() => {
              setCode("")
              setStatus("idle")
            }}
            className="rounded-xs text-lg text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
          >
            Start over ›
          </button>
        </div>
      </section>
    )
  }

  return (
    <section className="flex min-h-140 items-center justify-center p-6">
      <div className="flex flex-col items-center text-center">
        <span className="flex size-16 items-center justify-center rounded-full bg-muted text-primary">
          <LockSimpleIcon className="size-7.5" />
        </span>
        <h1 className="mt-5 text-4xl font-semibold tracking-tight text-balance md:text-5xl">
          Two-factor authentication
        </h1>
        <p className="mt-3 mb-7.5 max-w-110 text-lg text-muted-foreground">
          Enter the 6-digit code sent to your trusted devices.
        </p>
        <div ref={groupRef}>
          <InputOTP
            autoFocus
            maxLength={6}
            value={code}
            onChange={(value) => {
              setCode(value)
              setStatus("idle")
            }}
            onComplete={complete}
            aria-label="Verification code"
            aria-describedby={`${idPrefix}-hint`}
          >
            <div data-status={status} className="group/code flex gap-4">
              <InputOTPGroup className="gap-2.5">
                {[0, 1, 2].map((index) => (
                  <InputOTPSlot
                    key={index}
                    index={index}
                    className={slotClass}
                  />
                ))}
              </InputOTPGroup>
              <InputOTPGroup className="gap-2.5">
                {[3, 4, 5].map((index) => (
                  <InputOTPSlot
                    key={index}
                    index={index}
                    className={slotClass}
                  />
                ))}
              </InputOTPGroup>
            </div>
          </InputOTP>
        </div>
        <p
          id={`${idPrefix}-hint`}
          aria-live="polite"
          data-status={status}
          className="mt-3.5 min-h-5 text-sm text-muted-foreground data-[status=error]:text-destructive"
        >
          {status === "error" ? "Incorrect code." : `Demo code: ${DEMO_CODE}`}
        </p>
        <div className="mt-6 flex flex-col items-center gap-2.5 text-sm">
          {left > 0 ? (
            <span className="text-muted-foreground tabular-nums">
              Resend code · 0:{String(left).padStart(2, "0")}
            </span>
          ) : (
            <button
              type="button"
              onClick={() => setLeft(30)}
              className="rounded-xs text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
            >
              Resend code ›
            </button>
          )}
          <a href="#help" className="text-primary hover:underline">
            Didn't get a verification code? ›
          </a>
        </div>
      </div>
    </section>
  )
}
```

A two-factor screen with a six-digit code field that turns red on a wrong code and green on success, plus a resend countdown and a help link. A wrong code shakes the field and clears it. Use it for 2FA, phone verification or any one-time code step.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/verify-code
```

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 blocks verify-code
```

### Manual

1. Install the dependencies:

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

3. Copy the source into `components/blocks/verify-code.tsx`:

```tsx title="components/blocks/verify-code.tsx"
"use client"

import { CheckIcon, LockSimpleIcon } from "@phosphor-icons/react"
import * as React from "react"

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"

const DEMO_CODE = "482913"

const SHAKE = [0, -10, 9, -6, 4, 0].map((x) => ({
  transform: `translateX(${x}px)`,
}))

const slotClass =
  "h-14 w-11 rounded-xl sm:h-15.5 sm:w-12.5 border border-input bg-transparent text-3xl transition-[border-color,box-shadow,scale] duration-250 ease-[cubic-bezier(0.3,1.25,0.5,1)] data-[active=true]:border-primary data-[active=true]:ring-3 data-[active=true]:ring-primary/25 group-data-[status=error]/code:border-destructive group-data-[status=ok]/code:border-green-500"

export function VerifyCode() {
  const idPrefix = React.useId()
  const [code, setCode] = React.useState("")
  const [status, setStatus] = React.useState<"idle" | "error" | "ok">("idle")
  const [left, setLeft] = React.useState(30)
  const groupRef = React.useRef<HTMLDivElement>(null)

  React.useEffect(() => {
    if (left <= 0) return
    const timer = window.setTimeout(() => setLeft(left - 1), 1000)
    return () => window.clearTimeout(timer)
  }, [left])

  const complete = (value: string) => {
    window.setTimeout(() => {
      if (value === DEMO_CODE) return setStatus("ok")
      setStatus("error")
      if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches)
        groupRef.current?.animate(SHAKE, { duration: 420, easing: "ease-out" })
      window.setTimeout(() => {
        setCode("")
        setStatus("idle")
      }, 800)
    }, 200)
  }

  if (status === "ok") {
    return (
      <section className="flex min-h-140 items-center justify-center p-6">
        <div className="flex max-w-105 flex-col items-center gap-3.5 text-center transition-[opacity,translate] duration-1000 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-4.5 starting:opacity-0 motion-reduce:transition-none">
          <span className="flex size-19 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_25%)] text-white dark:bg-[color-mix(in_oklab,var(--color-green-500),black_35%)]">
            <CheckIcon className="size-9.5" />
          </span>
          <h1 className="text-4xl font-semibold tracking-tight">Verified.</h1>
          <p className="text-lg text-muted-foreground">
            This browser is now trusted. You won't be asked for a code next
            time.
          </p>
          <button
            type="button"
            onClick={() => {
              setCode("")
              setStatus("idle")
            }}
            className="rounded-xs text-lg text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
          >
            Start over ›
          </button>
        </div>
      </section>
    )
  }

  return (
    <section className="flex min-h-140 items-center justify-center p-6">
      <div className="flex flex-col items-center text-center">
        <span className="flex size-16 items-center justify-center rounded-full bg-muted text-primary">
          <LockSimpleIcon className="size-7.5" />
        </span>
        <h1 className="mt-5 text-4xl font-semibold tracking-tight text-balance md:text-5xl">
          Two-factor authentication
        </h1>
        <p className="mt-3 mb-7.5 max-w-110 text-lg text-muted-foreground">
          Enter the 6-digit code sent to your trusted devices.
        </p>
        <div ref={groupRef}>
          <InputOTP
            autoFocus
            maxLength={6}
            value={code}
            onChange={(value) => {
              setCode(value)
              setStatus("idle")
            }}
            onComplete={complete}
            aria-label="Verification code"
            aria-describedby={`${idPrefix}-hint`}
          >
            <div data-status={status} className="group/code flex gap-4">
              <InputOTPGroup className="gap-2.5">
                {[0, 1, 2].map((index) => (
                  <InputOTPSlot
                    key={index}
                    index={index}
                    className={slotClass}
                  />
                ))}
              </InputOTPGroup>
              <InputOTPGroup className="gap-2.5">
                {[3, 4, 5].map((index) => (
                  <InputOTPSlot
                    key={index}
                    index={index}
                    className={slotClass}
                  />
                ))}
              </InputOTPGroup>
            </div>
          </InputOTP>
        </div>
        <p
          id={`${idPrefix}-hint`}
          aria-live="polite"
          data-status={status}
          className="mt-3.5 min-h-5 text-sm text-muted-foreground data-[status=error]:text-destructive"
        >
          {status === "error" ? "Incorrect code." : `Demo code: ${DEMO_CODE}`}
        </p>
        <div className="mt-6 flex flex-col items-center gap-2.5 text-sm">
          {left > 0 ? (
            <span className="text-muted-foreground tabular-nums">
              Resend code · 0:{String(left).padStart(2, "0")}
            </span>
          ) : (
            <button
              type="button"
              onClick={() => setLeft(30)}
              className="rounded-xs text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
            >
              Resend code ›
            </button>
          )}
          <a href="#help" className="text-primary hover:underline">
            Didn't get a verification code? ›
          </a>
        </div>
      </div>
    </section>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { VerifyCode } from "@/components/blocks/verify-code"
```

```tsx
<VerifyCode />
```

Replace the comparison with `DEMO_CODE` in `complete` with your verification API, and remove the demo code hint. Resend only restarts the 30-second timer, so connect it to your code delivery service. Point `#help` at your support page.

## Notes

The code field receives focus on load, and errors are announced.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/blocks-verify-code--default.light.webp)

```tsx title="uniwind/components/blocks/verify-code.tsx"
import { cn } from "cn"
import { CheckIcon, LockSimpleIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import {
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withSequence,
  withTiming,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"
import { KeyboardScrollView } from "@/components/ui/keyboard-scroll-view"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

const DEMO_CODE = "482913"

const SHAKE = [-10, 9, -6, 4, 0]

const STEP = { duration: 70, reduceMotion: ReduceMotion.System }

type Status = "idle" | "error" | "ok"

function LinkText({
  onPress,
  children,
}: {
  onPress?: () => void
  children: React.ReactNode
}) {
  return (
    <Pressable
      role="link"
      hitSlop={8}
      onPress={onPress}
      className="active:opacity-60"
    >
      <Text className="text-sm text-primary">{children}</Text>
    </Pressable>
  )
}

function VerifyCode({
  onHelp,
  onVerify = (code) => code === DEMO_CODE,
}: {
  onHelp?: () => void
  onVerify?: (code: string) => boolean | Promise<boolean>
}) {
  const verifyRef = React.useRef(onVerify)
  verifyRef.current = onVerify
  const [code, setCode] = React.useState("")
  const [status, setStatus] = React.useState<Status>("idle")
  const [checking, setChecking] = React.useState<string | null>(null)
  const [left, setLeft] = React.useState(30)
  const offset = useSharedValue(0)

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

  React.useEffect(() => {
    if (left <= 0) return
    const timer = setTimeout(() => setLeft(left - 1), 1000)
    return () => clearTimeout(timer)
  }, [left])

  React.useEffect(() => {
    if (checking === null) return
    let active = true
    const timer = setTimeout(async () => {
      const valid = await verifyRef.current(checking)
      if (!active) return
      setChecking(null)
      if (valid) return setStatus("ok")
      setStatus("error")
      offset.value = withSequence(...SHAKE.map((x) => withTiming(x, STEP)))
    }, 200)
    return () => {
      active = false
      clearTimeout(timer)
    }
  }, [checking, offset])

  React.useEffect(() => {
    if (status !== "error") return
    const timer = setTimeout(() => {
      setCode("")
      setStatus("idle")
    }, 800)
    return () => clearTimeout(timer)
  }, [status])

  if (status === "ok") {
    return (
      <Screen>
        <View className="flex-1 items-center justify-center p-6">
          <AnimatedView
            entering={FadeInDown.duration(600).reduceMotion(
              ReduceMotion.System
            )}
            className="items-center gap-3.5"
          >
            <View className="size-19 items-center justify-center rounded-full bg-green-500">
              <Icon
                as={CheckIcon}
                weight="bold"
                className="size-9.5 text-primary-foreground"
              />
            </View>
            <Text role="heading" className="text-3xl font-semibold">
              Verified.
            </Text>
            <Text className="text-center text-lg text-muted-foreground">
              This device is now trusted. You won't be asked for a code next
              time.
            </Text>
            <Pressable
              role="link"
              hitSlop={8}
              onPress={() => {
                setCode("")
                setStatus("idle")
              }}
              className="active:opacity-60"
            >
              <Text className="text-lg text-primary">Start over ›</Text>
            </Pressable>
          </AnimatedView>
        </View>
      </Screen>
    )
  }

  return (
    <Screen>
      <KeyboardScrollView contentContainerClassName="grow items-center justify-center px-6 py-8">
        <View className="size-16 items-center justify-center rounded-full bg-muted">
          <Icon
            as={LockSimpleIcon}
            weight="bold"
            className="size-7.5 text-primary"
          />
        </View>
        <Text
          role="heading"
          className="mt-5 text-center text-3xl font-semibold"
        >
          Two-factor authentication
        </Text>
        <Text className="mt-3 mb-7.5 text-center text-lg text-muted-foreground">
          Enter the 6-digit code sent to your trusted devices.
        </Text>
        <AnimatedView style={shakeStyle}>
          <InputOTP
            autoFocus
            length={6}
            value={code}
            onValueChange={(value) => {
              setCode(value)
              setStatus("idle")
            }}
            onValueComplete={setChecking}
            accessibilityLabel="Verification code"
            className="gap-4"
          >
            {[
              [0, 1, 2],
              [3, 4, 5],
            ].map((group) => (
              <InputOTPGroup key={group[0]} className="gap-2.5">
                {group.map((index) => (
                  <InputOTPSlot
                    key={index}
                    index={index}
                    className={(state) =>
                      cn(
                        "h-14 w-11 rounded-xl border bg-transparent",
                        status === "error" && "border-destructive",
                        status !== "error" && state.active && "border-primary",
                        status !== "error" && !state.active && "border-input"
                      )
                    }
                  />
                ))}
              </InputOTPGroup>
            ))}
          </InputOTP>
        </AnimatedView>
        <Text
          accessibilityLiveRegion="polite"
          className={cn(
            "mt-3.5 min-h-5 text-sm",
            status === "error" ? "text-destructive" : "text-muted-foreground"
          )}
        >
          {status === "error" ? "Incorrect code." : `Demo code: ${DEMO_CODE}`}
        </Text>
        <View className="mt-6 items-center gap-2.5">
          {left > 0 ? (
            <Text className="text-sm text-muted-foreground tabular-nums">
              Resend code · 0:{String(left).padStart(2, "0")}
            </Text>
          ) : (
            <LinkText onPress={() => setLeft(30)}>Resend code ›</LinkText>
          )}
          <LinkText onPress={onHelp}>
            Didn't get a verification code? ›
          </LinkText>
        </View>
      </KeyboardScrollView>
    </Screen>
  )
}

export { VerifyCode }
```

```swift title="swift/Blocks/VerifyCode.swift"
import PhosphorSwift
import SwiftUI

public struct VerifyCode: View {
  enum Status: Sendable {
    case idle, error, ok
  }

  static let demoCode = "482913"

  private var onHelp: (() -> Void)?
  private var onVerify: (String) async -> Bool = { $0 == "482913" }
  @State private var code: String
  @State private var status: Status
  @State private var left: Int
  @State private var shakes = 0
  @State private var focused = true
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onVerify: @escaping (String) async -> Bool = { $0 == "482913" },
    onHelp: (() -> Void)? = nil
  ) {
    self.init(code: "", status: .idle, left: 30)
    self.onVerify = onVerify
    self.onHelp = onHelp
  }

  init(code: String, status: Status, left: Int) {
    _code = State(initialValue: code)
    _status = State(initialValue: status)
    _left = State(initialValue: left)
  }

  public var body: some View {
    Screen {
      if status == .ok {
        verified
          .transition(.opacity.combined(with: .offset(y: 18)))
      } else {
        form
      }
    }
    .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.6), value: status == .ok)
    .task(id: left) {
      guard left > 0 else { return }
      try? await Task.sleep(for: .seconds(1))
      guard !Task.isCancelled else { return }
      left -= 1
    }
  }

  private var form: some View {
    KeyboardScrollView {
      VStack(spacing: 0) {
        Ph.lockSimple.bold
          .frame(width: 30, height: 30)
          .foregroundStyle(EsuissColor.primary)
          .frame(width: 64, height: 64)
          .background(EsuissColor.muted, in: Circle())
          .accessibilityHidden(true)
        Text("Two-factor authentication")
          .scaledTextStyle(.xl2, weight: .bold)
          .padding(.top, 20)
        Text("Enter the 6-digit code sent to your trusted devices.")
          .scaledTextStyle(.lg)
          .foregroundStyle(EsuissColor.mutedForeground)
          .padding(.top, 12)
          .padding(.bottom, 30)
        InputOTP(text: $code, length: 6) {
          InputOTPGroup {
            ForEach(0..<3, id: \.self) { InputOTPSlot(index: $0) }
          }
          .padding(.trailing, 8)
          InputOTPGroup {
            ForEach(3..<6, id: \.self) { InputOTPSlot(index: $0) }
          }
        }
        .invalid(status == .error)
        .environment(\.inputOTPFocus, $focused)
        .shake(trigger: shakes)
        .hapticFeedback(.error, trigger: shakes)
        .onChange(of: code) { _, value in
          if status == .error && !value.isEmpty { status = .idle }
          if value.count == 6 { complete(value) }
        }
        .accessibilityLabel(Text("Verification code"))
        Text(status == .error ? "Incorrect code." : "Demo code: \(Self.demoCode)")
          .scaledFont(.sm)
          .foregroundStyle(status == .error ? EsuissColor.destructive : EsuissColor.mutedForeground)
          .frame(minHeight: 20)
          .padding(.top, 14)
        VStack(spacing: 10) {
          if left > 0 {
            Text("Resend code · 0:\(String(format: "%02d", left))")
              .scaledFont(.sm)
              .monospacedDigit()
              .foregroundStyle(EsuissColor.mutedForeground)
              .frame(minHeight: 32)
          } else {
            Button("Resend code ›") { left = 30 }
              .buttonStyle(VerifyCodeLinkStyle())
          }
          Button("Didn't get a verification code? ›") { onHelp?() }
            .buttonStyle(VerifyCodeLinkStyle())
        }
        .padding(.top, 24)
      }
      .multilineTextAlignment(.center)
      .fixedSize(horizontal: false, vertical: true)
      .frame(maxWidth: .infinity)
      .padding(.horizontal, 24)
      .padding(.top, 72)
      .padding(.bottom, 24)
    }
  }

  private var verified: some View {
    VStack(spacing: 14) {
      Ph.check.bold
        .frame(width: 38, height: 38)
        .foregroundStyle(EsuissColor.primaryForeground)
        .frame(width: 76, height: 76)
        .background(Palette.green500, in: Circle())
        .accessibilityHidden(true)
      Text("Verified.")
        .scaledTextStyle(.xl2, weight: .bold)
      Text("This device is now trusted. You won't be asked for a code next time.")
        .scaledTextStyle(.lg)
        .foregroundStyle(EsuissColor.mutedForeground)
      Button("Start over ›") {
        code = ""
        status = .idle
      }
      .buttonStyle(VerifyCodeLinkStyle(style: .lg))
    }
    .multilineTextAlignment(.center)
    .fixedSize(horizontal: false, vertical: true)
    .padding(24)
    .frame(maxWidth: .infinity, maxHeight: .infinity)
  }

  private func complete(_ value: String) {
    Task {
      try? await Task.sleep(for: .milliseconds(200))
      if await onVerify(value) {
        focused = false
        status = .ok
        return
      }
      status = .error
      shakes += 1
      try? await Task.sleep(for: .milliseconds(800))
      code = ""
      status = .idle
    }
  }
}

private struct VerifyCodeLinkStyle: ButtonStyle {
  var style: TextStyle = .sm

  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .scaledFont(style)
      .foregroundStyle(EsuissColor.primary)
      .opacity(configuration.isPressed ? 0.8 : 1)
      .frame(minHeight: 32)
      .contentShape(Rectangle())
  }
}
```

```kt title="kotlin/blocks/VerifyCode.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
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.graphics.graphicsLayer
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.bold.Check
import com.adamglin.phosphoricons.bold.LockSimple
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.InputOTPGroup
import com.esuiss.ui.components.InputOTPSlot
import com.esuiss.ui.components.KeyboardScrollView
import com.esuiss.ui.components.Screen
import com.esuiss.ui.interactions.OtpAutofill
import com.esuiss.ui.interactions.OtpAutofillInput
import com.esuiss.ui.interactions.OtpAutofillState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.delay

private const val DemoCode = "482913"

private val RevealEasing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f)

@Composable
fun VerifyCode(
    modifier: Modifier = Modifier,
    onVerify: suspend (code: String) -> Boolean = { it == DemoCode },
    onHelp: () -> Unit = {},
) {
    val colors = EsuissTheme.colors
    val verify by rememberUpdatedState(onVerify)
    var verified by rememberSaveable { mutableStateOf(false) }
    var attempt by remember { mutableIntStateOf(0) }
    var left by rememberSaveable { mutableIntStateOf(30) }

    LaunchedEffect(left) {
        if (left <= 0) return@LaunchedEffect
        delay(1000)
        left -= 1
    }

    Screen(modifier) {
        if (verified) {
            Success {
                verified = false
                attempt += 1
            }
            return@Screen
        }
        BoxWithConstraints(Modifier.fillMaxSize()) {
            val height = maxHeight
            KeyboardScrollView(Modifier.fillMaxSize()) {
                Column(
                    modifier = Modifier
                        .fillMaxWidth()
                        .heightIn(min = height)
                        .padding(24.dp),
                    verticalArrangement = Arrangement.Center,
                    horizontalAlignment = Alignment.CenterHorizontally,
                ) {
                    Box(
                        Modifier
                            .size(64.dp)
                            .background(colors.muted, CircleShape),
                        contentAlignment = Alignment.Center,
                    ) {
                        Icon(PhosphorIcons.Bold.LockSimple, contentDescription = null, size = 30.dp, tint = colors.primary)
                    }
                    Spacer(Modifier.height(20.dp))
                    BasicText(
                        "Two-factor authentication",
                        modifier = Modifier.semantics { heading() },
                        style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center),
                    )
                    Spacer(Modifier.height(12.dp))
                    BasicText(
                        "Enter the 6-digit code sent to your trusted devices.",
                        style = Typography.lg.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                    )
                    Spacer(Modifier.height(30.dp))
                    key(attempt) {
                        OtpAutofill(
                            maxLength = 6,
                            onVerify = { code ->
                                val ok = verify(code)
                                if (ok) {
                                    delay(500)
                                    verified = true
                                }
                                ok
                            },
                        ) {
                            OtpAutofillInput(contentDescription = "Verification code") {
                                InputOTPGroup(Modifier.padding(end = 8.dp)) {
                                    repeat(3) { InputOTPSlot(it) }
                                }
                                InputOTPGroup {
                                    repeat(3) { InputOTPSlot(it + 3) }
                                }
                            }
                            val invalid = state == OtpAutofillState.Invalid
                            BasicText(
                                if (invalid) "Incorrect code." else "Demo code: $DemoCode",
                                modifier = Modifier
                                    .heightIn(min = 20.dp)
                                    .semantics { liveRegion = LiveRegionMode.Polite },
                                style = Typography.sm.copy(
                                    color = if (invalid) colors.destructive else colors.mutedForeground,
                                    textAlign = TextAlign.Center,
                                ),
                            )
                        }
                    }
                    Spacer(Modifier.height(24.dp))
                    Column(
                        horizontalAlignment = Alignment.CenterHorizontally,
                        verticalArrangement = Arrangement.spacedBy(10.dp),
                    ) {
                        if (left > 0) {
                            BasicText(
                                "Resend code · 0:${left.toString().padStart(2, '0')}",
                                style = Typography.sm.copy(color = colors.mutedForeground, fontFeatureSettings = "tnum"),
                            )
                        } else {
                            LinkText("Resend code ›") {
                                left = 30
                            }
                        }
                        LinkText("Didn't get a verification code? ›", onHelp)
                    }
                }
            }
        }
    }
}

@Composable
private fun LinkText(text: String, onClick: () -> Unit) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    BasicText(
        text,
        modifier = Modifier
            .graphicsLayer { alpha = if (pressed) 0.8f else 1f }
            .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onClick)
            .padding(vertical = 4.dp),
        style = Typography.sm.copy(color = EsuissTheme.colors.primary, textAlign = TextAlign.Center),
    )
}

@Composable
private fun Success(onStartOver: () -> Unit) {
    val colors = EsuissTheme.colors
    val appear = remember { Animatable(0f) }
    LaunchedEffect(Unit) { appear.animateTo(1f, tween(1000, easing = RevealEasing)) }
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(24.dp)
            .graphicsLayer {
                alpha = appear.value
                translationY = (1f - appear.value) * 18.dp.toPx()
            },
        verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Box(
            Modifier
                .size(76.dp)
                .background(Palette.green500, CircleShape),
            contentAlignment = Alignment.Center,
        ) {
            Icon(PhosphorIcons.Bold.Check, contentDescription = null, size = 38.dp, tint = colors.primaryForeground)
        }
        BasicText(
            "Verified.",
            modifier = Modifier.semantics { heading() },
            style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
        )
        BasicText(
            "This device is now trusted. You won't be asked for a code next time.",
            style = Typography.lg.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
        )
        BasicText(
            "Start over ›",
            modifier = Modifier.clickable(
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
                role = Role.Button,
                onClick = onStartOver,
            ),
            style = Typography.lg.copy(color = colors.primary),
        )
    }
}
```

An `InputOTP` with six slots checks the code when the last digit is entered; a wrong code shakes the input and shows an error, and a correct one shows a verified screen. A resend link counts down from 30 seconds, and a help link sits below.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/verify-code
```

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 verify-code
```

**SwiftUI**

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

**Jetpack Compose**

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

The CLI also adds what it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Input OTP](https://ui.suiss.app/docs/components/input-otp), [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view), [Screen](https://ui.suiss.app/docs/components/screen), [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 phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Input OTP](https://ui.suiss.app/docs/components/input-otp), [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), 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/blocks/verify-code.tsx`:

- `uniwind/components/blocks/verify-code.tsx`: shown above

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Input OTP](https://ui.suiss.app/docs/components/input-otp), [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), 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/blocks/verify-code.tsx`:

```tsx title="nativewind/components/blocks/verify-code.tsx"
import { cn } from "cn"
import { CheckIcon, LockSimpleIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import {
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withSequence,
  withTiming,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"
import { KeyboardScrollView } from "@/components/ui/keyboard-scroll-view"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

const DEMO_CODE = "482913"

const SHAKE = [-10, 9, -6, 4, 0]

const STEP = { duration: 70, reduceMotion: ReduceMotion.System }

type Status = "idle" | "error" | "ok"

function LinkText({
  onPress,
  children,
}: {
  onPress?: () => void
  children: React.ReactNode
}) {
  return (
    <Pressable
      role="link"
      hitSlop={8}
      onPress={onPress}
      className="active:opacity-60"
    >
      <Text className="text-sm text-primary">{children}</Text>
    </Pressable>
  )
}

function VerifyCode({
  onHelp,
  onVerify = (code) => code === DEMO_CODE,
}: {
  onHelp?: () => void
  onVerify?: (code: string) => boolean | Promise<boolean>
}) {
  const verifyRef = React.useRef(onVerify)
  verifyRef.current = onVerify
  const [code, setCode] = React.useState("")
  const [status, setStatus] = React.useState<Status>("idle")
  const [checking, setChecking] = React.useState<string | null>(null)
  const [left, setLeft] = React.useState(30)
  const offset = useSharedValue(0)

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

  React.useEffect(() => {
    if (left <= 0) return
    const timer = setTimeout(() => setLeft(left - 1), 1000)
    return () => clearTimeout(timer)
  }, [left])

  React.useEffect(() => {
    if (checking === null) return
    let active = true
    const timer = setTimeout(async () => {
      const valid = await verifyRef.current(checking)
      if (!active) return
      setChecking(null)
      if (valid) return setStatus("ok")
      setStatus("error")
      offset.value = withSequence(...SHAKE.map((x) => withTiming(x, STEP)))
    }, 200)
    return () => {
      active = false
      clearTimeout(timer)
    }
  }, [checking, offset])

  React.useEffect(() => {
    if (status !== "error") return
    const timer = setTimeout(() => {
      setCode("")
      setStatus("idle")
    }, 800)
    return () => clearTimeout(timer)
  }, [status])

  if (status === "ok") {
    return (
      <Screen>
        <View className="flex-1 items-center justify-center p-6">
          <AnimatedView
            entering={FadeInDown.duration(600).reduceMotion(
              ReduceMotion.System
            )}
            className="items-center gap-3.5"
          >
            <View className="size-[4.75rem] items-center justify-center rounded-full bg-green-500">
              <Icon
                as={CheckIcon}
                weight="bold"
                className="size-[2.375rem] text-primary-foreground"
              />
            </View>
            <Text role="heading" className="text-3xl font-semibold">
              Verified.
            </Text>
            <Text className="text-center text-lg text-muted-foreground">
              This device is now trusted. You won't be asked for a code next
              time.
            </Text>
            <Pressable
              role="link"
              hitSlop={8}
              onPress={() => {
                setCode("")
                setStatus("idle")
              }}
              className="active:opacity-60"
            >
              <Text className="text-lg text-primary">Start over ›</Text>
            </Pressable>
          </AnimatedView>
        </View>
      </Screen>
    )
  }

  return (
    <Screen>
      <KeyboardScrollView contentContainerClassName="grow items-center justify-center px-6 py-8">
        <View className="size-16 items-center justify-center rounded-full bg-muted">
          <Icon
            as={LockSimpleIcon}
            weight="bold"
            className="size-[1.875rem] text-primary"
          />
        </View>
        <Text
          role="heading"
          className="mt-5 text-center text-3xl font-semibold"
        >
          Two-factor authentication
        </Text>
        <Text className="mt-3 mb-[1.875rem] text-center text-lg text-muted-foreground">
          Enter the 6-digit code sent to your trusted devices.
        </Text>
        <AnimatedView style={shakeStyle}>
          <InputOTP
            autoFocus
            length={6}
            value={code}
            onValueChange={(value) => {
              setCode(value)
              setStatus("idle")
            }}
            onValueComplete={setChecking}
            accessibilityLabel="Verification code"
            className="gap-4"
          >
            {[
              [0, 1, 2],
              [3, 4, 5],
            ].map((group) => (
              <InputOTPGroup key={group[0]} className="gap-2.5">
                {group.map((index) => (
                  <InputOTPSlot
                    key={index}
                    index={index}
                    className={(state) =>
                      cn(
                        "h-14 w-11 rounded-xl border bg-transparent",
                        status === "error" && "border-destructive",
                        status !== "error" && state.active && "border-primary",
                        status !== "error" && !state.active && "border-input"
                      )
                    }
                  />
                ))}
              </InputOTPGroup>
            ))}
          </InputOTP>
        </AnimatedView>
        <Text
          accessibilityLiveRegion="polite"
          className={cn(
            "mt-3.5 min-h-5 text-sm",
            status === "error" ? "text-destructive" : "text-muted-foreground"
          )}
        >
          {status === "error" ? "Incorrect code." : `Demo code: ${DEMO_CODE}`}
        </Text>
        <View className="mt-6 items-center gap-2.5">
          {left > 0 ? (
            <Text className="text-sm text-muted-foreground tabular-nums">
              Resend code · 0:{String(left).padStart(2, "0")}
            </Text>
          ) : (
            <LinkText onPress={() => setLeft(30)}>Resend code ›</LinkText>
          )}
          <LinkText onPress={onHelp}>
            Didn't get a verification code? ›
          </LinkText>
        </View>
      </KeyboardScrollView>
    </Screen>
  )
}

export { VerifyCode }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Input OTP](https://ui.suiss.app/docs/components/input-otp), [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), 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/blocks/verify-code.tsx`:

```tsx title="stylesheet/components/blocks/verify-code.tsx"
import { CheckIcon, LockSimpleIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, StyleSheet, View } from "react-native"
import {
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withSequence,
  withTiming,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"
import { KeyboardScrollView } from "@/components/ui/keyboard-scroll-view"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import {
  fontSize,
  palette,
  radius,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

const DEMO_CODE = "482913"

const SHAKE = [-10, 9, -6, 4, 0]

const STEP = { duration: 70, reduceMotion: ReduceMotion.System }

type Status = "idle" | "error" | "ok"

const styles = StyleSheet.create({
  center: {
    flex: 1,
    alignItems: "center",
    justifyContent: "center",
    padding: 24,
  },
  stack: { alignItems: "center", gap: 14 },
  check: {
    width: 76,
    height: 76,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
  },
  title: { ...fontSize["3xl"], fontWeight: "600", textAlign: "center" },
  lead: { ...fontSize.lg, textAlign: "center" },
  content: {
    flexGrow: 1,
    alignItems: "center",
    justifyContent: "center",
    paddingHorizontal: 24,
    paddingVertical: 32,
  },
  lock: {
    width: 64,
    height: 64,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
  },
  slot: {
    height: 56,
    width: 44,
    borderRadius: radius.xl,
    borderWidth: 1,
    backgroundColor: "transparent",
  },
  hint: { ...fontSize.sm, marginTop: 14, minHeight: 20 },
  links: { marginTop: 24, alignItems: "center", gap: 10 },
})

function slotBorder(active: boolean, colors: ThemeColors) {
  return active ? colors.primary : colors.input
}

function LinkText({
  onPress,
  children,
}: {
  onPress?: () => void
  children: React.ReactNode
}) {
  const { colors } = useTheme()
  return (
    <Pressable
      role="link"
      hitSlop={8}
      onPress={onPress}
      style={({ pressed }) => pressed && { opacity: 0.6 }}
    >
      <Text style={[fontSize.sm, { color: colors.primary }]}>{children}</Text>
    </Pressable>
  )
}

function VerifyCode({
  onHelp,
  onVerify = (code) => code === DEMO_CODE,
}: {
  onHelp?: () => void
  onVerify?: (code: string) => boolean | Promise<boolean>
}) {
  const verifyRef = React.useRef(onVerify)
  verifyRef.current = onVerify
  const { colors } = useTheme()
  const [code, setCode] = React.useState("")
  const [status, setStatus] = React.useState<Status>("idle")
  const [checking, setChecking] = React.useState<string | null>(null)
  const [left, setLeft] = React.useState(30)
  const offset = useSharedValue(0)

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

  React.useEffect(() => {
    if (left <= 0) return
    const timer = setTimeout(() => setLeft(left - 1), 1000)
    return () => clearTimeout(timer)
  }, [left])

  React.useEffect(() => {
    if (checking === null) return
    let active = true
    const timer = setTimeout(async () => {
      const valid = await verifyRef.current(checking)
      if (!active) return
      setChecking(null)
      if (valid) return setStatus("ok")
      setStatus("error")
      offset.value = withSequence(...SHAKE.map((x) => withTiming(x, STEP)))
    }, 200)
    return () => {
      active = false
      clearTimeout(timer)
    }
  }, [checking, offset])

  React.useEffect(() => {
    if (status !== "error") return
    const timer = setTimeout(() => {
      setCode("")
      setStatus("idle")
    }, 800)
    return () => clearTimeout(timer)
  }, [status])

  if (status === "ok") {
    return (
      <Screen>
        <View style={styles.center}>
          <AnimatedView
            entering={FadeInDown.duration(600).reduceMotion(
              ReduceMotion.System
            )}
            style={styles.stack}
          >
            <View style={[styles.check, { backgroundColor: palette.green500 }]}>
              <Icon
                as={CheckIcon}
                weight="bold"
                size={38}
                color={colors.primaryForeground}
              />
            </View>
            <Text role="heading" style={styles.title}>
              Verified.
            </Text>
            <Text style={[styles.lead, { color: colors.mutedForeground }]}>
              This device is now trusted. You won't be asked for a code next
              time.
            </Text>
            <Pressable
              role="link"
              hitSlop={8}
              onPress={() => {
                setCode("")
                setStatus("idle")
              }}
              style={({ pressed }) => pressed && { opacity: 0.6 }}
            >
              <Text style={[fontSize.lg, { color: colors.primary }]}>
                Start over ›
              </Text>
            </Pressable>
          </AnimatedView>
        </View>
      </Screen>
    )
  }

  return (
    <Screen>
      <KeyboardScrollView contentContainerStyle={styles.content}>
        <View style={[styles.lock, { backgroundColor: colors.muted }]}>
          <Icon
            as={LockSimpleIcon}
            weight="bold"
            size={30}
            color={colors.primary}
          />
        </View>
        <Text role="heading" style={[styles.title, { marginTop: 20 }]}>
          Two-factor authentication
        </Text>
        <Text
          style={[
            styles.lead,
            { marginTop: 12, marginBottom: 30, color: colors.mutedForeground },
          ]}
        >
          Enter the 6-digit code sent to your trusted devices.
        </Text>
        <AnimatedView style={shakeStyle}>
          <InputOTP
            autoFocus
            length={6}
            value={code}
            onValueChange={(value) => {
              setCode(value)
              setStatus("idle")
            }}
            onValueComplete={setChecking}
            accessibilityLabel="Verification code"
            style={{ gap: 16 }}
          >
            {[
              [0, 1, 2],
              [3, 4, 5],
            ].map((group) => (
              <InputOTPGroup key={group[0]} style={{ gap: 10 }}>
                {group.map((index) => (
                  <InputOTPSlot
                    key={index}
                    index={index}
                    style={(state) => [
                      styles.slot,
                      {
                        borderColor:
                          status === "error"
                            ? colors.destructive
                            : slotBorder(state.active, colors),
                      },
                    ]}
                  />
                ))}
              </InputOTPGroup>
            ))}
          </InputOTP>
        </AnimatedView>
        <Text
          accessibilityLiveRegion="polite"
          style={[
            styles.hint,
            {
              color:
                status === "error"
                  ? colors.destructive
                  : colors.mutedForeground,
            },
          ]}
        >
          {status === "error" ? "Incorrect code." : `Demo code: ${DEMO_CODE}`}
        </Text>
        <View style={styles.links}>
          {left > 0 ? (
            <Text
              style={[
                fontSize.sm,
                {
                  color: colors.mutedForeground,
                  fontVariant: ["tabular-nums"],
                },
              ]}
            >
              Resend code · 0:{String(left).padStart(2, "0")}
            </Text>
          ) : (
            <LinkText onPress={() => setLeft(30)}>Resend code ›</LinkText>
          )}
          <LinkText onPress={onHelp}>
            Didn't get a verification code? ›
          </LinkText>
        </View>
      </KeyboardScrollView>
    </Screen>
  )
}

export { VerifyCode }
```

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/Blocks/VerifyCode.swift`: shown above

- `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/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

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

- `swift/Components/Screen.swift`: see [Screen](https://ui.suiss.app/docs/components/screen.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:

- `kotlin/blocks/VerifyCode.kt`: shown above

- `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/KeyboardScrollView.kt`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.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/Screen.kt`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

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

- `kotlin/interactions/OtpAutofill.kt`: see [OTP Autofill](https://ui.suiss.app/docs/interactions/otp-autofill.md)

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

## Usage

```tsx
import { VerifyCode } from "@/components/blocks/verify-code"
```

```tsx
<VerifyCode />
```

`onVerify` receives the code and returns a boolean or a promise; by default it accepts the demo code `482913`. `onHelp` handles the help link.

