# Verify Device

A trusted-device sign-in flow that pairs instructions with a simulated approval alert and code entry.

Verify Device 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-device

**React**

**Example**

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

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

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

const DEMO_CODE = "482913"

const backdrop =
  "https://images.unsplash.com/photo-1714578187196-29775454aa39?w=900&q=80&auto=format&fit=crop"

type Phase = "ask" | "code" | "deny" | "ok"

const copy = {
  ask: {
    title: "Approve from your other device.",
    body: "We sent a sign-in request to your trusted devices. Tap Allow on the alert to the right.",
  },
  code: {
    title: "Enter the verification code.",
    body: "Type the 6-digit code shown on your trusted device.",
  },
  deny: {
    title: "Sign-in denied.",
    body: "If this wasn't you, your account is safe. There's nothing else you need to do.",
  },
}

const slotClass =
  "h-14 w-11 rounded-xl sm:h-15.5 sm:w-12.5 border border-input bg-transparent text-3xl data-[active=true]:border-primary data-[active=true]:ring-3 data-[active=true]:ring-primary/25 group-data-[status=error]/code:border-destructive"

function AlertAction({
  children,
  primary,
  onClick,
}: {
  children: React.ReactNode
  primary?: boolean
  onClick?: () => void
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      data-primary={primary ? "" : undefined}
      className="h-11 w-full border-t border-input text-lg text-primary outline-none hover:bg-accent focus-visible:bg-accent data-primary:font-semibold"
    >
      {children}
    </button>
  )
}

export function VerifyDevice() {
  const [phase, setPhase] = React.useState<Phase>("ask")
  const [code, setCode] = React.useState("")
  const [error, setError] = React.useState(false)

  if (phase === "ok") {
    return (
      <section className="flex min-h-150 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">
            Sign-in approved.
          </h1>
          <p className="text-lg text-muted-foreground">
            This browser was added to your suiss Account.
          </p>
          <button
            type="button"
            onClick={() => {
              setCode("")
              setPhase("ask")
            }}
            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="grid min-h-150 bg-background md:grid-cols-2">
      <div className="flex flex-col justify-center gap-3.5 px-6 py-12 md:px-12">
        <span className="flex size-14 items-center justify-center rounded-full bg-muted text-primary">
          {phase === "code" ? (
            <LockSimpleIcon className="size-6.5" />
          ) : (
            <DeviceMobileIcon className="size-6.5" />
          )}
        </span>
        <h1 className="text-4xl font-semibold tracking-tight text-balance">
          {copy[phase].title}
        </h1>
        <p className="max-w-95 text-lg text-muted-foreground">
          {copy[phase].body}
        </p>
        <div className="mt-2.5 min-h-16" aria-live="polite">
          {phase === "code" && (
            <div className="transition-[opacity,translate] duration-800 starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none">
              <InputOTP
                autoFocus
                maxLength={6}
                value={code}
                onChange={(value) => {
                  setCode(value)
                  setError(false)
                }}
                onComplete={(value) => {
                  if (value === DEMO_CODE) return setPhase("ok")
                  setError(true)
                  window.setTimeout(() => setCode(""), 800)
                }}
                aria-label="Verification code"
              >
                <InputOTPGroup
                  data-status={error ? "error" : "idle"}
                  className="group/code gap-2.5"
                >
                  {[0, 1, 2, 3, 4, 5].map((index) => (
                    <InputOTPSlot
                      key={index}
                      index={index}
                      className={slotClass}
                    />
                  ))}
                </InputOTPGroup>
              </InputOTP>
              {error && (
                <p className="mt-2.5 text-sm text-destructive">
                  Incorrect code.
                </p>
              )}
            </div>
          )}
          {phase === "ask" && (
            <p className="flex items-center gap-2.5 text-sm text-muted-foreground">
              <Spinner />
              Waiting for approval…
            </p>
          )}
          {phase === "deny" && (
            <button
              type="button"
              onClick={() => setPhase("ask")}
              className="rounded-xs text-base text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
            >
              Try again ›
            </button>
          )}
        </div>
      </div>
      <div className="relative flex min-h-110 items-center justify-center overflow-hidden p-6">
        <div
          aria-hidden
          style={{ backgroundImage: `url(${backdrop})` }}
          className="absolute -inset-5 bg-cover bg-center opacity-85 blur-lg saturate-120"
        />
        <div aria-hidden className="absolute inset-0 bg-black/18" />
        <div
          key={phase}
          role="alertdialog"
          aria-label="Trusted device"
          className="relative w-67.5 overflow-hidden rounded-[0.875rem] bg-popover/86 text-center text-popover-foreground shadow-[0_20px_60px_rgb(0_0_0/0.3)] backdrop-blur-2xl backdrop-saturate-180 transition-[opacity,translate] duration-800 starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none"
        >
          {phase === "code" && (
            <>
              <div className="px-4 pt-5 pb-4.5">
                <p className="text-lg font-semibold">
                  suiss Account Verification Code
                </p>
                <p className="mt-2.5 mb-1 text-4xl font-semibold tracking-widest tabular-nums">
                  482 913
                </p>
                <p className="text-sm leading-snug text-muted-foreground">
                  Enter this code in the browser you're signing in to.
                </p>
              </div>
              <AlertAction primary>OK</AlertAction>
            </>
          )}
          {phase === "deny" && (
            <>
              <div className="px-4 pt-5 pb-4.5">
                <p className="text-lg font-semibold">Sign-in blocked</p>
                <p className="mt-1 text-sm text-muted-foreground">
                  If this wasn't you, change your password.
                </p>
              </div>
              <AlertAction onClick={() => setPhase("ask")}>
                Show request again
              </AlertAction>
            </>
          )}
          {phase === "ask" && (
            <>
              <div className="px-4 pt-5 pb-4">
                <p className="text-lg font-semibold">
                  suiss Account Sign-in Requested
                </p>
                <p className="mt-1 text-sm leading-snug text-muted-foreground">
                  Your suiss Account is being used to sign in to a browser near
                  San Francisco.
                </p>
                <div
                  aria-hidden
                  className="mt-3.5 h-24 rounded-[0.625rem] bg-[radial-gradient(circle_at_50%_52%,var(--color-primary)_0_5px,color-mix(in_oklab,var(--color-primary)_25%,transparent)_6px_24px,transparent_25px),linear-gradient(135deg,oklch(0.9_0.03_140),oklch(0.88_0.03_230))]"
                />
              </div>
              <AlertAction primary onClick={() => setPhase("code")}>
                Allow
              </AlertAction>
              <AlertAction onClick={() => setPhase("deny")}>
                Don't Allow
              </AlertAction>
            </>
          )}
        </div>
      </div>
    </section>
  )
}
```

A trusted-device sign-in flow with instructions on one side and a simulated approval alert on the other, which shows a verification code after Allow and a blocked message after Don't Allow. The user then types the code to finish. The columns sit side by side on desktop and stack on mobile. Use it to illustrate or build sign-in approval from a second device.

## Installation

### CLI

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

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-device
```

### 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), [Spinner](https://ui.suiss.app/docs/components/spinner).

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

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

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

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

const DEMO_CODE = "482913"

const backdrop =
  "https://images.unsplash.com/photo-1714578187196-29775454aa39?w=900&q=80&auto=format&fit=crop"

type Phase = "ask" | "code" | "deny" | "ok"

const copy = {
  ask: {
    title: "Approve from your other device.",
    body: "We sent a sign-in request to your trusted devices. Tap Allow on the alert to the right.",
  },
  code: {
    title: "Enter the verification code.",
    body: "Type the 6-digit code shown on your trusted device.",
  },
  deny: {
    title: "Sign-in denied.",
    body: "If this wasn't you, your account is safe. There's nothing else you need to do.",
  },
}

const slotClass =
  "h-14 w-11 rounded-xl sm:h-15.5 sm:w-12.5 border border-input bg-transparent text-3xl data-[active=true]:border-primary data-[active=true]:ring-3 data-[active=true]:ring-primary/25 group-data-[status=error]/code:border-destructive"

function AlertAction({
  children,
  primary,
  onClick,
}: {
  children: React.ReactNode
  primary?: boolean
  onClick?: () => void
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      data-primary={primary ? "" : undefined}
      className="h-11 w-full border-t border-input text-lg text-primary outline-none hover:bg-accent focus-visible:bg-accent data-primary:font-semibold"
    >
      {children}
    </button>
  )
}

export function VerifyDevice() {
  const [phase, setPhase] = React.useState<Phase>("ask")
  const [code, setCode] = React.useState("")
  const [error, setError] = React.useState(false)

  if (phase === "ok") {
    return (
      <section className="flex min-h-150 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">
            Sign-in approved.
          </h1>
          <p className="text-lg text-muted-foreground">
            This browser was added to your suiss Account.
          </p>
          <button
            type="button"
            onClick={() => {
              setCode("")
              setPhase("ask")
            }}
            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="grid min-h-150 bg-background md:grid-cols-2">
      <div className="flex flex-col justify-center gap-3.5 px-6 py-12 md:px-12">
        <span className="flex size-14 items-center justify-center rounded-full bg-muted text-primary">
          {phase === "code" ? (
            <LockSimpleIcon className="size-6.5" />
          ) : (
            <DeviceMobileIcon className="size-6.5" />
          )}
        </span>
        <h1 className="text-4xl font-semibold tracking-tight text-balance">
          {copy[phase].title}
        </h1>
        <p className="max-w-95 text-lg text-muted-foreground">
          {copy[phase].body}
        </p>
        <div className="mt-2.5 min-h-16" aria-live="polite">
          {phase === "code" && (
            <div className="transition-[opacity,translate] duration-800 starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none">
              <InputOTP
                autoFocus
                maxLength={6}
                value={code}
                onChange={(value) => {
                  setCode(value)
                  setError(false)
                }}
                onComplete={(value) => {
                  if (value === DEMO_CODE) return setPhase("ok")
                  setError(true)
                  window.setTimeout(() => setCode(""), 800)
                }}
                aria-label="Verification code"
              >
                <InputOTPGroup
                  data-status={error ? "error" : "idle"}
                  className="group/code gap-2.5"
                >
                  {[0, 1, 2, 3, 4, 5].map((index) => (
                    <InputOTPSlot
                      key={index}
                      index={index}
                      className={slotClass}
                    />
                  ))}
                </InputOTPGroup>
              </InputOTP>
              {error && (
                <p className="mt-2.5 text-sm text-destructive">
                  Incorrect code.
                </p>
              )}
            </div>
          )}
          {phase === "ask" && (
            <p className="flex items-center gap-2.5 text-sm text-muted-foreground">
              <Spinner />
              Waiting for approval…
            </p>
          )}
          {phase === "deny" && (
            <button
              type="button"
              onClick={() => setPhase("ask")}
              className="rounded-xs text-base text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
            >
              Try again ›
            </button>
          )}
        </div>
      </div>
      <div className="relative flex min-h-110 items-center justify-center overflow-hidden p-6">
        <div
          aria-hidden
          style={{ backgroundImage: `url(${backdrop})` }}
          className="absolute -inset-5 bg-cover bg-center opacity-85 blur-lg saturate-120"
        />
        <div aria-hidden className="absolute inset-0 bg-black/18" />
        <div
          key={phase}
          role="alertdialog"
          aria-label="Trusted device"
          className="relative w-67.5 overflow-hidden rounded-[0.875rem] bg-popover/86 text-center text-popover-foreground shadow-[0_20px_60px_rgb(0_0_0/0.3)] backdrop-blur-2xl backdrop-saturate-180 transition-[opacity,translate] duration-800 starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none"
        >
          {phase === "code" && (
            <>
              <div className="px-4 pt-5 pb-4.5">
                <p className="text-lg font-semibold">
                  suiss Account Verification Code
                </p>
                <p className="mt-2.5 mb-1 text-4xl font-semibold tracking-widest tabular-nums">
                  482 913
                </p>
                <p className="text-sm leading-snug text-muted-foreground">
                  Enter this code in the browser you're signing in to.
                </p>
              </div>
              <AlertAction primary>OK</AlertAction>
            </>
          )}
          {phase === "deny" && (
            <>
              <div className="px-4 pt-5 pb-4.5">
                <p className="text-lg font-semibold">Sign-in blocked</p>
                <p className="mt-1 text-sm text-muted-foreground">
                  If this wasn't you, change your password.
                </p>
              </div>
              <AlertAction onClick={() => setPhase("ask")}>
                Show request again
              </AlertAction>
            </>
          )}
          {phase === "ask" && (
            <>
              <div className="px-4 pt-5 pb-4">
                <p className="text-lg font-semibold">
                  suiss Account Sign-in Requested
                </p>
                <p className="mt-1 text-sm leading-snug text-muted-foreground">
                  Your suiss Account is being used to sign in to a browser near
                  San Francisco.
                </p>
                <div
                  aria-hidden
                  className="mt-3.5 h-24 rounded-[0.625rem] bg-[radial-gradient(circle_at_50%_52%,var(--color-primary)_0_5px,color-mix(in_oklab,var(--color-primary)_25%,transparent)_6px_24px,transparent_25px),linear-gradient(135deg,oklch(0.9_0.03_140),oklch(0.88_0.03_230))]"
                />
              </div>
              <AlertAction primary onClick={() => setPhase("code")}>
                Allow
              </AlertAction>
              <AlertAction onClick={() => setPhase("deny")}>
                Don't Allow
              </AlertAction>
            </>
          )}
        </div>
      </div>
    </section>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { VerifyDevice } from "@/components/blocks/verify-device"
```

```tsx
<VerifyDevice />
```

In a real flow the approval alert appears on the user's other device, so remove the simulated alert and poll or subscribe for the approval instead. Replace the `DEMO_CODE` check with your verification API, and edit the per-phase text in `copy`. Swap the `backdrop` image and the location text.

## Notes

The backdrop image is an Unsplash placeholder.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx title="uniwind/components/blocks/verify-device.tsx"
import { cn } from "cn"
import {
  CheckIcon,
  DeviceMobileIcon,
  LockSimpleIcon,
} from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import { FadeIn, FadeInDown, ReduceMotion } 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 { Spinner } from "@/components/ui/spinner"
import { Text } from "@/components/ui/text"

const DEMO_CODE = "482913"

type Phase = "ask" | "code" | "deny" | "ok"

const COPY = {
  ask: {
    title: "Approve from your other device.",
    body: "We sent a sign-in request to your trusted devices. Tap Allow on the alert below.",
  },
  code: {
    title: "Enter the verification code.",
    body: "Type the 6-digit code shown on your trusted device.",
  },
  deny: {
    title: "Sign-in denied.",
    body: "If this wasn't you, your account is safe. There's nothing else you need to do.",
  },
}

const ENTERING = FadeIn.duration(500).reduceMotion(ReduceMotion.System)

function AlertAction({
  children,
  primary,
  onPress,
}: {
  children: React.ReactNode
  primary?: boolean
  onPress?: () => void
}) {
  return (
    <Pressable
      role="button"
      onPress={onPress}
      className="h-11 w-full items-center justify-center border-t border-input active:opacity-80"
    >
      <Text className={cn("text-lg text-primary", primary && "font-semibold")}>
        {children}
      </Text>
    </Pressable>
  )
}

function VerifyDevice({
  onVerify = (code) => code === DEMO_CODE,
}: {
  onVerify?: (code: string) => boolean | Promise<boolean>
}) {
  const [phase, setPhase] = React.useState<Phase>("ask")
  const [code, setCode] = React.useState("")
  const [error, setError] = React.useState(false)

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

  if (phase === "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-center text-3xl font-semibold">
              Sign-in approved.
            </Text>
            <Text className="text-center text-lg text-muted-foreground">
              This device was added to your suiss Account.
            </Text>
            <Pressable
              role="link"
              hitSlop={8}
              onPress={() => {
                setCode("")
                setError(false)
                setPhase("ask")
              }}
              className="active:opacity-60"
            >
              <Text className="text-lg text-primary">Start over ›</Text>
            </Pressable>
          </AnimatedView>
        </View>
      </Screen>
    )
  }

  return (
    <Screen>
      <KeyboardScrollView contentContainerClassName="grow gap-3.5 px-6 py-8">
        <View className="size-14 items-center justify-center rounded-full bg-muted">
          <Icon
            as={phase === "code" ? LockSimpleIcon : DeviceMobileIcon}
            weight="bold"
            className="size-6.5 text-primary"
          />
        </View>
        <Text role="heading" className="text-3xl font-semibold">
          {COPY[phase].title}
        </Text>
        <Text className="text-lg text-muted-foreground">
          {COPY[phase].body}
        </Text>
        <View accessibilityLiveRegion="polite" className="mt-2.5 min-h-16">
          {phase === "code" ? (
            <AnimatedView entering={ENTERING}>
              <InputOTP
                autoFocus
                length={6}
                value={code}
                onValueChange={(value) => {
                  setCode(value)
                  setError(false)
                }}
                onValueComplete={async (value) => {
                  if (await onVerify(value)) return setPhase("ok")
                  setError(true)
                }}
                accessibilityLabel="Verification code"
              >
                <InputOTPGroup className="gap-2">
                  {[0, 1, 2, 3, 4, 5].map((index) => (
                    <InputOTPSlot
                      key={index}
                      index={index}
                      className={(state) =>
                        cn(
                          "h-14 w-11 rounded-xl border bg-transparent",
                          error && "border-destructive",
                          !error && state.active && "border-primary",
                          !(error || state.active) && "border-input"
                        )
                      }
                    />
                  ))}
                </InputOTPGroup>
              </InputOTP>
              {error ? (
                <Text className="mt-2.5 text-sm text-destructive">
                  Incorrect code.
                </Text>
              ) : null}
            </AnimatedView>
          ) : null}
          {phase === "ask" ? (
            <View className="flex-row items-center gap-2.5">
              <Spinner />
              <Text className="text-sm text-muted-foreground">
                Waiting for approval…
              </Text>
            </View>
          ) : null}
          {phase === "deny" ? (
            <Pressable
              role="link"
              hitSlop={8}
              onPress={() => setPhase("ask")}
              className="self-start active:opacity-60"
            >
              <Text className="text-base text-primary">Try again ›</Text>
            </Pressable>
          ) : null}
        </View>
        <View className="mt-2 items-center justify-center rounded-3xl bg-muted px-6 py-10">
          <AnimatedView
            key={phase}
            entering={ENTERING}
            accessibilityRole="alert"
            accessibilityLabel="Trusted device"
            className="w-67.5 overflow-hidden rounded-[0.875rem] bg-popover shadow-lg"
          >
            {phase === "code" ? (
              <>
                <View className="items-center px-4 pt-5 pb-4.5">
                  <Text className="text-center text-lg font-semibold">
                    suiss Account Verification Code
                  </Text>
                  <Text className="mt-2.5 mb-1 text-3xl font-semibold tracking-widest tabular-nums">
                    482 913
                  </Text>
                  <Text className="text-center text-sm text-muted-foreground">
                    Enter this code on the device you're signing in to.
                  </Text>
                </View>
                <AlertAction primary>OK</AlertAction>
              </>
            ) : null}
            {phase === "deny" ? (
              <>
                <View className="items-center px-4 pt-5 pb-4.5">
                  <Text className="text-center text-lg font-semibold">
                    Sign-in blocked
                  </Text>
                  <Text className="mt-1 text-center text-sm text-muted-foreground">
                    If this wasn't you, change your password.
                  </Text>
                </View>
                <AlertAction onPress={() => setPhase("ask")}>
                  Show request again
                </AlertAction>
              </>
            ) : null}
            {phase === "ask" ? (
              <>
                <View className="px-4 pt-5 pb-4">
                  <Text className="text-center text-lg font-semibold">
                    suiss Account Sign-in Requested
                  </Text>
                  <Text className="mt-1 text-center text-sm text-muted-foreground">
                    Your suiss Account is being used to sign in to a device near
                    San Francisco.
                  </Text>
                  <View className="mt-3.5 h-24 items-center justify-center rounded-[0.625rem] bg-primary/10 dark:bg-primary/20">
                    <View className="size-12 items-center justify-center rounded-full bg-primary/25">
                      <View className="size-2.5 rounded-full bg-primary" />
                    </View>
                  </View>
                </View>
                <AlertAction primary onPress={() => setPhase("code")}>
                  Allow
                </AlertAction>
                <AlertAction onPress={() => setPhase("deny")}>
                  Don't Allow
                </AlertAction>
              </>
            ) : null}
          </AnimatedView>
        </View>
      </KeyboardScrollView>
    </Screen>
  )
}

export { VerifyDevice }
```

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

public struct VerifyDevice: View {
  enum Phase: Sendable {
    case ask, code, deny, ok
  }

  static let demoCode = "482913"
  static let backdrop = URL(string: "https://images.unsplash.com/photo-1714578187196-29775454aa39?w=900&q=80&auto=format&fit=crop")

  private var onVerify: (String) async -> Bool = { $0 == "482913" }
  @State private var phase: Phase
  @State private var code: String
  @State private var error: Bool
  @State private var shakes = 0
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(onVerify: @escaping (String) async -> Bool = { $0 == "482913" }) {
    self.init(phase: .ask)
    self.onVerify = onVerify
  }

  init(phase: Phase, code: String = "", error: Bool = false) {
    _phase = State(initialValue: phase)
    _code = State(initialValue: code)
    _error = State(initialValue: error)
  }

  public var body: some View {
    Screen {
      if phase == .ok {
        approved
          .transition(.opacity.combined(with: .offset(y: 18)))
      } else {
        KeyboardScrollView {
          VStack(alignment: .leading, spacing: 24) {
            prompt
            device
          }
          .padding(.horizontal, 20)
          .padding(.top, 40)
          .padding(.bottom, 24)
        }
      }
    }
    .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.6), value: phase)
  }

  private var title: LocalizedStringKey {
    switch phase {
    case .code: "Enter the verification code."
    case .deny: "Sign-in denied."
    default: "Approve from your other device."
    }
  }

  private var message: LocalizedStringKey {
    switch phase {
    case .code: "Type the 6-digit code shown on your trusted device."
    case .deny: "If this wasn't you, your account is safe. There's nothing else you need to do."
    default: "We sent a sign-in request to your trusted devices. Tap Allow on the alert below."
    }
  }

  private var prompt: some View {
    VStack(alignment: .leading, spacing: 14) {
      (phase == .code ? Ph.lockSimple.bold : Ph.deviceMobile.bold)
        .frame(width: 26, height: 26)
        .foregroundStyle(EsuissColor.primary)
        .frame(width: 56, height: 56)
        .background(EsuissColor.muted, in: Circle())
        .accessibilityHidden(true)
      Text(title)
        .scaledTextStyle(.xl2, weight: .bold)
        .fixedSize(horizontal: false, vertical: true)
      Text(message)
        .scaledTextStyle(.lg)
        .foregroundStyle(EsuissColor.mutedForeground)
        .fixedSize(horizontal: false, vertical: true)
      Group {
        switch phase {
        case .code:
          VStack(alignment: .leading, spacing: 10) {
            InputOTP(text: $code, length: 6) {
              InputOTPGroup {
                ForEach(0..<6, id: \.self) { InputOTPSlot(index: $0) }
              }
            }
            .invalid(error)
            .shake(trigger: shakes)
            .hapticFeedback(.error, trigger: shakes)
            .onChange(of: code) { _, value in
              if !value.isEmpty { error = false }
              if value.count == 6 { complete(value) }
            }
            .accessibilityLabel(Text("Verification code"))
            if error {
              Text("Incorrect code.")
                .scaledFont(.sm)
                .foregroundStyle(EsuissColor.destructive)
            }
          }
        case .deny:
          Button("Try again ›") { phase = .ask }
            .buttonStyle(VerifyDeviceLinkStyle())
        default:
          HStack(spacing: 10) {
            Spinner()
            Text("Waiting for approval…")
              .scaledFont(.sm)
          }
          .foregroundStyle(EsuissColor.mutedForeground)
        }
      }
      .frame(minHeight: 64, alignment: .topLeading)
      .padding(.top, 10)
      .transition(.opacity.combined(with: .offset(y: 6)))
    }
  }

  private var device: some View {
    Palette.black
      .overlay {
        AsyncImage(url: Self.backdrop) { phase in
          if let image = phase.image {
            image.resizable().scaledToFill().blur(radius: 16).saturation(1.2).opacity(0.85)
          }
        }
      }
      .overlay { Palette.black.opacity(0.18) }
      .overlay {
        alert
          .id(phase)
          .transition(.opacity.combined(with: .offset(y: 6)))
      }
      .frame(maxWidth: .infinity)
      .frame(height: 440)
      .clipShape(RoundedRectangle(cornerRadius: Radius.xl3, style: .continuous))
      .accessibilityElement(children: .contain)
      .accessibilityLabel(Text("Trusted device"))
  }

  private var alert: some View {
    VStack(spacing: 0) {
      switch phase {
      case .code:
        VStack(spacing: 0) {
          Text("suiss Account Verification Code")
            .scaledTextStyle(.lg, weight: .semibold)
          Text(verbatim: "482 913")
            .font(.system(size: TextStyle.xl3.size, weight: .semibold))
            .tracking(4)
            .monospacedDigit()
            .padding(.top, 10)
            .padding(.bottom, 4)
          Text("Enter this code on the device you're signing in to.")
            .scaledTextStyle(.sm)
            .foregroundStyle(EsuissColor.mutedForeground)
        }
        .padding(EdgeInsets(top: 20, leading: 16, bottom: 18, trailing: 16))
        VerifyDeviceAction(title: "OK", primary: true) {}
      case .deny:
        VStack(spacing: 4) {
          Text("Sign-in blocked")
            .scaledTextStyle(.lg, weight: .semibold)
          Text("If this wasn't you, change your password.")
            .scaledTextStyle(.sm)
            .foregroundStyle(EsuissColor.mutedForeground)
        }
        .padding(EdgeInsets(top: 20, leading: 16, bottom: 18, trailing: 16))
        VerifyDeviceAction(title: "Show request again", primary: false) { phase = .ask }
      default:
        VStack(spacing: 4) {
          Text("suiss Account Sign-in Requested")
            .scaledTextStyle(.lg, weight: .semibold)
          Text("Your suiss Account is being used to sign in to a device near San Francisco.")
            .scaledTextStyle(.sm)
            .foregroundStyle(EsuissColor.mutedForeground)
          VerifyDeviceMap()
            .padding(.top, 10)
        }
        .padding(EdgeInsets(top: 20, leading: 16, bottom: 16, trailing: 16))
        VerifyDeviceAction(title: "Allow", primary: true) { phase = .code }
        VerifyDeviceAction(title: "Don't Allow", primary: false) { phase = .deny }
      }
    }
    .multilineTextAlignment(.center)
    .fixedSize(horizontal: false, vertical: true)
    .foregroundStyle(EsuissColor.foreground)
    .frame(width: 270)
    .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
    .background(EsuissColor.popover.opacity(0.86), in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
    .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
    .shadow(Shadow(color: Palette.black.opacity(0.3), radius: 30, y: 20))
  }

  private var approved: 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("Sign-in approved.")
        .scaledTextStyle(.xl2, weight: .bold)
      Text("This device was added to your suiss Account.")
        .scaledTextStyle(.lg)
        .foregroundStyle(EsuissColor.mutedForeground)
      Button("Start over ›") {
        code = ""
        phase = .ask
      }
      .buttonStyle(VerifyDeviceLinkStyle(style: .lg))
    }
    .multilineTextAlignment(.center)
    .fixedSize(horizontal: false, vertical: true)
    .padding(24)
    .frame(maxWidth: .infinity, maxHeight: .infinity)
  }

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

private struct VerifyDeviceMap: View {
  var body: some View {
    ZStack {
      Color.adaptive(light: EsuissColor.primary.opacity(0.1), dark: EsuissColor.primary.opacity(0.2))
      Circle()
        .fill(EsuissColor.primary.opacity(0.25))
        .frame(width: 48, height: 48)
      Circle()
        .fill(EsuissColor.primary)
        .frame(width: 10, height: 10)
    }
    .frame(height: 96)
    .clipShape(RoundedRectangle(cornerRadius: Radius.lg, style: .continuous))
    .accessibilityHidden(true)
  }
}

private struct VerifyDeviceAction: View {
  let title: LocalizedStringKey
  let primary: Bool
  let action: () -> Void

  var body: some View {
    Button(action: action) {
      Text(title)
        .scaledFont(.lg, weight: primary ? .semibold : .regular)
        .foregroundStyle(EsuissColor.primary)
        .frame(maxWidth: .infinity, minHeight: 44)
        .contentShape(Rectangle())
    }
    .buttonStyle(VerifyDeviceActionStyle())
    .overlay(alignment: .top) {
      EsuissColor.input.frame(height: 1)
    }
  }
}

private struct VerifyDeviceActionStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .opacity(configuration.isPressed ? 0.8 : 1)
  }
}

private struct VerifyDeviceLinkStyle: ButtonStyle {
  var style: TextStyle = .base

  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/VerifyDevice.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
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.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
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.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.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.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
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 androidx.compose.ui.unit.em
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.bold.Check
import com.adamglin.phosphoricons.bold.DeviceMobile
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.components.Spinner
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.Radius
import com.esuiss.ui.theme.Shadows
import com.esuiss.ui.theme.Typography
import com.esuiss.ui.theme.shadow
import kotlinx.coroutines.delay

private const val DemoCode = "482913"

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

private enum class DevicePhase { Ask, Code, Deny, Ok }

private val Copy = mapOf(
    DevicePhase.Ask to ("Approve from your other device." to "We sent a sign-in request to your trusted devices. Tap Allow on the alert below."),
    DevicePhase.Code to ("Enter the verification code." to "Type the 6-digit code shown on your trusted device."),
    DevicePhase.Deny to ("Sign-in denied." to "If this wasn't you, your account is safe. There's nothing else you need to do."),
)

@Composable
fun VerifyDevice(
    modifier: Modifier = Modifier,
    onVerify: suspend (code: String) -> Boolean = { it == DemoCode },
) {
    val colors = EsuissTheme.colors
    val verify by rememberUpdatedState(onVerify)
    var phase by rememberSaveable { mutableStateOf(DevicePhase.Ask) }

    Screen(modifier) {
        if (phase == DevicePhase.Ok) {
            Success { phase = DevicePhase.Ask }
            return@Screen
        }
        KeyboardScrollView(Modifier.fillMaxSize()) {
            Column(
                modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 48.dp, bottom = 24.dp),
                verticalArrangement = Arrangement.spacedBy(14.dp),
            ) {
                Box(
                    Modifier
                        .size(56.dp)
                        .background(colors.muted, CircleShape),
                    contentAlignment = Alignment.Center,
                ) {
                    Icon(
                        if (phase == DevicePhase.Code) PhosphorIcons.Bold.LockSimple else PhosphorIcons.Bold.DeviceMobile,
                        contentDescription = null,
                        size = 26.dp,
                        tint = colors.primary,
                    )
                }
                val (title, body) = Copy.getValue(phase)
                BasicText(
                    title,
                    modifier = Modifier.semantics { heading() },
                    style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                )
                BasicText(body, style = Typography.lg.copy(color = colors.mutedForeground))
                Box(
                    Modifier
                        .padding(top = 10.dp)
                        .heightIn(min = 64.dp)
                        .semantics { liveRegion = LiveRegionMode.Polite },
                ) {
                    when (phase) {
                        DevicePhase.Ask -> Row(
                            horizontalArrangement = Arrangement.spacedBy(10.dp),
                            verticalAlignment = Alignment.CenterVertically,
                        ) {
                            Spinner(color = colors.mutedForeground)
                            BasicText("Waiting for approval…", style = Typography.sm.copy(color = colors.mutedForeground))
                        }
                        DevicePhase.Code -> OtpAutofill(
                            maxLength = 6,
                            onVerify = { code ->
                                val ok = verify(code)
                                if (ok) {
                                    delay(500)
                                    phase = DevicePhase.Ok
                                }
                                ok
                            },
                        ) {
                            OtpAutofillInput(contentDescription = "Verification code", autoFocus = true) {
                                InputOTPGroup {
                                    repeat(6) { InputOTPSlot(it) }
                                }
                            }
                            if (state == OtpAutofillState.Invalid) {
                                BasicText("Incorrect code.", style = Typography.sm.copy(color = colors.destructive))
                            }
                        }
                        else -> LinkText("Try again ›") { phase = DevicePhase.Ask }
                    }
                }
            }
            TrustedDevice(
                phase = phase,
                onAllow = { phase = DevicePhase.Code },
                onDeny = { phase = DevicePhase.Deny },
                onShowAgain = { phase = DevicePhase.Ask },
            )
        }
    }
}

@Composable
private fun TrustedDevice(
    phase: DevicePhase,
    onAllow: () -> Unit,
    onDeny: () -> Unit,
    onShowAgain: () -> Unit,
) {
    val colors = EsuissTheme.colors
    val shape = RoundedCornerShape(Radius.xl3)
    Box(
        modifier = Modifier
            .padding(horizontal = 16.dp)
            .padding(bottom = 24.dp)
            .fillMaxWidth()
            .height(440.dp)
            .clip(shape)
            .background(Palette.indigo500, shape)
            .background(Palette.black.copy(alpha = 0.18f)),
        contentAlignment = Alignment.Center,
    ) {
        AnimatedContent(
            targetState = phase,
            transitionSpec = {
                (fadeIn(tween(800)) + slideInVertically(tween(800)) { 6 }) togetherWith fadeOut(tween(200))
            },
            label = "alert",
        ) { current ->
            val alertShape = RoundedCornerShape(14.dp)
            Column(
                modifier = Modifier
                    .width(270.dp)
                    .shadow(Shadows.lg, alertShape)
                    .clip(alertShape)
                    .background(colors.popover.copy(alpha = 0.92f), alertShape)
                    .semantics { contentDescription = "Trusted device" },
                horizontalAlignment = Alignment.CenterHorizontally,
            ) {
                when (current) {
                    DevicePhase.Code -> {
                        AlertBody {
                            BasicText("suiss Account Verification Code", style = alertTitle())
                            BasicText(
                                "482 913",
                                modifier = Modifier.padding(top = 10.dp, bottom = 4.dp),
                                style = Typography.xl3.copy(
                                    color = colors.foreground,
                                    fontWeight = FontWeight.SemiBold,
                                    letterSpacing = 0.1.em,
                                    fontFeatureSettings = "tnum",
                                    textAlign = TextAlign.Center,
                                ),
                            )
                            BasicText("Enter this code on the device you're signing in to.", style = alertMessage())
                        }
                        AlertAction("OK", primary = true) {}
                    }
                    DevicePhase.Deny -> {
                        AlertBody {
                            BasicText("Sign-in blocked", style = alertTitle())
                            BasicText(
                                "If this wasn't you, change your password.",
                                modifier = Modifier.padding(top = 4.dp),
                                style = alertMessage(),
                            )
                        }
                        AlertAction("Show request again", onClick = onShowAgain)
                    }
                    else -> {
                        AlertBody {
                            BasicText("suiss Account Sign-in Requested", style = alertTitle())
                            BasicText(
                                "Your suiss Account is being used to sign in to a device near San Francisco.",
                                modifier = Modifier.padding(top = 4.dp),
                                style = alertMessage(),
                            )
                            MapPreview()
                        }
                        AlertAction("Allow", primary = true, onClick = onAllow)
                        AlertAction("Don't Allow", onClick = onDeny)
                    }
                }
            }
        }
    }
}

@Composable
private fun alertTitle() = Typography.lg.copy(
    color = EsuissTheme.colors.foreground,
    fontWeight = FontWeight.SemiBold,
    textAlign = TextAlign.Center,
)

@Composable
private fun alertMessage() = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground, textAlign = TextAlign.Center)

@Composable
private fun AlertBody(content: @Composable ColumnScope.() -> Unit) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        content = content,
    )
}

@Composable
private fun MapPreview() {
    val colors = EsuissTheme.colors
    val shape = RoundedCornerShape(10.dp)
    Box(
        Modifier
            .padding(top = 14.dp)
            .fillMaxWidth()
            .height(96.dp)
            .clip(shape)
            .background(colors.primary.copy(alpha = if (colors.isDark) 0.2f else 0.1f), shape)
            .clearAndSetSemantics {}
            .drawBehind {
                val center = Offset(size.width / 2f, size.height * 0.52f)
                drawCircle(colors.primary.copy(alpha = 0.25f), radius = 24.dp.toPx(), center = center)
                drawCircle(colors.primary, radius = 5.dp.toPx(), center = center)
            },
    )
}

@Composable
private fun AlertAction(text: String, primary: Boolean = false, onClick: () -> Unit) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Column(Modifier.fillMaxWidth()) {
        Box(
            Modifier
                .fillMaxWidth()
                .height(1.dp)
                .background(colors.input.copy(alpha = 0.5f)),
        )
        Box(
            Modifier
                .fillMaxWidth()
                .height(44.dp)
                .alpha(if (pressed) 0.8f else 1f)
                .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onClick),
            contentAlignment = Alignment.Center,
        ) {
            BasicText(
                text,
                style = Typography.lg.copy(
                    color = colors.primary,
                    fontWeight = if (primary) FontWeight.SemiBold else FontWeight.Normal,
                ),
            )
        }
    }
}

@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.base.copy(color = EsuissTheme.colors.primary),
    )
}

@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(
            "Sign-in approved.",
            modifier = Modifier.semantics { heading() },
            style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
        )
        BasicText(
            "This device was added to your suiss Account.",
            style = Typography.lg.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
        )
        LinkText("Start over ›", onStartOver)
    }
}
```

The screen shows a mock alert from the trusted device with Allow and Don't Allow. Allowing reveals a code and an `InputOTP` to enter it, with a `Spinner` while checking and an error for a wrong code; denying shows a blocked message with an option to show the request again. A correct code shows an approved screen.

## Installation

**React Native**

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

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-device
```

**SwiftUI**

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

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add verify-device --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), [Spinner](https://ui.suiss.app/docs/components/spinner), [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), [Spinner](https://ui.suiss.app/docs/components/spinner), [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-device.tsx`:

- `uniwind/components/blocks/verify-device.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), [Spinner](https://ui.suiss.app/docs/components/spinner), [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-device.tsx`:

```tsx title="nativewind/components/blocks/verify-device.tsx"
import { cn } from "cn"
import {
  CheckIcon,
  DeviceMobileIcon,
  LockSimpleIcon,
} from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import { FadeIn, FadeInDown, ReduceMotion } 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 { Spinner } from "@/components/ui/spinner"
import { Text } from "@/components/ui/text"

const DEMO_CODE = "482913"

type Phase = "ask" | "code" | "deny" | "ok"

const COPY = {
  ask: {
    title: "Approve from your other device.",
    body: "We sent a sign-in request to your trusted devices. Tap Allow on the alert below.",
  },
  code: {
    title: "Enter the verification code.",
    body: "Type the 6-digit code shown on your trusted device.",
  },
  deny: {
    title: "Sign-in denied.",
    body: "If this wasn't you, your account is safe. There's nothing else you need to do.",
  },
}

const ENTERING = FadeIn.duration(500).reduceMotion(ReduceMotion.System)

function AlertAction({
  children,
  primary,
  onPress,
}: {
  children: React.ReactNode
  primary?: boolean
  onPress?: () => void
}) {
  return (
    <Pressable
      role="button"
      onPress={onPress}
      className="h-11 w-full items-center justify-center border-t border-input active:opacity-80"
    >
      <Text className={cn("text-lg text-primary", primary && "font-semibold")}>
        {children}
      </Text>
    </Pressable>
  )
}

function VerifyDevice({
  onVerify = (code) => code === DEMO_CODE,
}: {
  onVerify?: (code: string) => boolean | Promise<boolean>
}) {
  const [phase, setPhase] = React.useState<Phase>("ask")
  const [code, setCode] = React.useState("")
  const [error, setError] = React.useState(false)

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

  if (phase === "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-center text-3xl font-semibold">
              Sign-in approved.
            </Text>
            <Text className="text-center text-lg text-muted-foreground">
              This device was added to your suiss Account.
            </Text>
            <Pressable
              role="link"
              hitSlop={8}
              onPress={() => {
                setCode("")
                setError(false)
                setPhase("ask")
              }}
              className="active:opacity-60"
            >
              <Text className="text-lg text-primary">Start over ›</Text>
            </Pressable>
          </AnimatedView>
        </View>
      </Screen>
    )
  }

  return (
    <Screen>
      <KeyboardScrollView contentContainerClassName="grow gap-3.5 px-6 py-8">
        <View className="size-14 items-center justify-center rounded-full bg-muted">
          <Icon
            as={phase === "code" ? LockSimpleIcon : DeviceMobileIcon}
            weight="bold"
            className="size-[1.625rem] text-primary"
          />
        </View>
        <Text role="heading" className="text-3xl font-semibold">
          {COPY[phase].title}
        </Text>
        <Text className="text-lg text-muted-foreground">
          {COPY[phase].body}
        </Text>
        <View accessibilityLiveRegion="polite" className="mt-2.5 min-h-16">
          {phase === "code" ? (
            <AnimatedView entering={ENTERING}>
              <InputOTP
                autoFocus
                length={6}
                value={code}
                onValueChange={(value) => {
                  setCode(value)
                  setError(false)
                }}
                onValueComplete={async (value) => {
                  if (await onVerify(value)) return setPhase("ok")
                  setError(true)
                }}
                accessibilityLabel="Verification code"
              >
                <InputOTPGroup className="gap-2">
                  {[0, 1, 2, 3, 4, 5].map((index) => (
                    <InputOTPSlot
                      key={index}
                      index={index}
                      className={(state) =>
                        cn(
                          "h-14 w-11 rounded-xl border bg-transparent",
                          error && "border-destructive",
                          !error && state.active && "border-primary",
                          !(error || state.active) && "border-input"
                        )
                      }
                    />
                  ))}
                </InputOTPGroup>
              </InputOTP>
              {error ? (
                <Text className="mt-2.5 text-sm text-destructive">
                  Incorrect code.
                </Text>
              ) : null}
            </AnimatedView>
          ) : null}
          {phase === "ask" ? (
            <View className="flex-row items-center gap-2.5">
              <Spinner />
              <Text className="text-sm text-muted-foreground">
                Waiting for approval…
              </Text>
            </View>
          ) : null}
          {phase === "deny" ? (
            <Pressable
              role="link"
              hitSlop={8}
              onPress={() => setPhase("ask")}
              className="self-start active:opacity-60"
            >
              <Text className="text-base text-primary">Try again ›</Text>
            </Pressable>
          ) : null}
        </View>
        <View className="mt-2 items-center justify-center rounded-3xl bg-muted px-6 py-10">
          <AnimatedView
            key={phase}
            entering={ENTERING}
            accessibilityRole="alert"
            accessibilityLabel="Trusted device"
            className="w-[16.875rem] overflow-hidden rounded-[0.875rem] bg-popover shadow-lg"
          >
            {phase === "code" ? (
              <>
                <View className="items-center px-4 pt-5 pb-[1.125rem]">
                  <Text className="text-center text-lg font-semibold">
                    suiss Account Verification Code
                  </Text>
                  <Text className="mt-2.5 mb-1 text-3xl font-semibold tracking-widest tabular-nums">
                    482 913
                  </Text>
                  <Text className="text-center text-sm text-muted-foreground">
                    Enter this code on the device you're signing in to.
                  </Text>
                </View>
                <AlertAction primary>OK</AlertAction>
              </>
            ) : null}
            {phase === "deny" ? (
              <>
                <View className="items-center px-4 pt-5 pb-[1.125rem]">
                  <Text className="text-center text-lg font-semibold">
                    Sign-in blocked
                  </Text>
                  <Text className="mt-1 text-center text-sm text-muted-foreground">
                    If this wasn't you, change your password.
                  </Text>
                </View>
                <AlertAction onPress={() => setPhase("ask")}>
                  Show request again
                </AlertAction>
              </>
            ) : null}
            {phase === "ask" ? (
              <>
                <View className="px-4 pt-5 pb-4">
                  <Text className="text-center text-lg font-semibold">
                    suiss Account Sign-in Requested
                  </Text>
                  <Text className="mt-1 text-center text-sm text-muted-foreground">
                    Your suiss Account is being used to sign in to a device near
                    San Francisco.
                  </Text>
                  <View className="mt-3.5 h-24 items-center justify-center rounded-[0.625rem] bg-primary-10 dark:bg-primary-20">
                    <View className="size-12 items-center justify-center rounded-full bg-primary-25">
                      <View className="size-2.5 rounded-full bg-primary" />
                    </View>
                  </View>
                </View>
                <AlertAction primary onPress={() => setPhase("code")}>
                  Allow
                </AlertAction>
                <AlertAction onPress={() => setPhase("deny")}>
                  Don't Allow
                </AlertAction>
              </>
            ) : null}
          </AnimatedView>
        </View>
      </KeyboardScrollView>
    </Screen>
  )
}

export { VerifyDevice }
```

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), [Spinner](https://ui.suiss.app/docs/components/spinner), [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-device.tsx`:

```tsx title="stylesheet/components/blocks/verify-device.tsx"
import {
  CheckIcon,
  DeviceMobileIcon,
  LockSimpleIcon,
} from "phosphor-react-native"
import * as React from "react"
import { Pressable, StyleSheet, View } from "react-native"
import { FadeIn, FadeInDown, ReduceMotion } 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 { Spinner } from "@/components/ui/spinner"
import { Text } from "@/components/ui/text"
import {
  fontSize,
  palette,
  radius,
  shadow,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

const DEMO_CODE = "482913"

type Phase = "ask" | "code" | "deny" | "ok"

const COPY = {
  ask: {
    title: "Approve from your other device.",
    body: "We sent a sign-in request to your trusted devices. Tap Allow on the alert below.",
  },
  code: {
    title: "Enter the verification code.",
    body: "Type the 6-digit code shown on your trusted device.",
  },
  deny: {
    title: "Sign-in denied.",
    body: "If this wasn't you, your account is safe. There's nothing else you need to do.",
  },
}

const ENTERING = FadeIn.duration(500).reduceMotion(ReduceMotion.System)

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

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

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" },
  lead: { ...fontSize.lg },
  content: {
    flexGrow: 1,
    gap: 14,
    paddingHorizontal: 24,
    paddingVertical: 32,
  },
  badge: {
    width: 56,
    height: 56,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
  },
  status: { marginTop: 10, minHeight: 64 },
  slot: {
    height: 56,
    width: 44,
    borderRadius: radius.xl,
    borderWidth: 1,
    backgroundColor: "transparent",
  },
  waiting: { flexDirection: "row", alignItems: "center", gap: 10 },
  panel: {
    marginTop: 8,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius["3xl"],
    paddingHorizontal: 24,
    paddingVertical: 40,
  },
  card: {
    width: 270,
    overflow: "hidden",
    borderRadius: 14,
  },
  cardBody: {
    alignItems: "center",
    paddingHorizontal: 16,
    paddingTop: 20,
    paddingBottom: 18,
  },
  cardTitle: { ...fontSize.lg, fontWeight: "600", textAlign: "center" },
  cardText: { ...fontSize.sm, textAlign: "center" },
  map: {
    marginTop: 14,
    height: 96,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: 10,
  },
  ring: {
    width: 48,
    height: 48,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
  },
  dot: { width: 10, height: 10, borderRadius: radius.full },
  action: {
    height: 44,
    width: "100%",
    alignItems: "center",
    justifyContent: "center",
    borderTopWidth: 1,
  },
})

function AlertAction({
  children,
  primary,
  onPress,
}: {
  children: React.ReactNode
  primary?: boolean
  onPress?: () => void
}) {
  const { colors } = useTheme()
  return (
    <Pressable
      role="button"
      onPress={onPress}
      style={({ pressed }) => [
        styles.action,
        { borderColor: colors.input },
        pressed && { opacity: 0.8 },
      ]}
    >
      <Text
        style={[
          fontSize.lg,
          { color: colors.primary },
          primary && { fontWeight: "600" },
        ]}
      >
        {children}
      </Text>
    </Pressable>
  )
}

function VerifyDevice({
  onVerify = (code) => code === DEMO_CODE,
}: {
  onVerify?: (code: string) => boolean | Promise<boolean>
}) {
  const { colors, isDark } = useTheme()
  const [phase, setPhase] = React.useState<Phase>("ask")
  const [code, setCode] = React.useState("")
  const [error, setError] = React.useState(false)

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

  if (phase === "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, { textAlign: "center" }]}
            >
              Sign-in approved.
            </Text>
            <Text
              style={[
                styles.lead,
                { textAlign: "center", color: colors.mutedForeground },
              ]}
            >
              This device was added to your suiss Account.
            </Text>
            <Pressable
              role="link"
              hitSlop={8}
              onPress={() => {
                setCode("")
                setError(false)
                setPhase("ask")
              }}
              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.badge, { backgroundColor: colors.muted }]}>
          <Icon
            as={phase === "code" ? LockSimpleIcon : DeviceMobileIcon}
            weight="bold"
            size={26}
            color={colors.primary}
          />
        </View>
        <Text role="heading" style={styles.title}>
          {COPY[phase].title}
        </Text>
        <Text style={[styles.lead, { color: colors.mutedForeground }]}>
          {COPY[phase].body}
        </Text>
        <View accessibilityLiveRegion="polite" style={styles.status}>
          {phase === "code" ? (
            <AnimatedView entering={ENTERING}>
              <InputOTP
                autoFocus
                length={6}
                value={code}
                onValueChange={(value) => {
                  setCode(value)
                  setError(false)
                }}
                onValueComplete={async (value) => {
                  if (await onVerify(value)) return setPhase("ok")
                  setError(true)
                }}
                accessibilityLabel="Verification code"
              >
                <InputOTPGroup style={{ gap: 8 }}>
                  {[0, 1, 2, 3, 4, 5].map((index) => (
                    <InputOTPSlot
                      key={index}
                      index={index}
                      style={(state) => [
                        styles.slot,
                        {
                          borderColor: error
                            ? colors.destructive
                            : slotBorder(state.active, colors),
                        },
                      ]}
                    />
                  ))}
                </InputOTPGroup>
              </InputOTP>
              {error ? (
                <Text
                  style={[
                    fontSize.sm,
                    { marginTop: 10, color: colors.destructive },
                  ]}
                >
                  Incorrect code.
                </Text>
              ) : null}
            </AnimatedView>
          ) : null}
          {phase === "ask" ? (
            <View style={styles.waiting}>
              <Spinner />
              <Text style={[fontSize.sm, { color: colors.mutedForeground }]}>
                Waiting for approval…
              </Text>
            </View>
          ) : null}
          {phase === "deny" ? (
            <Pressable
              role="link"
              hitSlop={8}
              onPress={() => setPhase("ask")}
              style={({ pressed }) => [
                { alignSelf: "flex-start" },
                pressed && { opacity: 0.6 },
              ]}
            >
              <Text style={[fontSize.base, { color: colors.primary }]}>
                Try again ›
              </Text>
            </Pressable>
          ) : null}
        </View>
        <View style={[styles.panel, { backgroundColor: colors.muted }]}>
          <AnimatedView
            key={phase}
            entering={ENTERING}
            accessibilityRole="alert"
            accessibilityLabel="Trusted device"
            style={[
              styles.card,
              shadow.lg,
              { backgroundColor: colors.popover },
            ]}
          >
            {phase === "code" ? (
              <>
                <View style={styles.cardBody}>
                  <Text style={styles.cardTitle}>
                    suiss Account Verification Code
                  </Text>
                  <Text
                    style={[
                      fontSize["3xl"],
                      {
                        marginTop: 10,
                        marginBottom: 4,
                        fontWeight: "600",
                        letterSpacing: 0.1 * fontSize["3xl"].fontSize,
                        fontVariant: ["tabular-nums"],
                      },
                    ]}
                  >
                    482 913
                  </Text>
                  <Text
                    style={[styles.cardText, { color: colors.mutedForeground }]}
                  >
                    Enter this code on the device you're signing in to.
                  </Text>
                </View>
                <AlertAction primary>OK</AlertAction>
              </>
            ) : null}
            {phase === "deny" ? (
              <>
                <View style={styles.cardBody}>
                  <Text style={styles.cardTitle}>Sign-in blocked</Text>
                  <Text
                    style={[
                      styles.cardText,
                      { marginTop: 4, color: colors.mutedForeground },
                    ]}
                  >
                    If this wasn't you, change your password.
                  </Text>
                </View>
                <AlertAction onPress={() => setPhase("ask")}>
                  Show request again
                </AlertAction>
              </>
            ) : null}
            {phase === "ask" ? (
              <>
                <View
                  style={{
                    paddingHorizontal: 16,
                    paddingTop: 20,
                    paddingBottom: 16,
                  }}
                >
                  <Text style={styles.cardTitle}>
                    suiss Account Sign-in Requested
                  </Text>
                  <Text
                    style={[
                      styles.cardText,
                      { marginTop: 4, color: colors.mutedForeground },
                    ]}
                  >
                    Your suiss Account is being used to sign in to a device near
                    San Francisco.
                  </Text>
                  <View
                    style={[
                      styles.map,
                      {
                        backgroundColor: withAlpha(
                          colors.primary,
                          isDark ? 0.2 : 0.1
                        ),
                      },
                    ]}
                  >
                    <View
                      style={[
                        styles.ring,
                        { backgroundColor: withAlpha(colors.primary, 0.25) },
                      ]}
                    >
                      <View
                        style={[
                          styles.dot,
                          { backgroundColor: colors.primary },
                        ]}
                      />
                    </View>
                  </View>
                </View>
                <AlertAction primary onPress={() => setPhase("code")}>
                  Allow
                </AlertAction>
                <AlertAction onPress={() => setPhase("deny")}>
                  Don't Allow
                </AlertAction>
              </>
            ) : null}
          </AnimatedView>
        </View>
      </KeyboardScrollView>
    </Screen>
  )
}

export { VerifyDevice }
```

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/VerifyDevice.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/Spinner.swift`: see [Spinner](https://ui.suiss.app/docs/components/spinner.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/VerifyDevice.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/components/Spinner.kt`: see [Spinner](https://ui.suiss.app/docs/components/spinner.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 { VerifyDevice } from "@/components/blocks/verify-device"
```

```tsx
<VerifyDevice />
```

`onVerify` receives the code and returns a boolean or a promise; by default it accepts the demo code `482913`. Edit `COPY` to change the text for each phase.

