suiss/uim · React Native Component
Pin Pad
A passcode entry screen element with a row of dots and a numeric keypad.
Pin Pad is an open-source mobile component from suiss/uim for React Native and Expo, with SwiftUI and Jetpack Compose versions built from the same design tokens. The @esuiss/uim CLI copies its source into your project, so you own the code.


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.
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 @esuiss/uim@latest add pin-pad$ pnpm dlx @esuiss/uim@latest add pin-pad --platform swift$ pnpm dlx @esuiss/uim@latest add pin-pad --platform kotlinThe CLI also adds what it builds on: Keypad.
Manual
- Install the dependencies:
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets-
Add the pieces it builds on: Keypad, and the primitives
components/primitives/haptics.tsx,components/primitives/hooks.tsx. -
Copy the source into
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"- Update the import paths to match your project.
- Install the dependencies:
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets-
Add the pieces it builds on: Keypad, and the primitives
components/primitives/haptics.tsx,components/primitives/hooks.tsx. -
Copy the source into
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"- Update the import paths to match your project.
- Install the dependencies:
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets-
Add the pieces it builds on: Keypad, and the primitives
components/primitives/haptics.tsx,components/primitives/hooks.tsx. -
Copy the source into
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"- Update the import paths to match your project.
-
Target iOS 17 or later.
-
Add the Swift package to your app:
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")- Copy the theme files every view uses:
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
}import SwiftUI
extension Color {
public static let surface = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
public static let surfaceSecondary = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
public static let surfaceTertiary = Color.adaptive(light: .init(0xE8E8ED), dark: .init(0x2C2C2E))
public static let surfaceRaised = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
public static let label = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
public static let labelSecondary = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
public static let labelTertiary = Color.adaptive(light: .init(0x86868B), dark: .init(0x86868B))
public static let labelQuaternary = Color.adaptive(light: .init(0xD2D2D7), dark: .init(0x424245))
public static let control = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
public static let controlPressed = Color.adaptive(light: .init(0x000000, 0.12), dark: .init(0xFFFFFF, 0.22))
public static let controlSelected = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
public static let controlDisabled = Color.adaptive(light: .init(0x000000, 0.03), dark: .init(0xFFFFFF, 0.05))
public static let onControlSelected = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))- Copy the source and every file it uses:
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())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 invalidimport 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 {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>(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")
}import SwiftUI
public struct Separator: View {
let orientation: Axis
public init(_ orientation: Axis = .horizontal) {
self.orientation = orientation
}
public var body: some View {
Rectangle()
.fill(Color.separator)
.frame(width: orientation == .vertical ? 1 : nil, height: orientation == .horizontal ? 1 : nil)
.frame(maxWidth: orientation == .horizontal ? .infinity : nil, maxHeight: orientation == .vertical ? .infinity : nil)
.accessibilityHidden(true)
}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
}
}- Add the files to your app target.
- Set
minSdkto 24 or higher and add the dependencies:
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")- Copy the theme files every view uses:
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.DpOffsetpackage 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 surface: Color,
val surfaceSecondary: Color,
val surfaceTertiary: Color,
val surfaceRaised: Color,
val label: Color,
val labelSecondary: Color,
val labelTertiary: Color,
val labelQuaternary: Color,- Copy the source and every file it uses:
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.getValuepackage 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.RoundedCornerShapepackage 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.Immutablepackage 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 }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.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Sizepackage 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)
}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,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- Update the
com.esuiss.uipackage 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.


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.


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.


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-danger" : "text-sm text-label-secondary"
}
>
{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 ? Color.danger : .labelSecondary)
.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.danger else colors.labelSecondary,
)
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.


import { PinPad } from "@/components/ui/pin-pad"
export function PinPadDisabledExample() {
return (
<PinPad defaultValue="12" disabled onBiometric={() => {}} />
)
}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.


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-label-secondary">
{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(Color.labelSecondary)
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.labelSecondary,
)
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.


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-label-secondary">
{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(Color.labelSecondary)
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.labelSecondary,
)
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}.


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
PinPad
React.ComponentProps<typeof View>- About
- Number of digits, clamped between 4 and 8. Defaults to
6. - Type
number- Default
- 6
- About
- Entered digits for controlled use.
- Type
string- Default
- —
- About
- Initial digits for uncontrolled use. Non-digits are removed.
- Type
string- Default
- ""
- About
- Called with the new value after each digit or deletion.
- Type
(value: string) => void- Default
- —
- About
- Called with the full code when the last digit is entered.
- Type
(value: string) => void- Default
- —
- About
- Shows the error state. Turning it on shakes the dots and plays an error haptic.
- Type
boolean- Default
- false
- About
- Turns off the keypad and dims the dots.
- Type
boolean- Default
- false
- About
- Called when the biometric key is pressed. The key is shown only when this is set.
- Type
() => void- Default
- —
- About
- Icon for the biometric key.
- Type
PhosphorIcon- Default
- —
- About
- Accessibility label for the biometric key.
- Type
string- Default
- —
- About
- Accessibility label for the delete key.
- Type
string- Default
- —
- About
- Builds the dots label from the entered count and the length. Defaults to "3 of 6 entered".
- Type
(entered: number, length: number) => string- Default
- defaultDotsLabel
- About
- Classes for the dots row.
- Type
string- Default
- —
- About
- Classes for the keypad.
- Type
string- Default
- —
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
deleteLabelandbiometricLabel. - The shake animation respects the system reduce motion setting.
Notes
- Uses
bg-labelfor filled dots,border-separator-strongfor empty ones andbg-dangerin the error state; colors follow the system appearance.