# Login Passkey

A passwordless sign-in screen that runs a simulated face scan in a dialog, then welcomes the user.

Login Passkey 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/login-passkey

**React**

**Example**

```tsx title="components/blocks/login-passkey.tsx"
"use client"

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

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogTitle,
} from "@/components/ui/dialog"

type Status = "idle" | "scan" | "ok" | "done"

const avatarClass =
  "bg-linear-to-br from-[oklch(0.78_0.1_250)] to-[oklch(0.6_0.15_280)] text-white dark:from-[oklch(0.78_0.1_250)] dark:to-[oklch(0.6_0.15_280)]"

function ScanGlyph({ verified }: { verified: boolean }) {
  return (
    <div className="relative size-18">
      <ScanSmileyIcon
        data-verified={verified ? "" : undefined}
        className="absolute inset-0 size-full animate-[pulse_1.2s_ease-in-out_infinite] text-foreground transition-[opacity,scale] duration-500 data-verified:scale-60 data-verified:animate-none data-verified:opacity-0 motion-reduce:animate-none"
      />
      <span
        data-verified={verified ? "" : undefined}
        className="absolute inset-1.5 flex scale-40 items-center justify-center rounded-full bg-green-500 text-white opacity-0 transition-[opacity,scale] duration-[300ms,550ms] ease-[ease,cubic-bezier(0.3,1.25,0.5,1)] data-verified:scale-100 data-verified:opacity-100"
      >
        <CheckIcon className="size-8" />
      </span>
    </div>
  )
}

export function LoginPasskey() {
  const [status, setStatus] = React.useState<Status>("idle")
  const timers = React.useRef<number[]>([])

  const clear = () => {
    for (const timer of timers.current) window.clearTimeout(timer)
    timers.current = []
  }

  React.useEffect(
    () => () => {
      for (const timer of timers.current) window.clearTimeout(timer)
    },
    []
  )

  const start = () => {
    setStatus("scan")
    timers.current.push(
      window.setTimeout(() => setStatus("ok"), 1500),
      window.setTimeout(() => setStatus("done"), 2400)
    )
  }

  const cancel = () => {
    clear()
    setStatus("idle")
  }

  if (status === "done") {
    return (
      <section className="flex min-h-150 items-center justify-center p-6">
        <div className="flex 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">
          <Avatar className="size-21">
            <AvatarFallback className={`${avatarClass} text-3xl`}>
              JR
            </AvatarFallback>
          </Avatar>
          <h1 className="text-4xl font-semibold tracking-tight md:text-5xl">
            Welcome, Jamie.
          </h1>
          <p className="text-lg text-muted-foreground">
            You signed in with a passkey. No password needed.
          </p>
          <button
            type="button"
            onClick={() => setStatus("idle")}
            className="rounded-xs text-lg text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
          >
            Sign out ›
          </button>
        </div>
      </section>
    )
  }

  return (
    <section className="flex min-h-150 items-center justify-center p-6">
      <div className="flex max-w-140 flex-col items-center text-center">
        <span className="flex size-18 items-center justify-center rounded-full bg-muted text-primary">
          <KeyIcon className="size-8.5" />
        </span>
        <h1 className="mt-5.5 text-4xl font-semibold tracking-tight text-balance md:text-5xl">
          Sign in without a password.
        </h1>
        <p className="mt-3 max-w-120 text-xl text-balance text-muted-foreground">
          With a passkey, your face or fingerprint is all it takes. Nothing to
          remember, nothing to type.
        </p>
        <div className="mt-7 flex items-center gap-2.5 rounded-full bg-muted py-2 ps-2 pe-4">
          <Avatar size="sm" className="size-7">
            <AvatarFallback className={`${avatarClass} text-xs`}>
              JR
            </AvatarFallback>
          </Avatar>
          <span className="text-base">jamie@example.com</span>
          <a href="#change" className="text-sm text-primary hover:underline">
            Change ›
          </a>
        </div>
        <Button size="lg" onClick={start} className="mt-5.5 gap-2">
          <ScanSmileyIcon className="size-4.5" />
          Continue with passkey
        </Button>
        <a
          href="#password"
          className="mt-4.5 text-sm text-primary hover:underline"
        >
          Sign in with password ›
        </a>
        <p className="mt-10 max-w-105 text-xs leading-normal text-muted-foreground">
          Passkeys are end-to-end encrypted and sync across all your devices
          with suiss Keychain.
        </p>
      </div>
      <Dialog
        open={status === "scan" || status === "ok"}
        onOpenChange={(open) => {
          if (!open) cancel()
        }}
      >
        <DialogContent
          showCloseButton={false}
          className="flex w-65 flex-col items-center gap-3.5 rounded-3xl bg-popover/82 px-6 pt-7.5 pb-4.5 text-center backdrop-blur-2xl backdrop-saturate-180 data-open:zoom-in-90 sm:max-w-65"
        >
          <ScanGlyph verified={status === "ok"} />
          <div className="flex flex-col gap-0.75">
            <DialogTitle className="text-lg font-semibold">
              {status === "ok" ? "Verified" : "Face scan"}
            </DialogTitle>
            <DialogDescription className="text-sm text-muted-foreground">
              Passkey for suiss.com
            </DialogDescription>
          </div>
          <DialogClose
            render={
              <Button
                variant="plain"
                data-hidden={status === "ok" ? "" : undefined}
                className="text-base data-hidden:invisible"
              />
            }
          >
            Cancel
          </DialogClose>
        </DialogContent>
      </Dialog>
    </section>
  )
}
```

A passwordless sign-in screen with an account chip, a Continue with passkey button and a fallback link to sign in with a password. Starting the flow opens a small dialog with a scanning animation that turns into a check mark, then the screen welcomes the user. Use it as the front door for passkey or biometric login.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/login-passkey
```

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 login-passkey
```

### Manual

1. Install the dependencies:

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

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Dialog](https://ui.suiss.app/docs/components/dialog).

3. Copy the source into `components/blocks/login-passkey.tsx`:

```tsx title="components/blocks/login-passkey.tsx"
"use client"

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

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogTitle,
} from "@/components/ui/dialog"

type Status = "idle" | "scan" | "ok" | "done"

const avatarClass =
  "bg-linear-to-br from-[oklch(0.78_0.1_250)] to-[oklch(0.6_0.15_280)] text-white dark:from-[oklch(0.78_0.1_250)] dark:to-[oklch(0.6_0.15_280)]"

function ScanGlyph({ verified }: { verified: boolean }) {
  return (
    <div className="relative size-18">
      <ScanSmileyIcon
        data-verified={verified ? "" : undefined}
        className="absolute inset-0 size-full animate-[pulse_1.2s_ease-in-out_infinite] text-foreground transition-[opacity,scale] duration-500 data-verified:scale-60 data-verified:animate-none data-verified:opacity-0 motion-reduce:animate-none"
      />
      <span
        data-verified={verified ? "" : undefined}
        className="absolute inset-1.5 flex scale-40 items-center justify-center rounded-full bg-green-500 text-white opacity-0 transition-[opacity,scale] duration-[300ms,550ms] ease-[ease,cubic-bezier(0.3,1.25,0.5,1)] data-verified:scale-100 data-verified:opacity-100"
      >
        <CheckIcon className="size-8" />
      </span>
    </div>
  )
}

export function LoginPasskey() {
  const [status, setStatus] = React.useState<Status>("idle")
  const timers = React.useRef<number[]>([])

  const clear = () => {
    for (const timer of timers.current) window.clearTimeout(timer)
    timers.current = []
  }

  React.useEffect(
    () => () => {
      for (const timer of timers.current) window.clearTimeout(timer)
    },
    []
  )

  const start = () => {
    setStatus("scan")
    timers.current.push(
      window.setTimeout(() => setStatus("ok"), 1500),
      window.setTimeout(() => setStatus("done"), 2400)
    )
  }

  const cancel = () => {
    clear()
    setStatus("idle")
  }

  if (status === "done") {
    return (
      <section className="flex min-h-150 items-center justify-center p-6">
        <div className="flex 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">
          <Avatar className="size-21">
            <AvatarFallback className={`${avatarClass} text-3xl`}>
              JR
            </AvatarFallback>
          </Avatar>
          <h1 className="text-4xl font-semibold tracking-tight md:text-5xl">
            Welcome, Jamie.
          </h1>
          <p className="text-lg text-muted-foreground">
            You signed in with a passkey. No password needed.
          </p>
          <button
            type="button"
            onClick={() => setStatus("idle")}
            className="rounded-xs text-lg text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
          >
            Sign out ›
          </button>
        </div>
      </section>
    )
  }

  return (
    <section className="flex min-h-150 items-center justify-center p-6">
      <div className="flex max-w-140 flex-col items-center text-center">
        <span className="flex size-18 items-center justify-center rounded-full bg-muted text-primary">
          <KeyIcon className="size-8.5" />
        </span>
        <h1 className="mt-5.5 text-4xl font-semibold tracking-tight text-balance md:text-5xl">
          Sign in without a password.
        </h1>
        <p className="mt-3 max-w-120 text-xl text-balance text-muted-foreground">
          With a passkey, your face or fingerprint is all it takes. Nothing to
          remember, nothing to type.
        </p>
        <div className="mt-7 flex items-center gap-2.5 rounded-full bg-muted py-2 ps-2 pe-4">
          <Avatar size="sm" className="size-7">
            <AvatarFallback className={`${avatarClass} text-xs`}>
              JR
            </AvatarFallback>
          </Avatar>
          <span className="text-base">jamie@example.com</span>
          <a href="#change" className="text-sm text-primary hover:underline">
            Change ›
          </a>
        </div>
        <Button size="lg" onClick={start} className="mt-5.5 gap-2">
          <ScanSmileyIcon className="size-4.5" />
          Continue with passkey
        </Button>
        <a
          href="#password"
          className="mt-4.5 text-sm text-primary hover:underline"
        >
          Sign in with password ›
        </a>
        <p className="mt-10 max-w-105 text-xs leading-normal text-muted-foreground">
          Passkeys are end-to-end encrypted and sync across all your devices
          with suiss Keychain.
        </p>
      </div>
      <Dialog
        open={status === "scan" || status === "ok"}
        onOpenChange={(open) => {
          if (!open) cancel()
        }}
      >
        <DialogContent
          showCloseButton={false}
          className="flex w-65 flex-col items-center gap-3.5 rounded-3xl bg-popover/82 px-6 pt-7.5 pb-4.5 text-center backdrop-blur-2xl backdrop-saturate-180 data-open:zoom-in-90 sm:max-w-65"
        >
          <ScanGlyph verified={status === "ok"} />
          <div className="flex flex-col gap-0.75">
            <DialogTitle className="text-lg font-semibold">
              {status === "ok" ? "Verified" : "Face scan"}
            </DialogTitle>
            <DialogDescription className="text-sm text-muted-foreground">
              Passkey for suiss.com
            </DialogDescription>
          </div>
          <DialogClose
            render={
              <Button
                variant="plain"
                data-hidden={status === "ok" ? "" : undefined}
                className="text-base data-hidden:invisible"
              />
            }
          >
            Cancel
          </DialogClose>
        </DialogContent>
      </Dialog>
    </section>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { LoginPasskey } from "@/components/blocks/login-passkey"
```

```tsx
<LoginPasskey />
```

The scan is faked with timers in `start`; replace it with a WebAuthn call such as `navigator.credentials.get` and move to the welcome state when it resolves. Replace the account email, initials and welcome name, and point the `#change` and `#password` links at your routes.

## Notes

Closing the dialog cancels the pending timers; abort your WebAuthn request the same way when the user cancels.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx title="uniwind/components/blocks/login-passkey.tsx"
import { cn } from "cn"
import { CheckIcon, KeyIcon, ScanSmileyIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, ScrollView, View } from "react-native"
import {
  cancelAnimation,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withSequence,
  withSpring,
  withTiming,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogTitle,
} from "@/components/ui/dialog"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

type Status = "idle" | "scan" | "ok" | "done"

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

function ScanGlyph({ verified }: { verified: boolean }) {
  const pulse = useSharedValue(1)
  const check = useSharedValue(0)

  React.useEffect(() => {
    if (verified) {
      cancelAnimation(pulse)
      pulse.value = withTiming(0, {
        duration: 300,
        reduceMotion: ReduceMotion.System,
      })
      check.value = withSpring(1, {
        damping: 12,
        reduceMotion: ReduceMotion.System,
      })
      return
    }
    check.value = 0
    pulse.value = withRepeat(
      withSequence(
        withTiming(0.4, { duration: 600 }),
        withTiming(1, { duration: 600 })
      ),
      -1,
      false,
      undefined,
      ReduceMotion.System
    )
    return () => cancelAnimation(pulse)
  }, [verified, pulse, check])

  const glyphStyle = useAnimatedStyle(() => ({
    opacity: pulse.value,
    transform: [{ scale: 0.6 + pulse.value * 0.4 }],
  }))

  const checkStyle = useAnimatedStyle(() => ({
    opacity: check.value,
    transform: [{ scale: 0.4 + check.value * 0.6 }],
  }))

  return (
    <View className="size-18">
      <AnimatedView
        style={glyphStyle}
        className="absolute inset-0 items-center justify-center"
      >
        <Icon as={ScanSmileyIcon} weight="light" className="size-18" />
      </AnimatedView>
      <AnimatedView
        style={checkStyle}
        className="absolute inset-1.5 items-center justify-center rounded-full bg-green-500"
      >
        <Icon
          as={CheckIcon}
          weight="bold"
          className="size-8 text-primary-foreground"
        />
      </AnimatedView>
    </View>
  )
}

function LoginPasskey({
  onChangeAccount,
  onUsePassword,
  onContinue,
}: {
  onChangeAccount?: () => void
  onUsePassword?: () => void
  onContinue?: () => void
}) {
  const [status, setStatus] = React.useState<Status>("idle")

  React.useEffect(() => {
    if (status !== "scan" && status !== "ok") return
    const timer = setTimeout(
      () => setStatus(status === "scan" ? "ok" : "done"),
      status === "scan" ? 1500 : 900
    )
    return () => clearTimeout(timer)
  }, [status])

  if (status === "done") {
    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"
          >
            <Avatar size="xl" className="size-21">
              <AvatarFallback className="bg-indigo-500">
                <Text className="text-3xl">JR</Text>
              </AvatarFallback>
            </Avatar>
            <Text role="heading" className="text-center text-3xl font-semibold">
              Welcome, Jamie.
            </Text>
            <Text className="text-center text-lg text-muted-foreground">
              You signed in with a passkey. No password needed.
            </Text>
            <LinkText className="text-lg" onPress={() => setStatus("idle")}>
              Sign out ›
            </LinkText>
          </AnimatedView>
        </View>
      </Screen>
    )
  }

  return (
    <Screen>
      <ScrollView contentContainerClassName="grow items-center justify-center px-6 py-8">
        <View className="size-18 items-center justify-center rounded-full bg-muted">
          <Icon as={KeyIcon} weight="bold" className="size-8.5 text-primary" />
        </View>
        <Text
          role="heading"
          className="mt-5.5 text-center text-3xl font-semibold"
        >
          Sign in without a password.
        </Text>
        <Text className="mt-3 text-center text-xl text-muted-foreground">
          With a passkey, your face or fingerprint is all it takes. Nothing to
          remember, nothing to type.
        </Text>
        <View className="mt-7 flex-row items-center gap-2.5 rounded-full bg-muted py-2 ps-2 pe-4">
          <Avatar size="sm" className="size-7">
            <AvatarFallback className="bg-indigo-500">JR</AvatarFallback>
          </Avatar>
          <Text className="shrink text-base" numberOfLines={1}>
            jamie@example.com
          </Text>
          <LinkText className="text-sm" onPress={onChangeAccount}>
            Change ›
          </LinkText>
        </View>
        <Button
          size="lg"
          onPress={() => {
            onContinue?.()
            setStatus("scan")
          }}
          className="mt-5.5"
        >
          <Icon
            as={ScanSmileyIcon}
            className="size-4.5 text-primary-foreground"
          />
          <Text>Continue with passkey</Text>
        </Button>
        <View className="mt-4.5">
          <LinkText className="text-sm" onPress={onUsePassword}>
            Sign in with password ›
          </LinkText>
        </View>
        <Text className="mt-10 text-center text-xs text-muted-foreground">
          Passkeys are end-to-end encrypted and sync across all your devices
          with suiss Keychain.
        </Text>
      </ScrollView>
      <Dialog
        open={status === "scan" || status === "ok"}
        onOpenChange={(open) => {
          if (!open) setStatus("idle")
        }}
      >
        <DialogContent
          showCloseButton={false}
          className="w-65 items-center gap-3.5 self-center rounded-3xl px-6 pt-7.5 pb-4.5"
        >
          <ScanGlyph verified={status === "ok"} />
          <View className="items-center gap-0.75">
            <DialogTitle className="text-center text-lg font-semibold">
              {status === "ok" ? "Verified" : "Face scan"}
            </DialogTitle>
            <DialogDescription className="text-center text-sm text-muted-foreground">
              Passkey for suiss.com
            </DialogDescription>
          </View>
          <DialogClose
            disabled={status === "ok"}
            render={
              <Button
                variant="plain"
                className={cn(status === "ok" && "opacity-0")}
              />
            }
          >
            <Text className="text-base">Cancel</Text>
          </DialogClose>
        </DialogContent>
      </Dialog>
    </Screen>
  )
}

export { LoginPasskey }
```

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

public struct LoginPasskey: View {
  enum Status: Sendable {
    case idle, scan, ok, done
  }

  private var onChangeAccount: (() -> Void)?
  private var onUsePassword: (() -> Void)?
  private var onContinue: (() -> Void)?
  @State private var status: Status
  @State private var task: Task<Void, Never>?
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onContinue: (() -> Void)? = nil,
    onChangeAccount: (() -> Void)? = nil,
    onUsePassword: (() -> Void)? = nil
  ) {
    self.init(status: .idle)
    self.onContinue = onContinue
    self.onChangeAccount = onChangeAccount
    self.onUsePassword = onUsePassword
  }

  init(status: Status) {
    _status = State(initialValue: status)
  }

  public var body: some View {
    Screen {
      if status == .done {
        welcome
          .transition(.opacity.combined(with: .offset(y: 18)))
      } else {
        intro
      }
    }
    .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.6), value: status == .done)
    .dialog(isPresented: scanning) {
      LoginPasskeyScan(verified: status == .ok, onCancel: cancel)
    }
    .onDisappear { task?.cancel() }
  }

  private var scanning: Binding<Bool> {
    Binding(
      get: { status == .scan || status == .ok },
      set: { if !$0 { cancel() } }
    )
  }

  private var intro: some View {
    VStack(spacing: 0) {
      KeyboardScrollView {
        VStack(spacing: 0) {
          Ph.key.bold
            .frame(width: 34, height: 34)
            .foregroundStyle(EsuissColor.primary)
            .frame(width: 72, height: 72)
            .background(EsuissColor.muted, in: Circle())
            .accessibilityHidden(true)
          Text("Sign in without a password.")
            .scaledTextStyle(.xl2, weight: .bold)
            .padding(.top, 22)
          Text("With a passkey, your face or fingerprint is all it takes. Nothing to remember, nothing to type.")
            .scaledTextStyle(.lg)
            .foregroundStyle(EsuissColor.mutedForeground)
            .padding(.top, 12)
          account
            .padding(.top, 28)
          Button(action: start) {
            Label {
              Text("Continue with passkey")
            } icon: {
              Ph.scanSmiley.regular
            }
          }
          .buttonStyle(.variant(.default, size: .lg))
          .padding(.top, 22)
          Button("Sign in with password ›") { onUsePassword?() }
            .buttonStyle(LoginPasskeyLinkStyle())
            .padding(.top, 10)
          Text("Passkeys are end-to-end encrypted and sync across all your devices with suiss Keychain.")
            .scaledTextStyle(.xs)
            .foregroundStyle(EsuissColor.mutedForeground)
            .padding(.top, 40)
        }
        .multilineTextAlignment(.center)
        .fixedSize(horizontal: false, vertical: true)
        .frame(maxWidth: .infinity)
        .padding(.horizontal, 24)
        .padding(.top, 72)
        .padding(.bottom, 24)
      }
    }
  }

  private var account: some View {
    HStack(spacing: 10) {
      Avatar(size: .sm) {
        LoginPasskeyInitials(style: .xs)
      }
      .frame(width: 28, height: 28)
      Text(verbatim: "jamie@example.com")
        .scaledFont(.base)
        .foregroundStyle(EsuissColor.foreground)
        .lineLimit(1)
      Button("Change ›") { onChangeAccount?() }
        .buttonStyle(LoginPasskeyLinkStyle())
    }
    .padding(.leading, 8)
    .padding(.trailing, 16)
    .padding(.vertical, 2)
    .background(EsuissColor.muted, in: Capsule())
  }

  private var welcome: some View {
    VStack(spacing: 14) {
      Avatar(size: .xl) {
        LoginPasskeyInitials(style: .xl2)
      }
      Text("Welcome, Jamie.")
        .scaledTextStyle(.xl3, weight: .bold)
      Text("You signed in with a passkey. No password needed.")
        .scaledTextStyle(.lg)
        .foregroundStyle(EsuissColor.mutedForeground)
      Button("Sign out ›") { status = .idle }
        .buttonStyle(LoginPasskeyLinkStyle(style: .lg))
    }
    .multilineTextAlignment(.center)
    .fixedSize(horizontal: false, vertical: true)
    .padding(24)
    .frame(maxWidth: .infinity, maxHeight: .infinity)
  }

  private func start() {
    onContinue?()
    status = .scan
    task?.cancel()
    task = Task {
      try? await Task.sleep(for: .milliseconds(1500))
      guard !Task.isCancelled else { return }
      status = .ok
      try? await Task.sleep(for: .milliseconds(900))
      guard !Task.isCancelled else { return }
      status = .done
    }
  }

  private func cancel() {
    task?.cancel()
    task = nil
    if status != .done { status = .idle }
  }
}

struct LoginPasskeyScan: View {
  let verified: Bool
  let onCancel: () -> Void

  var body: some View {
    DialogContent(showCloseButton: false) {
      VStack(spacing: 14) {
        LoginPasskeyGlyph(verified: verified)
        VStack(spacing: 3) {
          DialogTitle(verified ? "Verified" : "Face scan")
          DialogDescription("Passkey for suiss.com")
        }
        .multilineTextAlignment(.center)
        Button("Cancel", action: onCancel)
          .buttonStyle(.variant(.plain))
          .opacity(verified ? 0 : 1)
          .disabled(verified)
      }
      .frame(width: 212)
      .frame(maxWidth: .infinity)
    }
    .frame(width: 260)
  }
}

private struct LoginPasskeyGlyph: View {
  let verified: Bool

  @State private var pulse = false
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  var body: some View {
    ZStack {
      Ph.scanSmiley.light
        .foregroundStyle(EsuissColor.foreground)
        .opacity(verified ? 0 : pulse ? 0.5 : 1)
        .scaleEffect(verified ? 0.6 : 1)
      Ph.check.bold
        .frame(width: 32, height: 32)
        .foregroundStyle(EsuissColor.primaryForeground)
        .frame(width: 60, height: 60)
        .background(Palette.green500, in: Circle())
        .scaleEffect(verified ? 1 : 0.4)
        .opacity(verified ? 1 : 0)
    }
    .frame(width: 72, height: 72)
    .animation(reduceMotion ? nil : .spring(response: 0.55, dampingFraction: 0.6), value: verified)
    .onAppear {
      guard !reduceMotion else { return }
      withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true)) { pulse = true }
    }
    .accessibilityHidden(true)
  }
}

private struct LoginPasskeyInitials: View {
  let style: TextStyle

  var body: some View {
    Text(verbatim: "JR")
      .scaledFont(style, weight: .semibold)
      .foregroundStyle(EsuissColor.primaryForeground)
      .frame(maxWidth: .infinity, maxHeight: .infinity)
      .background(
        Palette.indigo500,
        in: Circle()
      )
  }
}

private struct LoginPasskeyLinkStyle: 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/LoginPasskey.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
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.Row
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.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
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.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.Role
import androidx.compose.ui.semantics.heading
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.Light
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.bold.Check
import com.adamglin.phosphoricons.bold.Key
import com.adamglin.phosphoricons.light.ScanSmiley
import com.adamglin.phosphoricons.regular.ScanSmiley
import com.esuiss.ui.components.Avatar
import com.esuiss.ui.components.AvatarSize
import com.esuiss.ui.components.BottomBar
import com.esuiss.ui.components.BottomBarContent
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Dialog
import com.esuiss.ui.components.DialogDescription
import com.esuiss.ui.components.DialogTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.LocalContentStyle
import com.esuiss.ui.components.Screen
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.delay

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

private val PopEasing = CubicBezierEasing(0.3f, 1.25f, 0.5f, 1f)

private enum class PasskeyStatus { Idle, Scan, Ok, Done }

@Composable
fun LoginPasskey(
    modifier: Modifier = Modifier,
    onContinue: () -> Unit = {},
    onChangeAccount: () -> Unit = {},
    onUsePassword: () -> Unit = {},
) {
    val colors = EsuissTheme.colors
    var status by rememberSaveable { mutableStateOf(PasskeyStatus.Idle) }

    LaunchedEffect(status) {
        when (status) {
            PasskeyStatus.Scan -> {
                delay(1500)
                status = PasskeyStatus.Ok
            }
            PasskeyStatus.Ok -> {
                delay(900)
                status = PasskeyStatus.Done
            }
            else -> Unit
        }
    }

    Screen(modifier) {
        if (status == PasskeyStatus.Done) {
            Welcome { status = PasskeyStatus.Idle }
            return@Screen
        }
        Column(Modifier.fillMaxSize()) {
            BoxWithConstraints(Modifier.weight(1f)) {
                val height = maxHeight
                Column(
                    modifier = Modifier
                        .fillMaxSize()
                        .verticalScroll(rememberScrollState()),
                ) {
                    Column(
                        modifier = Modifier
                            .fillMaxWidth()
                            .heightIn(min = height)
                            .padding(24.dp),
                        verticalArrangement = Arrangement.Center,
                        horizontalAlignment = Alignment.CenterHorizontally,
                    ) {
                        Box(
                            Modifier
                                .size(72.dp)
                                .background(colors.muted, CircleShape),
                            contentAlignment = Alignment.Center,
                        ) {
                            Icon(PhosphorIcons.Bold.Key, contentDescription = null, size = 34.dp, tint = colors.primary)
                        }
                        Spacer(Modifier.height(22.dp))
                        BasicText(
                            "Sign in without a password.",
                            modifier = Modifier.semantics { heading() },
                            style = Typography.xl3.copy(
                                color = colors.foreground,
                                fontWeight = FontWeight.SemiBold,
                                textAlign = TextAlign.Center,
                            ),
                        )
                        Spacer(Modifier.height(12.dp))
                        BasicText(
                            "With a passkey, your face or fingerprint is all it takes. Nothing to remember, nothing to type.",
                            modifier = Modifier.widthIn(max = 480.dp),
                            style = Typography.lg.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                        )
                        Spacer(Modifier.height(28.dp))
                        AccountPill(onChangeAccount)
                        Spacer(Modifier.height(40.dp))
                        BasicText(
                            "Passkeys are end-to-end encrypted and sync across all your devices with suiss Keychain.",
                            modifier = Modifier.widthIn(max = 420.dp),
                            style = Typography.xs.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                        )
                    }
                }
            }
            BottomBar {
                BottomBarContent {
                    Button(
                        onClick = {
                            onContinue()
                            status = PasskeyStatus.Scan
                        },
                        size = ButtonSize.Lg,
                        block = true,
                    ) {
                        Icon(PhosphorIcons.Regular.ScanSmiley, contentDescription = null, size = 18.dp)
                        BasicText("Continue with passkey", style = LocalContentStyle.current)
                    }
                    Button("Sign in with password ›", onUsePassword, variant = ButtonVariant.Plain)
                }
            }
        }
        val scanning = status == PasskeyStatus.Scan || status == PasskeyStatus.Ok
        Dialog(
            open = scanning,
            onOpenChange = { if (!it) status = PasskeyStatus.Idle },
            modifier = Modifier.width(260.dp),
            showCloseButton = false,
        ) {
            Column(
                modifier = Modifier.fillMaxWidth(),
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.spacedBy(14.dp),
            ) {
                ScanGlyph(verified = status == PasskeyStatus.Ok)
                Column(
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.spacedBy(3.dp),
                ) {
                    DialogTitle(if (status == PasskeyStatus.Ok) "Verified" else "Face scan")
                    DialogDescription("Passkey for suiss.com")
                }
                Button(
                    "Cancel",
                    { status = PasskeyStatus.Idle },
                    modifier = Modifier.graphicsLayer { alpha = if (status == PasskeyStatus.Ok) 0f else 1f },
                    variant = ButtonVariant.Plain,
                    enabled = status != PasskeyStatus.Ok,
                )
            }
        }
    }
}

@Composable
private fun AccountPill(onChange: () -> Unit) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Row(
        modifier = Modifier
            .background(colors.muted, CircleShape)
            .padding(start = 8.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
        horizontalArrangement = Arrangement.spacedBy(10.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        GradientAvatar("JR", Modifier.size(28.dp), AvatarSize.Sm)
        BasicText("jamie@example.com", style = Typography.base.copy(color = colors.foreground))
        BasicText(
            "Change ›",
            modifier = Modifier
                .graphicsLayer { alpha = if (pressed) 0.8f else 1f }
                .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onChange),
            style = Typography.sm.copy(color = colors.primary),
        )
    }
}

@Composable
private fun GradientAvatar(initials: String, modifier: Modifier, size: AvatarSize) {
    val colors = EsuissTheme.colors
    Avatar(
        modifier = modifier,
        size = size,
        fallback = {
            Box(
                Modifier
                    .fillMaxSize()
                    .background(Palette.indigo500, CircleShape),
                contentAlignment = Alignment.Center,
            ) {
                BasicText(
                    initials,
                    style = (if (size == AvatarSize.Sm) Typography.xs else Typography.xl3).copy(
                        color = colors.primaryForeground,
                        fontWeight = FontWeight.SemiBold,
                    ),
                )
            }
        },
    )
}

@Composable
private fun ScanGlyph(verified: Boolean) {
    val colors = EsuissTheme.colors
    val pulse = rememberInfiniteTransition(label = "pulse")
    val breathe by pulse.animateFloat(
        initialValue = 1f,
        targetValue = 0.5f,
        animationSpec = infiniteRepeatable(tween(600), RepeatMode.Reverse),
        label = "breathe",
    )
    val glyph by animateFloatAsState(if (verified) 0f else 1f, tween(500), label = "glyph")
    val check by animateFloatAsState(if (verified) 1f else 0f, tween(550, easing = PopEasing), label = "check")
    Box(Modifier.size(72.dp), contentAlignment = Alignment.Center) {
        Icon(
            PhosphorIcons.Light.ScanSmiley,
            contentDescription = null,
            modifier = Modifier.graphicsLayer {
                alpha = glyph * (if (verified) 1f else breathe)
                val scale = 0.6f + 0.4f * glyph
                scaleX = scale
                scaleY = scale
            },
            size = 72.dp,
            tint = colors.foreground,
        )
        Box(
            Modifier
                .size(60.dp)
                .graphicsLayer {
                    alpha = check.coerceIn(0f, 1f)
                    val scale = 0.4f + 0.6f * check
                    scaleX = scale
                    scaleY = scale
                }
                .background(Palette.green500, CircleShape),
            contentAlignment = Alignment.Center,
        ) {
            Icon(PhosphorIcons.Bold.Check, contentDescription = null, size = 32.dp, tint = colors.primaryForeground)
        }
    }
}

@Composable
private fun Welcome(onSignOut: () -> Unit) {
    val colors = EsuissTheme.colors
    val appear = remember { Animatable(0f) }
    LaunchedEffect(Unit) { appear.animateTo(1f, tween(1000, easing = RevealEasing)) }
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    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,
    ) {
        GradientAvatar("JR", Modifier.size(84.dp), AvatarSize.Xl)
        BasicText(
            "Welcome, Jamie.",
            modifier = Modifier.semantics { heading() },
            style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center),
        )
        BasicText(
            "You signed in with a passkey. No password needed.",
            style = Typography.lg.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
        )
        BasicText(
            "Sign out ›",
            modifier = Modifier
                .graphicsLayer { alpha = if (pressed) 0.8f else 1f }
                .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onSignOut),
            style = Typography.lg.copy(color = colors.primary),
        )
    }
}
```

A sign-in screen that shows the account with an `Avatar`, a Change link, a Continue with passkey `Button` and a link to sign in with a password. Continuing opens a `Dialog` with an animated scan glyph that turns into a verified state, then shows a welcome view with a Sign out link. The dialog can be cancelled during the scan.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/login-passkey
```

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 login-passkey
```

**SwiftUI**

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

**Jetpack Compose**

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

The CLI also adds what it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Dialog](https://ui.suiss.app/docs/components/dialog), [Icon](https://ui.suiss.app/docs/components/icon), [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: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Dialog](https://ui.suiss.app/docs/components/dialog), [Icon](https://ui.suiss.app/docs/components/icon), [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/login-passkey.tsx`:

- `uniwind/components/blocks/login-passkey.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: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Dialog](https://ui.suiss.app/docs/components/dialog), [Icon](https://ui.suiss.app/docs/components/icon), [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/login-passkey.tsx`:

```tsx title="nativewind/components/blocks/login-passkey.tsx"
import { cn } from "cn"
import { CheckIcon, KeyIcon, ScanSmileyIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, ScrollView, View } from "react-native"
import {
  cancelAnimation,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withSequence,
  withSpring,
  withTiming,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogTitle,
} from "@/components/ui/dialog"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

type Status = "idle" | "scan" | "ok" | "done"

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

function ScanGlyph({ verified }: { verified: boolean }) {
  const pulse = useSharedValue(1)
  const check = useSharedValue(0)

  React.useEffect(() => {
    if (verified) {
      cancelAnimation(pulse)
      pulse.value = withTiming(0, {
        duration: 300,
        reduceMotion: ReduceMotion.System,
      })
      check.value = withSpring(1, {
        damping: 12,
        reduceMotion: ReduceMotion.System,
      })
      return
    }
    check.value = 0
    pulse.value = withRepeat(
      withSequence(
        withTiming(0.4, { duration: 600 }),
        withTiming(1, { duration: 600 })
      ),
      -1,
      false,
      undefined,
      ReduceMotion.System
    )
    return () => cancelAnimation(pulse)
  }, [verified, pulse, check])

  const glyphStyle = useAnimatedStyle(() => ({
    opacity: pulse.value,
    transform: [{ scale: 0.6 + pulse.value * 0.4 }],
  }))

  const checkStyle = useAnimatedStyle(() => ({
    opacity: check.value,
    transform: [{ scale: 0.4 + check.value * 0.6 }],
  }))

  return (
    <View className="size-[4.5rem]">
      <AnimatedView
        style={glyphStyle}
        className="absolute inset-0 items-center justify-center"
      >
        <Icon as={ScanSmileyIcon} weight="light" className="size-[4.5rem]" />
      </AnimatedView>
      <AnimatedView
        style={checkStyle}
        className="absolute inset-1.5 items-center justify-center rounded-full bg-green-500"
      >
        <Icon
          as={CheckIcon}
          weight="bold"
          className="size-8 text-primary-foreground"
        />
      </AnimatedView>
    </View>
  )
}

function LoginPasskey({
  onChangeAccount,
  onUsePassword,
  onContinue,
}: {
  onChangeAccount?: () => void
  onUsePassword?: () => void
  onContinue?: () => void
}) {
  const [status, setStatus] = React.useState<Status>("idle")

  React.useEffect(() => {
    if (status !== "scan" && status !== "ok") return
    const timer = setTimeout(
      () => setStatus(status === "scan" ? "ok" : "done"),
      status === "scan" ? 1500 : 900
    )
    return () => clearTimeout(timer)
  }, [status])

  if (status === "done") {
    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"
          >
            <Avatar size="xl" className="size-[5.25rem]">
              <AvatarFallback className="bg-indigo-500">
                <Text className="text-3xl">JR</Text>
              </AvatarFallback>
            </Avatar>
            <Text role="heading" className="text-center text-3xl font-semibold">
              Welcome, Jamie.
            </Text>
            <Text className="text-center text-lg text-muted-foreground">
              You signed in with a passkey. No password needed.
            </Text>
            <LinkText className="text-lg" onPress={() => setStatus("idle")}>
              Sign out ›
            </LinkText>
          </AnimatedView>
        </View>
      </Screen>
    )
  }

  return (
    <Screen>
      <ScrollView contentContainerClassName="grow items-center justify-center px-6 py-8">
        <View className="size-[4.5rem] items-center justify-center rounded-full bg-muted">
          <Icon
            as={KeyIcon}
            weight="bold"
            className="size-[2.125rem] text-primary"
          />
        </View>
        <Text
          role="heading"
          className="mt-[1.375rem] text-center text-3xl font-semibold"
        >
          Sign in without a password.
        </Text>
        <Text className="mt-3 text-center text-xl text-muted-foreground">
          With a passkey, your face or fingerprint is all it takes. Nothing to
          remember, nothing to type.
        </Text>
        <View className="mt-7 flex-row items-center gap-2.5 rounded-full bg-muted py-2 ps-2 pe-4">
          <Avatar size="sm" className="size-7">
            <AvatarFallback className="bg-indigo-500">JR</AvatarFallback>
          </Avatar>
          <Text className="shrink text-base" numberOfLines={1}>
            jamie@example.com
          </Text>
          <LinkText className="text-sm" onPress={onChangeAccount}>
            Change ›
          </LinkText>
        </View>
        <Button
          size="lg"
          onPress={() => {
            onContinue?.()
            setStatus("scan")
          }}
          className="mt-[1.375rem]"
        >
          <Icon
            as={ScanSmileyIcon}
            className="size-[1.125rem] text-primary-foreground"
          />
          <Text>Continue with passkey</Text>
        </Button>
        <View className="mt-[1.125rem]">
          <LinkText className="text-sm" onPress={onUsePassword}>
            Sign in with password ›
          </LinkText>
        </View>
        <Text className="mt-10 text-center text-xs text-muted-foreground">
          Passkeys are end-to-end encrypted and sync across all your devices
          with suiss Keychain.
        </Text>
      </ScrollView>
      <Dialog
        open={status === "scan" || status === "ok"}
        onOpenChange={(open) => {
          if (!open) setStatus("idle")
        }}
      >
        <DialogContent
          showCloseButton={false}
          className="w-[16.25rem] items-center gap-3.5 self-center rounded-3xl px-6 pt-[1.875rem] pb-[1.125rem]"
        >
          <ScanGlyph verified={status === "ok"} />
          <View className="items-center gap-[0.1875rem]">
            <DialogTitle className="text-center text-lg font-semibold">
              {status === "ok" ? "Verified" : "Face scan"}
            </DialogTitle>
            <DialogDescription className="text-center text-sm text-muted-foreground">
              Passkey for suiss.com
            </DialogDescription>
          </View>
          <DialogClose
            disabled={status === "ok"}
            render={
              <Button
                variant="plain"
                className={cn(status === "ok" && "opacity-0")}
              />
            }
          >
            <Text className="text-base">Cancel</Text>
          </DialogClose>
        </DialogContent>
      </Dialog>
    </Screen>
  )
}

export { LoginPasskey }
```

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: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Dialog](https://ui.suiss.app/docs/components/dialog), [Icon](https://ui.suiss.app/docs/components/icon), [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/login-passkey.tsx`:

```tsx title="stylesheet/components/blocks/login-passkey.tsx"
import { CheckIcon, KeyIcon, ScanSmileyIcon } from "phosphor-react-native"
import * as React from "react"
import {
  Pressable,
  ScrollView,
  StyleSheet,
  type TextStyle,
  View,
} from "react-native"
import {
  cancelAnimation,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withSequence,
  withSpring,
  withTiming,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogTitle,
} from "@/components/ui/dialog"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { fontSize, palette, radius, useTheme } from "@/components/ui/theme"

type Status = "idle" | "scan" | "ok" | "done"

const styles = StyleSheet.create({
  center: {
    flex: 1,
    alignItems: "center",
    justifyContent: "center",
    padding: 24,
  },
  done: { alignItems: "center", gap: 14 },
  heading: { ...fontSize["3xl"], textAlign: "center", fontWeight: "600" },
  glyph: { width: 72, height: 72 },
  glyphLayer: {
    position: "absolute",
    top: 0,
    right: 0,
    bottom: 0,
    left: 0,
    alignItems: "center",
    justifyContent: "center",
  },
  check: {
    position: "absolute",
    top: 6,
    right: 6,
    bottom: 6,
    left: 6,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
  },
  scroll: {
    flexGrow: 1,
    alignItems: "center",
    justifyContent: "center",
    paddingHorizontal: 24,
    paddingVertical: 32,
  },
  key: {
    width: 72,
    height: 72,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
  },
  account: {
    marginTop: 28,
    flexDirection: "row",
    alignItems: "center",
    gap: 10,
    borderRadius: radius.full,
    paddingVertical: 8,
    paddingStart: 8,
    paddingEnd: 16,
  },
  dialog: {
    width: 260,
    alignItems: "center",
    gap: 14,
    alignSelf: "center",
    borderRadius: radius["3xl"],
    paddingHorizontal: 24,
    paddingTop: 30,
    paddingBottom: 18,
  },
})

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

function ScanGlyph({ verified }: { verified: boolean }) {
  const { colors } = useTheme()
  const pulse = useSharedValue(1)
  const check = useSharedValue(0)

  React.useEffect(() => {
    if (verified) {
      cancelAnimation(pulse)
      pulse.value = withTiming(0, {
        duration: 300,
        reduceMotion: ReduceMotion.System,
      })
      check.value = withSpring(1, {
        damping: 12,
        reduceMotion: ReduceMotion.System,
      })
      return
    }
    check.value = 0
    pulse.value = withRepeat(
      withSequence(
        withTiming(0.4, { duration: 600 }),
        withTiming(1, { duration: 600 })
      ),
      -1,
      false,
      undefined,
      ReduceMotion.System
    )
    return () => cancelAnimation(pulse)
  }, [verified, pulse, check])

  const glyphStyle = useAnimatedStyle(() => ({
    opacity: pulse.value,
    transform: [{ scale: 0.6 + pulse.value * 0.4 }],
  }))

  const checkStyle = useAnimatedStyle(() => ({
    opacity: check.value,
    transform: [{ scale: 0.4 + check.value * 0.6 }],
  }))

  return (
    <View style={styles.glyph}>
      <AnimatedView style={[styles.glyphLayer, glyphStyle]}>
        <Icon as={ScanSmileyIcon} weight="light" size={72} />
      </AnimatedView>
      <AnimatedView
        style={[
          styles.check,
          { backgroundColor: palette.green500 },
          checkStyle,
        ]}
      >
        <Icon
          as={CheckIcon}
          weight="bold"
          size={32}
          color={colors.primaryForeground}
        />
      </AnimatedView>
    </View>
  )
}

function LoginPasskey({
  onChangeAccount,
  onUsePassword,
  onContinue,
}: {
  onChangeAccount?: () => void
  onUsePassword?: () => void
  onContinue?: () => void
}) {
  const { colors } = useTheme()
  const [status, setStatus] = React.useState<Status>("idle")

  React.useEffect(() => {
    if (status !== "scan" && status !== "ok") return
    const timer = setTimeout(
      () => setStatus(status === "scan" ? "ok" : "done"),
      status === "scan" ? 1500 : 900
    )
    return () => clearTimeout(timer)
  }, [status])

  if (status === "done") {
    return (
      <Screen>
        <View style={styles.center}>
          <AnimatedView
            entering={FadeInDown.duration(600).reduceMotion(
              ReduceMotion.System
            )}
            style={styles.done}
          >
            <Avatar size="xl" style={{ width: 84, height: 84 }}>
              <AvatarFallback style={{ backgroundColor: palette.indigo500 }}>
                <Text style={fontSize["3xl"]}>JR</Text>
              </AvatarFallback>
            </Avatar>
            <Text role="heading" style={styles.heading}>
              Welcome, Jamie.
            </Text>
            <Text
              style={{
                ...fontSize.lg,
                textAlign: "center",
                color: colors.mutedForeground,
              }}
            >
              You signed in with a passkey. No password needed.
            </Text>
            <LinkText style={fontSize.lg} onPress={() => setStatus("idle")}>
              Sign out ›
            </LinkText>
          </AnimatedView>
        </View>
      </Screen>
    )
  }

  return (
    <Screen>
      <ScrollView contentContainerStyle={styles.scroll}>
        <View style={[styles.key, { backgroundColor: colors.muted }]}>
          <Icon as={KeyIcon} weight="bold" size={34} color={colors.primary} />
        </View>
        <Text role="heading" style={[styles.heading, { marginTop: 22 }]}>
          Sign in without a password.
        </Text>
        <Text
          style={{
            ...fontSize.xl,
            marginTop: 12,
            textAlign: "center",
            color: colors.mutedForeground,
          }}
        >
          With a passkey, your face or fingerprint is all it takes. Nothing to
          remember, nothing to type.
        </Text>
        <View style={[styles.account, { backgroundColor: colors.muted }]}>
          <Avatar size="sm" style={{ width: 28, height: 28 }}>
            <AvatarFallback style={{ backgroundColor: palette.indigo500 }}>
              JR
            </AvatarFallback>
          </Avatar>
          <Text style={{ ...fontSize.base, flexShrink: 1 }} numberOfLines={1}>
            jamie@example.com
          </Text>
          <LinkText style={fontSize.sm} onPress={onChangeAccount}>
            Change ›
          </LinkText>
        </View>
        <Button
          size="lg"
          onPress={() => {
            onContinue?.()
            setStatus("scan")
          }}
          style={{ marginTop: 22 }}
        >
          <Icon
            as={ScanSmileyIcon}
            size={18}
            color={colors.primaryForeground}
          />
          <Text>Continue with passkey</Text>
        </Button>
        <View style={{ marginTop: 18 }}>
          <LinkText style={fontSize.sm} onPress={onUsePassword}>
            Sign in with password ›
          </LinkText>
        </View>
        <Text
          style={{
            ...fontSize.xs,
            marginTop: 40,
            textAlign: "center",
            color: colors.mutedForeground,
          }}
        >
          Passkeys are end-to-end encrypted and sync across all your devices
          with suiss Keychain.
        </Text>
      </ScrollView>
      <Dialog
        open={status === "scan" || status === "ok"}
        onOpenChange={(open) => {
          if (!open) setStatus("idle")
        }}
      >
        <DialogContent showCloseButton={false} style={styles.dialog}>
          <ScanGlyph verified={status === "ok"} />
          <View style={{ alignItems: "center", gap: 3 }}>
            <DialogTitle
              style={{ ...fontSize.lg, textAlign: "center", fontWeight: "600" }}
            >
              {status === "ok" ? "Verified" : "Face scan"}
            </DialogTitle>
            <DialogDescription
              style={{
                ...fontSize.sm,
                textAlign: "center",
                color: colors.mutedForeground,
              }}
            >
              Passkey for suiss.com
            </DialogDescription>
          </View>
          <DialogClose
            disabled={status === "ok"}
            render={
              <Button
                variant="plain"
                style={status === "ok" ? { opacity: 0 } : undefined}
              />
            }
          >
            <Text style={fontSize.base}>Cancel</Text>
          </DialogClose>
        </DialogContent>
      </Dialog>
    </Screen>
  )
}

export { LoginPasskey }
```

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

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

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

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

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

- `swift/Components/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

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

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

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

- `kotlin/components/BottomBar.kt`: see [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar.md)

- `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/Dialog.kt`: see [Dialog](https://ui.suiss.app/docs/components/dialog.md)

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

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

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

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

## Usage

```tsx
import { LoginPasskey } from "@/components/blocks/login-passkey"
```

```tsx
<LoginPasskey />
```

Call your passkey API from `onContinue`, and wire `onChangeAccount` and `onUsePassword` to your navigation. The account details and the scan timing are sample values in the component.

