suiss/ui
  • Docs
  • Components
  • Patterns
  • Interactions
  • Charts
  • Blocks
GitHubGet started
DocsComponentsPatternsInteractionsChartsBlocks
Components82
  • Overview
  • Accordion
  • Alert
  • Alert Dialog
  • Alert Sheet
  • Amount Input
  • Aspect Ratio
  • Attachment
  • Avatar
  • Badge
  • Bottom Bar
  • Breadcrumb
  • Bubble
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Chip
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Dialog
  • Direction
  • Drawer
  • Dropdown Menu
  • Empty
  • FAB
  • Field
  • Fullscreen Menu
  • Haptics
  • Header
  • Hover Card
  • Icon
  • Input
  • Input Accessory Bar
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Keyboard Scroll View
  • Keypad
  • Label
  • Marker
  • Menubar
  • Message
  • Message Scroller
  • Native Select
  • Navigation Menu
  • Page Indicator
  • Pagination
  • Pin Pad
  • Popover
  • Progress
  • QR Scanner
  • Questionnaire
  • Radio Group
  • Resizable
  • Screen
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Signature Pad
  • Skeleton
  • Slider
  • Spinner
  • Switch
  • Tab Bar
  • Table
  • Tabs
  • Text
  • Textarea
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip
  • Wheel Picker
  • Widget

suiss/ui · Component

Pin Pad

A passcode entry screen element with a row of dots and a numeric keypad.

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

iOS
Pin Pad on iOS in light modePin Pad on iOS in dark mode
import { PinPad } from "@/components/ui/pin-pad"

export function PinPadExample() {
  return (
    <PinPad />
  )
}
import EsuissUI
import SwiftUI

struct PinPadDemo: View {
  let length: Int
  let error: Bool
  let biometric: Bool

  @State private var value: String

  init(value: String = "", length: Int = 6, error: Bool = false, biometric: Bool = false) {
    self.length = length
    self.error = error
    self.biometric = biometric
    _value = State(initialValue: value)
  }

  var body: some View {
    PinPad(value: $value, length: length, error: error, onBiometric: biometric ? {} : nil)
      .frame(maxWidth: .infinity)
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.PinPad

@Composable
fun PinPadExample() {
    PinPadCentered { PinPad() }
}

@Composable
fun PinPadCentered(content: @Composable () -> Unit) {
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(24.dp),
    ) {
        content()
    }
}

PinPad combines a row of dots that fill as digits are entered with a Keypad for input. The length is between 4 and 8 digits. Each key press plays a selection haptic, and switching to the error state turns the dots red, plays an error haptic and shakes the row with Reanimated. Haptics play when a haptics adapter is provided through HapticsContext.

An optional biometric key appears in the bottom-left corner of the keypad when onBiometric is set, and the delete key is disabled while nothing is entered.

Installation

$ pnpm dlx shadcn@latest add @esuiss-rn/pin-pad

See Installation for the components.json setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add pin-pad
$ pnpm dlx @esuiss/ui@latest add pin-pad --platform swift
$ pnpm dlx @esuiss/ui@latest add pin-pad --platform kotlin

The CLI also adds what it builds on: Keypad. On React Native it also adds the animated view components/ui/animated-view.tsx.

Manual

  1. Install the dependencies:
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
  1. Add the pieces it builds on: Keypad, and the primitives components/primitives/haptics.tsx, components/primitives/hooks.tsx, and the animated view components/ui/animated-view.tsx from the next step.

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

uniwind/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/ui/pin-pad.tsx:
uniwind/components/ui/pin-pad.tsx
import { cn } from "cn"
import type { Icon as PhosphorIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import {
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withSequence,
  withTiming,
} from "react-native-reanimated"
import { useHaptics } from "@/components/primitives/haptics"
import { useEffectEvent } from "@/components/primitives/hooks"
import { AnimatedView } from "@/components/ui/animated-view"
import { Keypad, type KeypadKeyValue } from "@/components/ui/keypad"
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
  1. Add the pieces it builds on: Keypad, and the primitives components/primitives/haptics.tsx, components/primitives/hooks.tsx, and the animated view components/ui/animated-view.tsx from the next step.

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

nativewind/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/ui/pin-pad.tsx:
nativewind/components/ui/pin-pad.tsx
import { cn } from "cn"
import type { Icon as PhosphorIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import {
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withSequence,
  withTiming,
} from "react-native-reanimated"
import { useHaptics } from "@/components/primitives/haptics"
import { useEffectEvent } from "@/components/primitives/hooks"
import { AnimatedView } from "@/components/ui/animated-view"
import { Keypad, type KeypadKeyValue } from "@/components/ui/keypad"
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
  1. Add the pieces it builds on: Keypad, and the primitives components/primitives/haptics.tsx, components/primitives/hooks.tsx, and the animated view components/ui/animated-view.tsx from the next step.

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

stylesheet/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/ui/pin-pad.tsx:
stylesheet/components/ui/pin-pad.tsx
import type { Icon as PhosphorIcon } from "phosphor-react-native"
import * as React from "react"
import { type StyleProp, StyleSheet, View, type ViewStyle } from "react-native"
import {
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withSequence,
  withTiming,
} from "react-native-reanimated"
import { useHaptics } from "@/components/primitives/haptics"
import { useEffectEvent } from "@/components/primitives/hooks"
import { AnimatedView } from "@/components/ui/animated-view"
import { Keypad, type KeypadKeyValue } from "@/components/ui/keypad"
import { radius, useTheme } from "@/components/ui/theme"
  1. Update the import paths to match your project.
  1. Target iOS 17 or later.

  2. Add the Swift package to your app:

Package.swift
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
  1. Copy the theme files every view uses:
swift/Theme/Theme.swift
import SwiftUI
import UIKit

struct RGBA: Sendable {
  let red: CGFloat
  let green: CGFloat
  let blue: CGFloat
  let alpha: CGFloat

  init(_ hex: UInt32, _ alpha: CGFloat = 1) {
    red = CGFloat((hex >> 16) & 0xFF) / 255
    green = CGFloat((hex >> 8) & 0xFF) / 255
    blue = CGFloat(hex & 0xFF) / 255
    self.alpha = alpha
  }
swift/Theme/Tokens.swift
import SwiftUI

public enum EsuissColor {
  public static let background = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
  public static let foreground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let card = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let cardForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let popover = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let popoverForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let primary = Color.adaptive(light: .init(0x0071E3), dark: .init(0x2997FF))
  public static let primaryForeground = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0xFFFFFF))
  public static let secondary = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
  public static let secondaryForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let muted = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
  public static let mutedForeground = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
  public static let accent = Color.adaptive(light: .init(0x000000, 0.06), dark: .init(0xFFFFFF, 0.1))
  1. Copy the source and every file it uses:
swift/Components/PinPad.swift
import PhosphorSwift
import SwiftUI

enum PinPadMath {
  static func clamp(_ length: Int) -> Int {
    min(max(length, 4), 8)
  }

  static func press(_ key: KeypadKey, value: String, length: Int) -> String? {
    switch key {
    case .digit(let digit):
      guard value.count < length else { return nil }
      return value + String(digit)
    case .delete:
      guard !value.isEmpty else { return nil }
      return String(value.dropLast())
swift/Components/Checkbox.swift
import PhosphorSwift
import SwiftUI

extension Shadow {
  static let control = Shadow(color: .black.opacity(0.08), radius: 0.5, y: 0.5)
  static let thumb = Shadow(color: .black.opacity(0.12), radius: 1.5, y: 1)
}

struct SelectionIndicator<Mark: View>: View {
  let checked: Bool
  let pressed: Bool
  let cornerRadius: CGFloat
  let mark: Mark

  @Environment(\.isEnabled) private var isEnabled
  @Environment(\.isInvalid) private var invalid
swift/Components/Field.swift
import SwiftUI

extension EnvironmentValues {
  @Entry public var isInvalid = false
  @Entry var fieldInset = false
  @Entry var fieldGroupInset = false
  @Entry var fieldSetInset = false
}

extension View {
  public func invalid(_ isInvalid: Bool = true) -> some View {
    environment(\.isInvalid, isInvalid)
  }
}

public enum FieldOrientation: CaseIterable, Sendable {
swift/Components/Haptics.swift
import SwiftUI

extension EnvironmentValues {
  @Entry public var hapticsEnabled = true
}

extension View {
  public func haptics(_ enabled: Bool) -> some View {
    environment(\.hapticsEnabled, enabled)
  }

  func hapticFeedback<Trigger: Equatable>(_ feedback: SensoryFeedback, trigger: Trigger) -> some View {
    modifier(HapticFeedbackModifier(feedback: feedback, trigger: trigger) { _, _ in true })
  }

  func hapticFeedback<Trigger: Equatable>(
swift/Components/Keypad.swift
import PhosphorSwift
import SwiftUI

public enum KeypadKey: Hashable, Sendable {
  case digit(Int)
  case decimal
  case delete
  case biometric
}

public enum KeypadAccessory {
  case none
  case decimal(String, label: LocalizedStringKey = "Decimal separator")
  case biometric(Image, label: LocalizedStringKey = "Use biometrics")
}
swift/Components/Separator.swift
import SwiftUI

public struct Separator: View {
  let orientation: Axis

  public init(_ orientation: Axis = .horizontal) {
    self.orientation = orientation
  }

  public var body: some View {
    Rectangle()
      .fill(EsuissColor.border)
      .frame(width: orientation == .vertical ? 1 : nil, height: orientation == .horizontal ? 1 : nil)
      .frame(maxWidth: orientation == .horizontal ? .infinity : nil, maxHeight: orientation == .vertical ? .infinity : nil)
      .accessibilityHidden(true)
  }
swift/Components/TextVariant.swift
import SwiftUI

public enum TextVariant: CaseIterable, Sendable {
  case `default`, h1, h2, h3, h4, p, blockquote, code, lead, large, small, muted

  var style: TextStyle {
    switch self {
    case .h1: .xl3
    case .h2: .xl2
    case .h3: .xl
    case .h4, .lead, .large: .lg
    case .code, .small, .muted: .sm
    default: .base
    }
  }
  1. Add the files to your app target.
  1. Set minSdk to 24 or higher and add the dependencies:
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")
  1. Copy the theme files every view uses:
kotlin/theme/Theme.kt
package com.esuiss.ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
kotlin/theme/Tokens.kt
package com.esuiss.ui.theme

import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

@Immutable
data class Colors(
    val background: Color,
    val foreground: Color,
    val card: Color,
    val cardForeground: Color,
    val popover: Color,
    val popoverForeground: Color,
    val primary: Color,
    val primaryForeground: Color,
  1. Copy the source and every file it uses:
kotlin/components/PinPad.kt
package com.esuiss.ui.components

import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
kotlin/components/Button.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
kotlin/components/ButtonGroup.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
kotlin/components/Content.kt
package com.esuiss.ui.components

import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

val LocalContentStyle = compositionLocalOf { TextStyle.Default }

val LocalContentColor = compositionLocalOf { Color.Unspecified }

val LocalIconSize = compositionLocalOf { 20.dp }
kotlin/components/FormSupport.kt
package com.esuiss.ui.components

import android.provider.Settings
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
kotlin/components/Haptics.kt
package com.esuiss.ui.components

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback

val LocalHapticsEnabled = staticCompositionLocalOf { true }

@Composable
fun ProvideHaptics(enabled: Boolean, content: @Composable () -> Unit) {
    CompositionLocalProvider(LocalHapticsEnabled provides enabled, content = content)
}
kotlin/components/Icon.kt
package com.esuiss.ui.components

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import com.esuiss.ui.theme.EsuissTheme

@Composable
fun Icon(
    imageVector: ImageVector,
kotlin/components/Keypad.kt
package com.esuiss.ui.components

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.keyframes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
  1. Update the com.esuiss.ui package names to match your project.

Usage

import { PinPad } from "@/components/ui/pin-pad"
<PinPad />

Use it uncontrolled with defaultValue, or controlled with value and onValueChange. onComplete fires with the full code once the last digit is entered; check the code there, set error when it is wrong and clear the value to let the user try again.

Pass onBiometric to show the biometric key, with biometricIcon and biometricLabel to match the method in use. Style the dots and keypad separately with dotsClassName and keypadClassName.

Examples

Filled

A pin pad with four of six digits entered.

iOS
Pin Pad (filled) on iOS in light modePin Pad (filled) on iOS in dark mode
import { PinPad } from "@/components/ui/pin-pad"

export function PinPadFilledExample() {
  return (
    <PinPad defaultValue="1234" />
  )
}
import EsuissUI
import SwiftUI

struct PinPadFilledExample: View {
  var body: some View {
    PinPadDemo(value: "1234")
  }
}

struct PinPadDemo: View {
  let length: Int
  let error: Bool
  let biometric: Bool

  @State private var value: String

  init(value: String = "", length: Int = 6, error: Bool = false, biometric: Bool = false) {
    self.length = length
    self.error = error
    self.biometric = biometric
    _value = State(initialValue: value)
  }

  var body: some View {
    PinPad(value: $value, length: length, error: error, onBiometric: biometric ? {} : nil)
      .frame(maxWidth: .infinity)
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.PinPad

@Composable
fun PinPadFilledExample() {
    PinPadCentered { PinPad(defaultValue = "1234") }
}

@Composable
fun PinPadCentered(content: @Composable () -> Unit) {
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(24.dp),
    ) {
        content()
    }
}

Error

The error state with red dots.

iOS
Pin Pad (error state) on iOS in light modePin Pad (error state) on iOS in dark mode
import { PinPad } from "@/components/ui/pin-pad"

export function PinPadErrorStateExample() {
  return (
    <PinPad defaultValue="654321" error />
  )
}
import EsuissUI
import SwiftUI

struct PinPadErrorStateExample: View {
  var body: some View {
    PinPadDemo(value: "654321", error: true)
  }
}

struct PinPadDemo: View {
  let length: Int
  let error: Bool
  let biometric: Bool

  @State private var value: String

  init(value: String = "", length: Int = 6, error: Bool = false, biometric: Bool = false) {
    self.length = length
    self.error = error
    self.biometric = biometric
    _value = State(initialValue: value)
  }

  var body: some View {
    PinPad(value: $value, length: length, error: error, onBiometric: biometric ? {} : nil)
      .frame(maxWidth: .infinity)
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.PinPad

@Composable
fun PinPadErrorStateExample() {
    PinPadCentered { PinPad(defaultValue = "654321", error = true) }
}

@Composable
fun PinPadCentered(content: @Composable () -> Unit) {
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(24.dp),
    ) {
        content()
    }
}

Verify

Checks the code on completion, shows an error for a wrong code and clears it.

iOS
Pin Pad (verify) on iOS in light modePin Pad (verify) on iOS in dark mode
import * as React from "react"
import { View } from "react-native"
import { PinPad } from "@/components/ui/pin-pad"
import { Text } from "@/components/ui/text"

const PASSCODE = "123456"

function VerifyDemo() {
  const [value, setValue] = React.useState("")
  const [error, setError] = React.useState(false)
  const [status, setStatus] = React.useState("Enter 123456")
  return (
    <View className="items-center gap-6">
      <Text
        role="status"
        accessibilityLiveRegion="polite"
        className={
          error ? "text-sm text-destructive" : "text-sm text-muted-foreground"
        }
      >
        {status}
      </Text>
      <PinPad
        value={value}
        error={error}
        onValueChange={(next) => {
          setValue(next)
          if (next.length > 0) setError(false)
        }}
        onComplete={(code) => {
          if (code === PASSCODE) {
            setStatus("Unlocked")
          } else {
            setError(true)
            setStatus("Wrong passcode. Try again.")
            setTimeout(() => setValue(""), 600)
          }
        }}
      />
    </View>
  )
}

export function PinPadVerifyExample() {
  return (
    <VerifyDemo />
  )
}
import EsuissUI
import SwiftUI

struct PinPadVerify: View {
  private static let passcode = "123456"

  @State private var value = ""
  @State private var error = false
  @State private var status = "Enter 123456"

  var body: some View {
    VStack(spacing: 24) {
      Text(verbatim: status)
        .textStyle(.sm)
        .foregroundStyle(error ? EsuissColor.destructive : EsuissColor.mutedForeground)
        .accessibilityAddTraits(.updatesFrequently)
      PinPad(value: $value, error: error, onComplete: complete)
      .onChange(of: value) { _, next in
        if !next.isEmpty { error = false }
      }
    }
    .frame(maxWidth: .infinity)
  }

  func complete(_ code: String) {
    if code == Self.passcode {
      status = "Unlocked"
    } else {
      error = true
      status = "Wrong passcode. Try again."
      Task {
        try? await Task.sleep(for: .milliseconds(600))
        value = ""
      }
    }
  }
}

let passcode = "123456"
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
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.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.PinPad
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

@Composable
fun PinPadCentered(content: @Composable () -> Unit) {
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(24.dp),
    ) {
        content()
    }
}

@Composable
fun PinPadVerify() {
    var value by rememberSaveable { mutableStateOf("") }
    var error by rememberSaveable { mutableStateOf(false) }
    var status by rememberSaveable { mutableStateOf("Enter 123456") }
    val scope = rememberCoroutineScope()
    val colors = EsuissTheme.colors
    PinPadCentered {
        Text(
            status,
            modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm,
            color = if (error) colors.destructive else colors.mutedForeground,
        )
        PinPad(
            value = value,
            error = error,
            onValueChange = { next ->
                value = next
                if (next.isNotEmpty()) error = false
            },
            onComplete = { code ->
                if (code == PinPadPasscode) {
                    status = "Unlocked"
                } else {
                    error = true
                    status = "Wrong passcode. Try again."
                    scope.launch {
                        delay(600)
                        value = ""
                    }
                }
            },
        )
    }
}

Disabled

A disabled pin pad with a biometric key.

iOS
Pin Pad (disabled) on iOS in light modePin Pad (disabled) on iOS in dark mode
import { PinPad } from "@/components/ui/pin-pad"

export function PinPadDisabledExample() {
  return (
    <PinPad defaultValue="12" disabled onBiometric={() => undefined} />
  )
}
import EsuissUI
import SwiftUI

struct PinPadDisabledExample: View {
  var body: some View {
    PinPadDemo(value: "12", biometric: true)
      .disabled(true)
  }
}

struct PinPadDemo: View {
  let length: Int
  let error: Bool
  let biometric: Bool

  @State private var value: String

  init(value: String = "", length: Int = 6, error: Bool = false, biometric: Bool = false) {
    self.length = length
    self.error = error
    self.biometric = biometric
    _value = State(initialValue: value)
  }

  var body: some View {
    PinPad(value: $value, length: length, error: error, onBiometric: biometric ? {} : nil)
      .frame(maxWidth: .infinity)
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.PinPad

@Composable
fun PinPadDisabledExample() {
    PinPadCentered { PinPad(defaultValue = "12", enabled = false, onBiometric = {}) }
}

@Composable
fun PinPadCentered(content: @Composable () -> Unit) {
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(24.dp),
    ) {
        content()
    }
}

Biometric

A biometric key with the default fingerprint icon.

iOS
Pin Pad (biometric) on iOS in light modePin Pad (biometric) on iOS in dark mode
import { ScanSmileyIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { PinPad } from "@/components/ui/pin-pad"
import { Text } from "@/components/ui/text"

function BiometricDemo({ faceId = false }: { faceId?: boolean }) {
  const [log, setLog] = React.useState(0)
  return (
    <View className="items-center gap-6">
      <Text className="text-sm text-muted-foreground">
        {log > 0 ? `Biometric requested (${log})` : "Biometric key"}
      </Text>
      <PinPad
        biometricIcon={faceId ? ScanSmileyIcon : undefined}
        biometricLabel={faceId ? "Use Face ID" : "Use Touch ID"}
        onBiometric={() => setLog((count) => count + 1)}
      />
    </View>
  )
}

export function PinPadBiometricExample() {
  return (
    <BiometricDemo />
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct PinPadBiometricExample: View {
  var body: some View {
    PinPadBiometric(faceID: false)
  }
}

struct PinPadBiometric: View {
  let faceID: Bool

  @State private var value = ""
  @State private var log = 0

  var body: some View {
    VStack(spacing: 24) {
      Group {
        if log > 0 {
          Text(verbatim: "Biometric requested (\(log))")
        } else {
          Text("Biometric key")
        }
      }
      .textStyle(.sm)
      .foregroundStyle(EsuissColor.mutedForeground)
      PinPad(
        value: $value,
        biometricIcon: faceID ? Ph.scanSmiley.regular : Ph.fingerprint.regular,
        biometricLabel: faceID ? "Use Face ID" : "Use Touch ID",
        onBiometric: { log += 1 }
      )
    }
    .frame(maxWidth: .infinity)
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Fingerprint
import com.adamglin.phosphoricons.regular.ScanSmiley
import com.esuiss.ui.components.PinPad
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun PinPadBiometricExample() {
    PinPadBiometric(faceId = false)
}

@Composable
fun PinPadCentered(content: @Composable () -> Unit) {
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(24.dp),
    ) {
        content()
    }
}

@Composable
fun PinPadBiometric(faceId: Boolean) {
    var log by rememberSaveable { mutableIntStateOf(0) }
    PinPadCentered {
        Text(
            if (log > 0) "Biometric requested ($log)" else "Biometric key",
            style = Typography.sm,
            color = EsuissTheme.colors.mutedForeground,
        )
        PinPad(
            biometricIcon = if (faceId) PhosphorIcons.Regular.ScanSmiley else PhosphorIcons.Regular.Fingerprint,
            biometricLabel = if (faceId) "Use Face ID" else "Use Touch ID",
            onBiometric = { log += 1 },
        )
    }
}

Face biometric

A biometric key with a face icon and a matching label.

iOS
Pin Pad (face biometric) on iOS in light modePin Pad (face biometric) on iOS in dark mode
import { ScanSmileyIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { PinPad } from "@/components/ui/pin-pad"
import { Text } from "@/components/ui/text"

function BiometricDemo({ faceId = false }: { faceId?: boolean }) {
  const [log, setLog] = React.useState(0)
  return (
    <View className="items-center gap-6">
      <Text className="text-sm text-muted-foreground">
        {log > 0 ? `Biometric requested (${log})` : "Biometric key"}
      </Text>
      <PinPad
        biometricIcon={faceId ? ScanSmileyIcon : undefined}
        biometricLabel={faceId ? "Use Face ID" : "Use Touch ID"}
        onBiometric={() => setLog((count) => count + 1)}
      />
    </View>
  )
}

export function PinPadFaceBiometricExample() {
  return (
    <BiometricDemo faceId />
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct PinPadFaceBiometricExample: View {
  var body: some View {
    PinPadBiometric(faceID: true)
  }
}

struct PinPadBiometric: View {
  let faceID: Bool

  @State private var value = ""
  @State private var log = 0

  var body: some View {
    VStack(spacing: 24) {
      Group {
        if log > 0 {
          Text(verbatim: "Biometric requested (\(log))")
        } else {
          Text("Biometric key")
        }
      }
      .textStyle(.sm)
      .foregroundStyle(EsuissColor.mutedForeground)
      PinPad(
        value: $value,
        biometricIcon: faceID ? Ph.scanSmiley.regular : Ph.fingerprint.regular,
        biometricLabel: faceID ? "Use Face ID" : "Use Touch ID",
        onBiometric: { log += 1 }
      )
    }
    .frame(maxWidth: .infinity)
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Fingerprint
import com.adamglin.phosphoricons.regular.ScanSmiley
import com.esuiss.ui.components.PinPad
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun PinPadFaceBiometricExample() {
    PinPadBiometric(faceId = true)
}

@Composable
fun PinPadCentered(content: @Composable () -> Unit) {
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(24.dp),
    ) {
        content()
    }
}

@Composable
fun PinPadBiometric(faceId: Boolean) {
    var log by rememberSaveable { mutableIntStateOf(0) }
    PinPadCentered {
        Text(
            if (log > 0) "Biometric requested ($log)" else "Biometric key",
            style = Typography.sm,
            color = EsuissTheme.colors.mutedForeground,
        )
        PinPad(
            biometricIcon = if (faceId) PhosphorIcons.Regular.ScanSmiley else PhosphorIcons.Regular.Fingerprint,
            biometricLabel = if (faceId) "Use Face ID" else "Use Touch ID",
            onBiometric = { log += 1 },
        )
    }
}

Four digits

A four-digit code with length={4}.

iOS
Pin Pad (four digits) on iOS in light modePin Pad (four digits) on iOS in dark mode
import { PinPad } from "@/components/ui/pin-pad"

export function PinPadFourDigitsExample() {
  return (
    <PinPad length={4} defaultValue="1" />
  )
}
import EsuissUI
import SwiftUI

struct PinPadFourDigitsExample: View {
  var body: some View {
    PinPadDemo(value: "1", length: 4)
  }
}

struct PinPadDemo: View {
  let length: Int
  let error: Bool
  let biometric: Bool

  @State private var value: String

  init(value: String = "", length: Int = 6, error: Bool = false, biometric: Bool = false) {
    self.length = length
    self.error = error
    self.biometric = biometric
    _value = State(initialValue: value)
  }

  var body: some View {
    PinPad(value: $value, length: length, error: error, onBiometric: biometric ? {} : nil)
      .frame(maxWidth: .infinity)
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.PinPad

@Composable
fun PinPadFourDigitsExample() {
    PinPadCentered { PinPad(defaultValue = "1", length = 4) }
}

@Composable
fun PinPadCentered(content: @Composable () -> Unit) {
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(24.dp),
    ) {
        content()
    }
}

API Reference

01

PinPad

PropTypeDefaultDescription
lengthnumber6Number of digits, clamped between 4 and 8. Defaults to 6.
valuestring-Entered digits for controlled use.
defaultValuestring""Initial digits for uncontrolled use. Non-digits are removed.
onValueChange(value: string) => void-Called with the new value after each digit or deletion.
onComplete(value: string) => void-Called with the full code when the last digit is entered.
errorbooleanfalseShows the error state. Turning it on shakes the dots and plays an error haptic.
disabledbooleanfalseTurns off the keypad and dims the dots.
onBiometric() => void-Called when the biometric key is pressed. The key is shown only when this is set.
biometricIconPhosphorIcon-Icon for the biometric key.
biometricLabelstring-Accessibility label for the biometric key.
deleteLabelstring-Accessibility label for the delete key.
dotsLabel(entered: number, length: number) => stringdefaultDotsLabelBuilds the dots label from the entered count and the length. Defaults to "3 of 6 entered".
dotsClassNamestring-Classes for the dots row.
keypadClassNamestring-Classes for the keypad.

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

Accessibility

  • The dots row is a single element labeled by dotsLabel, so screen readers hear progress without the digits.
  • The row is a polite live region, so TalkBack announces progress as it changes.
  • Keypad keys are buttons labeled with their digit; the delete and biometric keys use deleteLabel and biometricLabel.
  • The shake animation respects the system reduce motion setting.

Notes

  • Uses bg-foreground for filled dots, border-input for empty ones and bg-destructive in the error state; colors follow the system appearance.
NextPopoverPreviousPagination

On this page

  1. 01Installation
    • Manual
  2. 02Usage
  3. 03Examples
    • Filled
    • Error
    • Verify
    • Disabled
    • Biometric
    • Face biometric
    • Four digits
  4. 04API Reference
    • PinPad
  5. 05Accessibility
  6. 06Notes