# Keypad

A numeric keypad with round digit keys, a delete key and an optional decimal or biometric key.

Keypad 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.

Source: https://ui.suiss.app/docs/components/keypad

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/components-keypad--default.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Keypad, type KeypadAccessoryKey, type KeypadKeyValue } from "@/components/ui/keypad"
import { Text } from "@/components/ui/text"

function KeyLog({
  accessoryKey,
  disabled,
}: {
  accessoryKey?: KeypadAccessoryKey
  disabled?: boolean
}) {
  const [last, setLast] = React.useState<KeypadKeyValue | null>(null)
  return (
    <View className="items-center gap-6">
      <Text
        role="status"
        accessibilityLiveRegion="polite"
        className="text-sm text-muted-foreground"
      >
        {last ? `Last key: ${last}` : "Press a key"}
      </Text>
      <Keypad
        accessoryKey={accessoryKey}
        disabled={disabled}
        onKeyPress={setLast}
      />
    </View>
  )
}

export function KeypadExample() {
  return (
    <KeyLog />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct KeypadExample: View {
  var body: some View {
    KeypadLog(accessory: .none)
  }
}

struct KeypadLog: View {
  let accessory: KeypadAccessory

  @State private var last: KeypadKey?

  var body: some View {
    VStack(spacing: 24) {
      Group {
        if let last {
          Text(verbatim: "Last key: \(name(last))")
        } else {
          Text("Press a key")
        }
      }
      .textStyle(.sm)
      .foregroundStyle(EsuissColor.mutedForeground)
      .accessibilityAddTraits(.updatesFrequently)
      Keypad(accessory: accessory) { key in
        last = key
      }
    }
    .frame(maxWidth: .infinity)
  }

  func name(_ key: KeypadKey) -> String {
    switch key {
    case .digit(let value): "\(value)"
    case .decimal: "decimal"
    case .delete: "delete"
    case .biometric: "biometric"
    }
  }
}
```

```kotlin
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.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.Keypad
import com.esuiss.ui.components.KeypadAccessory
import com.esuiss.ui.components.KeypadKey
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun KeypadExample() {
    KeypadKeyLog(KeypadAccessory.None)
}

fun keypadKeyName(key: KeypadKey): String = when (key) {
    is KeypadKey.Digit -> key.value.toString()
    KeypadKey.Decimal -> "decimal"
    KeypadKey.Biometric -> "biometric"
    KeypadKey.Delete -> "delete"
}

@Composable
fun KeypadKeyLog(accessory: KeypadAccessory, enabled: Boolean = true) {
    var last by rememberSaveable { mutableStateOf<String?>(null) }
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(24.dp),
    ) {
        Text(
            last?.let { "Last key: $it" } ?: "Press a key",
            modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm,
            color = EsuissTheme.colors.mutedForeground,
        )
        Keypad(onKeyPress = { last = keypadKeyName(it) }, accessory = accessory, enabled = enabled)
    }
}
```

`Keypad` lays out digits 1 to 9 in three rows and a bottom row with an accessory key, `0` and a delete key. Each key is a `KeypadKey`, a round `Pressable` with the `button` role. Digit keys have a filled background; the accessory and delete keys use the `plain` variant with no fill.

Use it for PIN entry, amounts and other numeric input where the system keyboard is not wanted.

## Installation

**React Native**

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

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

**SwiftUI**

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

**Jetpack Compose**

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

The CLI also adds what it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text).

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text).

3. Copy the source into `components/ui/keypad.tsx`:

```tsx title="uniwind/components/ui/keypad.tsx"
import { cn } from "cn"
import {
  BackspaceIcon,
  FingerprintIcon,
  type Icon as PhosphorIcon,
} from "phosphor-react-native"
import type * as React from "react"
import { Pressable, View } from "react-native"
import { Icon, IconClassContext } from "@/components/ui/icon"
import { Text, TextClassContext } from "@/components/ui/text"

type KeypadDigit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"

type KeypadKeyValue = KeypadDigit | "decimal" | "delete" | "biometric"

type KeypadAccessoryKey = "decimal" | "biometric"

const ROWS: readonly (readonly KeypadDigit[])[] = [
  ["1", "2", "3"],
  ["4", "5", "6"],
  ["7", "8", "9"],
]

function KeypadKey({
  variant = "default",
  disabled,
  className,
  children,
  accessibilityState,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children"> & {
  variant?: "default" | "plain"
  className?: string
  children?: React.ReactNode
}) {
  return (
    <Pressable
      role="button"
      disabled={disabled}
      accessibilityState={{ ...accessibilityState, disabled: !!disabled }}
      className={cn(
        "size-18 items-center justify-center rounded-full",
        variant === "default" && "bg-secondary",
        disabled ? "opacity-50" : "active:opacity-80",
        className
      )}
      {...props}
    >
      <TextClassContext.Provider value="text-2xl tabular-nums text-foreground">
        <IconClassContext.Provider value="size-7 text-foreground">
          {children}
        </IconClassContext.Provider>
      </TextClassContext.Provider>
    </Pressable>
  )
}

function Keypad({
  onKeyPress,
  accessoryKey,
  decimalSeparator = ".",
  biometricIcon = FingerprintIcon,
  biometricLabel = "Use biometrics",
  decimalLabel = "Decimal separator",
  deleteLabel = "Delete",
  disabled = false,
  deleteDisabled = false,
  className,
  keyClassName,
  ...props
}: React.ComponentProps<typeof View> & {
  onKeyPress?: (key: KeypadKeyValue) => void
  accessoryKey?: KeypadAccessoryKey
  decimalSeparator?: string
  biometricIcon?: PhosphorIcon
  biometricLabel?: string
  decimalLabel?: string
  deleteLabel?: string
  disabled?: boolean
  deleteDisabled?: boolean
  keyClassName?: string
}) {
  const digit = (value: KeypadDigit) => (
    <KeypadKey
      key={value}
      accessibilityLabel={value}
      disabled={disabled}
      className={keyClassName}
      onPress={() => onKeyPress?.(value)}
    >
      <Text>{value}</Text>
    </KeypadKey>
  )

  const accessory = () => {
    if (accessoryKey === "decimal") {
      return (
        <KeypadKey
          variant="plain"
          accessibilityLabel={decimalLabel}
          disabled={disabled}
          className={keyClassName}
          onPress={() => onKeyPress?.("decimal")}
        >
          <Text>{decimalSeparator}</Text>
        </KeypadKey>
      )
    }
    if (accessoryKey === "biometric") {
      return (
        <KeypadKey
          variant="plain"
          accessibilityLabel={biometricLabel}
          disabled={disabled}
          className={keyClassName}
          onPress={() => onKeyPress?.("biometric")}
        >
          <Icon as={biometricIcon} />
        </KeypadKey>
      )
    }
    return <View className="size-18" />
  }

  return (
    <View className={cn("items-center gap-4", className)} {...props}>
      {ROWS.map((row) => (
        <View key={row.join("")} className="flex-row gap-6">
          {row.map(digit)}
        </View>
      ))}
      <View className="flex-row gap-6">
        {accessory()}
        {digit("0")}
        <KeypadKey
          variant="plain"
          accessibilityLabel={deleteLabel}
          disabled={disabled || deleteDisabled}
          className={keyClassName}
          onPress={() => onKeyPress?.("delete")}
        >
          <Icon as={BackspaceIcon} />
        </KeypadKey>
      </View>
    </View>
  )
}

export type { KeypadAccessoryKey, KeypadDigit, KeypadKeyValue }
export { Keypad, KeypadKey }
```

4. 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-svg
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text).

3. Copy the source into `components/ui/keypad.tsx`:

```tsx title="nativewind/components/ui/keypad.tsx"
import { cn } from "cn"
import {
  BackspaceIcon,
  FingerprintIcon,
  type Icon as PhosphorIcon,
} from "phosphor-react-native"
import type * as React from "react"
import { Pressable, View } from "react-native"
import { Icon, IconClassContext } from "@/components/ui/icon"
import { Text, TextClassContext } from "@/components/ui/text"

type KeypadDigit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"

type KeypadKeyValue = KeypadDigit | "decimal" | "delete" | "biometric"

type KeypadAccessoryKey = "decimal" | "biometric"

const ROWS: readonly (readonly KeypadDigit[])[] = [
  ["1", "2", "3"],
  ["4", "5", "6"],
  ["7", "8", "9"],
]

function KeypadKey({
  variant = "default",
  disabled,
  className,
  children,
  accessibilityState,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children"> & {
  variant?: "default" | "plain"
  className?: string
  children?: React.ReactNode
}) {
  return (
    <Pressable
      role="button"
      disabled={disabled}
      accessibilityState={{ ...accessibilityState, disabled: !!disabled }}
      className={cn(
        "size-[4.5rem] items-center justify-center rounded-full",
        variant === "default" && "bg-secondary",
        disabled ? "opacity-50" : "active:opacity-80",
        className
      )}
      {...props}
    >
      <TextClassContext.Provider value="text-2xl tabular-nums text-foreground">
        <IconClassContext.Provider value="size-7 text-foreground">
          {children}
        </IconClassContext.Provider>
      </TextClassContext.Provider>
    </Pressable>
  )
}

function Keypad({
  onKeyPress,
  accessoryKey,
  decimalSeparator = ".",
  biometricIcon = FingerprintIcon,
  biometricLabel = "Use biometrics",
  decimalLabel = "Decimal separator",
  deleteLabel = "Delete",
  disabled = false,
  deleteDisabled = false,
  className,
  keyClassName,
  ...props
}: React.ComponentProps<typeof View> & {
  onKeyPress?: (key: KeypadKeyValue) => void
  accessoryKey?: KeypadAccessoryKey
  decimalSeparator?: string
  biometricIcon?: PhosphorIcon
  biometricLabel?: string
  decimalLabel?: string
  deleteLabel?: string
  disabled?: boolean
  deleteDisabled?: boolean
  keyClassName?: string
}) {
  const digit = (value: KeypadDigit) => (
    <KeypadKey
      key={value}
      accessibilityLabel={value}
      disabled={disabled}
      className={keyClassName}
      onPress={() => onKeyPress?.(value)}
    >
      <Text>{value}</Text>
    </KeypadKey>
  )

  const accessory = () => {
    if (accessoryKey === "decimal") {
      return (
        <KeypadKey
          variant="plain"
          accessibilityLabel={decimalLabel}
          disabled={disabled}
          className={keyClassName}
          onPress={() => onKeyPress?.("decimal")}
        >
          <Text>{decimalSeparator}</Text>
        </KeypadKey>
      )
    }
    if (accessoryKey === "biometric") {
      return (
        <KeypadKey
          variant="plain"
          accessibilityLabel={biometricLabel}
          disabled={disabled}
          className={keyClassName}
          onPress={() => onKeyPress?.("biometric")}
        >
          <Icon as={biometricIcon} />
        </KeypadKey>
      )
    }
    return <View className="size-[4.5rem]" />
  }

  return (
    <View className={cn("items-center gap-4", className)} {...props}>
      {ROWS.map((row) => (
        <View key={row.join("")} className="flex-row gap-6">
          {row.map(digit)}
        </View>
      ))}
      <View className="flex-row gap-6">
        {accessory()}
        {digit("0")}
        <KeypadKey
          variant="plain"
          accessibilityLabel={deleteLabel}
          disabled={disabled || deleteDisabled}
          className={keyClassName}
          onPress={() => onKeyPress?.("delete")}
        >
          <Icon as={BackspaceIcon} />
        </KeypadKey>
      </View>
    </View>
  )
}

export type { KeypadAccessoryKey, KeypadDigit, KeypadKeyValue }
export { Keypad, KeypadKey }
```

4. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text).

3. Copy the source into `components/ui/keypad.tsx`:

```tsx title="stylesheet/components/ui/keypad.tsx"
import {
  BackspaceIcon,
  FingerprintIcon,
  type Icon as PhosphorIcon,
} from "phosphor-react-native"
import type * as React from "react"
import {
  Pressable,
  type StyleProp,
  StyleSheet,
  View,
  type ViewStyle,
} from "react-native"
import { Icon, IconSizeContext } from "@/components/ui/icon"
import { Text, TextStyleContext } from "@/components/ui/text"
import { fontSize, radius, useTheme } from "@/components/ui/theme"

type KeypadDigit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"

type KeypadKeyValue = KeypadDigit | "decimal" | "delete" | "biometric"

type KeypadAccessoryKey = "decimal" | "biometric"

const ROWS: readonly (readonly KeypadDigit[])[] = [
  ["1", "2", "3"],
  ["4", "5", "6"],
  ["7", "8", "9"],
]

const KEY_SIZE = 72

const styles = StyleSheet.create({
  root: { alignItems: "center", gap: 16 },
  row: { flexDirection: "row", gap: 24 },
  key: {
    width: KEY_SIZE,
    height: KEY_SIZE,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
  },
  text: { ...fontSize["2xl"], fontVariant: ["tabular-nums"] },
})

function KeypadKey({
  variant = "default",
  disabled,
  style,
  children,
  accessibilityState,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children" | "style"> & {
  variant?: "default" | "plain"
  style?: StyleProp<ViewStyle>
  children?: React.ReactNode
}) {
  const { colors } = useTheme()
  const color = colors.foreground
  const background = variant === "plain" ? "transparent" : colors.secondary
  return (
    <Pressable
      role="button"
      disabled={disabled}
      accessibilityState={{ ...accessibilityState, disabled: !!disabled }}
      style={({ pressed }) => [
        styles.key,
        { backgroundColor: background },
        pressed && !disabled && { opacity: 0.8 },
        disabled && { opacity: 0.5 },
        style,
      ]}
      {...props}
    >
      <TextStyleContext.Provider value={{ ...styles.text, color }}>
        <IconSizeContext.Provider value={28}>
          {children}
        </IconSizeContext.Provider>
      </TextStyleContext.Provider>
    </Pressable>
  )
}

function Keypad({
  onKeyPress,
  accessoryKey,
  decimalSeparator = ".",
  biometricIcon = FingerprintIcon,
  biometricLabel = "Use biometrics",
  decimalLabel = "Decimal separator",
  deleteLabel = "Delete",
  disabled = false,
  deleteDisabled = false,
  style,
  keyStyle,
  ...props
}: React.ComponentProps<typeof View> & {
  onKeyPress?: (key: KeypadKeyValue) => void
  accessoryKey?: KeypadAccessoryKey
  decimalSeparator?: string
  biometricIcon?: PhosphorIcon
  biometricLabel?: string
  decimalLabel?: string
  deleteLabel?: string
  disabled?: boolean
  deleteDisabled?: boolean
  keyStyle?: StyleProp<ViewStyle>
}) {
  const digit = (value: KeypadDigit) => (
    <KeypadKey
      key={value}
      accessibilityLabel={value}
      disabled={disabled}
      style={keyStyle}
      onPress={() => onKeyPress?.(value)}
    >
      <Text>{value}</Text>
    </KeypadKey>
  )
  let accessory: React.ReactNode = <View style={styles.key} />
  if (accessoryKey === "decimal") {
    accessory = (
      <KeypadKey
        variant="plain"
        accessibilityLabel={decimalLabel}
        disabled={disabled}
        style={keyStyle}
        onPress={() => onKeyPress?.("decimal")}
      >
        <Text>{decimalSeparator}</Text>
      </KeypadKey>
    )
  } else if (accessoryKey === "biometric") {
    accessory = (
      <KeypadKey
        variant="plain"
        accessibilityLabel={biometricLabel}
        disabled={disabled}
        style={keyStyle}
        onPress={() => onKeyPress?.("biometric")}
      >
        <Icon as={biometricIcon} />
      </KeypadKey>
    )
  }

  return (
    <View style={[styles.root, style]} {...props}>
      {ROWS.map((row) => (
        <View key={row.join("")} style={styles.row}>
          {row.map(digit)}
        </View>
      ))}
      <View style={styles.row}>
        {accessory}
        {digit("0")}
        <KeypadKey
          variant="plain"
          accessibilityLabel={deleteLabel}
          disabled={disabled || deleteDisabled}
          style={keyStyle}
          onPress={() => onKeyPress?.("delete")}
        >
          <Icon as={BackspaceIcon} />
        </KeypadKey>
      </View>
    </View>
  )
}

export type { KeypadAccessoryKey, KeypadDigit, KeypadKeyValue }
export { Keypad, KeypadKey }
```

4. 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 title="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")
}

public struct Keypad: View {
  let accessory: KeypadAccessory
  let deleteLabel: LocalizedStringKey
  let canDelete: Bool
  let press: (KeypadKey) -> Bool

  public init(
    accessory: KeypadAccessory = .none,
    deleteLabel: LocalizedStringKey = "Delete",
    deleteDisabled: Bool = false,
    onKeyPress: @escaping (KeypadKey) -> Void
  ) {
    self.accessory = accessory
    self.deleteLabel = deleteLabel
    canDelete = !deleteDisabled
    press = { key in
      onKeyPress(key)
      return true
    }
  }

  init(
    accessory: KeypadAccessory,
    deleteLabel: LocalizedStringKey,
    canDelete: Bool,
    press: @escaping (KeypadKey) -> Bool
  ) {
    self.accessory = accessory
    self.deleteLabel = deleteLabel
    self.canDelete = canDelete
    self.press = press
  }

  @ScaledMetric(relativeTo: .title) private var scale: CGFloat = 1

  public var body: some View {
    let size = min(max(72 * scale, 64), 80)
    VStack(spacing: 16) {
      ForEach(0..<3) { row in
        HStack(spacing: 24) {
          ForEach(1..<4) { column in
            digit(row * 3 + column, size: size)
          }
        }
      }
      HStack(spacing: 24) {
        accessoryKey(size: size)
        digit(0, size: size)
        key(.delete, size: size, filled: false) {
          Ph.backspace.regular
            .resizable()
            .frame(width: 28, height: 28)
        }
        .disabled(!canDelete)
        .accessibilityLabel(Text(deleteLabel))
      }
    }
    .accessibilityElement(children: .contain)
  }

  private func digit(_ value: Int, size: CGFloat) -> some View {
    key(.digit(value), size: size, filled: true) {
      Text(verbatim: "\(value)")
        .scaledFont(.xl2)
        .monospacedDigit()
    }
    .accessibilityLabel(Text(verbatim: "\(value)"))
  }

  @ViewBuilder
  private func accessoryKey(size: CGFloat) -> some View {
    switch accessory {
    case .none:
      Color.clear.frame(width: size, height: size)
        .accessibilityHidden(true)
    case .decimal(let separator, let label):
      key(.decimal, size: size, filled: false) {
        Text(verbatim: separator)
          .scaledFont(.xl2)
      }
      .accessibilityLabel(Text(label))
    case .biometric(let icon, let label):
      key(.biometric, size: size, filled: false) {
        icon
          .resizable()
          .frame(width: 28, height: 28)
      }
      .accessibilityLabel(Text(label))
    }
  }

  private func key<Label: View>(
    _ key: KeypadKey,
    size: CGFloat,
    filled: Bool,
    @ViewBuilder label: @escaping () -> Label
  ) -> some View {
    HapticButton(style: PressReader { pressed in
      KeypadFace(pressed: pressed, filled: filled, size: size, label: label)
    }) {
      press(key)
    }
  }
}

private struct KeypadFace<Label: View>: View {
  let pressed: Bool
  let filled: Bool
  let size: CGFloat
  let label: () -> Label

  @Environment(\.isEnabled) private var isEnabled

  var body: some View {
    label()
      .foregroundStyle(EsuissColor.foreground)
      .frame(width: size, height: size)
      .background(filled ? EsuissColor.secondary : .clear, in: Circle())
      .opacity(isEnabled ? (pressed ? 0.8 : 1) : 0.5)
      .contentShape(Circle())
      .animation(.easeOut(duration: 0.12), value: pressed)
  }
}

private struct ShakeModifier: ViewModifier {
  let trigger: Int

  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  func body(content: Content) -> some View {
    let still = reduceMotion
    content.keyframeAnimator(initialValue: CGFloat(0), trigger: trigger) { view, offset in
      view.offset(x: still ? 0 : offset)
    } keyframes: { _ in
      KeyframeTrack {
        LinearKeyframe(-8, duration: 0.08)
        LinearKeyframe(7, duration: 0.08)
        LinearKeyframe(-5, duration: 0.08)
        LinearKeyframe(3, duration: 0.08)
        LinearKeyframe(0, duration: 0.08, timingCurve: .easeOut)
      }
    }
  }
}

extension View {
  func shake(trigger: Int) -> some View {
    modifier(ShakeModifier(trigger: trigger))
  }
}
```

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

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

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

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

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

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

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

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

3. Copy the source and every file it uses:

```kt title="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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Backspace
import com.adamglin.phosphoricons.regular.Fingerprint
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

private val KeypadKeySize = 72.dp

private val KeypadColumnGap = 24.dp

private val KeypadRowGap = 16.dp

private val KeypadRows = listOf(listOf(1, 2, 3), listOf(4, 5, 6), listOf(7, 8, 9))

sealed interface KeypadKey {
    data class Digit(val value: Int) : KeypadKey

    data object Decimal : KeypadKey

    data object Biometric : KeypadKey

    data object Delete : KeypadKey
}

sealed interface KeypadAccessory {
    data object None : KeypadAccessory

    data class Decimal(
        val separator: String = ".",
        val label: String = "Decimal separator",
    ) : KeypadAccessory

    data class Biometric(
        val icon: ImageVector = PhosphorIcons.Regular.Fingerprint,
        val label: String = "Use biometrics",
    ) : KeypadAccessory
}

@Composable
fun Keypad(
    onKeyPress: (KeypadKey) -> Unit,
    modifier: Modifier = Modifier,
    accessory: KeypadAccessory = KeypadAccessory.None,
    deleteLabel: String = "Delete",
    enabled: Boolean = true,
) {
    Column(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(KeypadRowGap),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        KeypadRows.forEach { row ->
            Row(horizontalArrangement = Arrangement.spacedBy(KeypadColumnGap)) {
                row.forEach { digit -> KeypadDigit(digit, enabled, onKeyPress) }
            }
        }
        Row(horizontalArrangement = Arrangement.spacedBy(KeypadColumnGap)) {
            when (accessory) {
                KeypadAccessory.None -> Box(Modifier.size(KeypadKeySize))
                is KeypadAccessory.Decimal -> KeypadButton(
                    onClick = { onKeyPress(KeypadKey.Decimal) },
                    enabled = enabled,
                    filled = true,
                    contentDescription = accessory.label,
                ) {
                    KeypadText(accessory.separator)
                }
                is KeypadAccessory.Biometric -> KeypadButton(
                    onClick = { onKeyPress(KeypadKey.Biometric) },
                    enabled = enabled,
                    filled = false,
                    contentDescription = accessory.label,
                ) {
                    Icon(accessory.icon, contentDescription = null, size = 32.dp)
                }
            }
            KeypadDigit(0, enabled, onKeyPress)
            KeypadButton(
                onClick = { onKeyPress(KeypadKey.Delete) },
                enabled = enabled,
                filled = false,
                contentDescription = deleteLabel,
            ) {
                Icon(PhosphorIcons.Regular.Backspace, contentDescription = null, size = 28.dp)
            }
        }
    }
}

@Composable
private fun KeypadDigit(digit: Int, enabled: Boolean, onKeyPress: (KeypadKey) -> Unit) {
    KeypadButton(onClick = { onKeyPress(KeypadKey.Digit(digit)) }, enabled = enabled, filled = true) {
        KeypadText(digit.toString())
    }
}

@Composable
private fun KeypadText(text: String) {
    BasicText(
        text,
        style = Typography.xl2.copy(
            color = LocalContentColor.current,
            fontWeight = FontWeight.Medium,
            fontFeatureSettings = "tnum",
        ),
    )
}

@Composable
private fun KeypadButton(
    onClick: () -> Unit,
    enabled: Boolean,
    filled: Boolean,
    contentDescription: String? = null,
    content: @Composable () -> Unit,
) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val haptics = rememberHaptics()
    val background = if (filled) colors.secondary else Color.Transparent
    val foreground = colors.foreground
    Box(
        modifier = Modifier
            .size(KeypadKeySize)
            .alpha(
                when {
                    !enabled -> 0.5f
                    pressed -> 0.8f
                    else -> 1f
                },
            )
            .clip(CircleShape)
            .background(background, CircleShape)
            .clickable(
                interactionSource = interaction,
                indication = null,
                enabled = enabled,
                role = Role.Button,
            ) {
                haptics.selection()
                onClick()
            }
            .then(
                if (contentDescription != null) {
                    Modifier.semantics { this.contentDescription = contentDescription }
                } else {
                    Modifier
                },
            ),
        contentAlignment = Alignment.Center,
    ) {
        CompositionLocalProvider(LocalContentColor provides foreground) {
            content()
        }
    }
}

internal suspend fun Animatable<Float, AnimationVector1D>.shake() {
    snapTo(0f)
    animateTo(
        0f,
        keyframes {
            durationMillis = 400
            -8f at 60
            7f at 140
            -5f at 220
            3f at 300
            0f at 400
        },
    )
}
```

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

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

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

## Usage

```tsx
import { KeypadKey, Keypad } from "@/components/ui/keypad"
```

```tsx
<KeyLog />
```

Handle input in `onKeyPress`, which receives a digit string (`"0"` to `"9"`), `"decimal"`, `"delete"` or `"biometric"`. The keypad holds no value of its own.

Set `accessoryKey` to `decimal` or `biometric` to fill the bottom-left slot; otherwise it stays empty. Change the decimal character with `decimalSeparator` and the biometric icon with `biometricIcon`.

Use `deleteDisabled` to turn off only the delete key, for example when the value is empty. `KeypadKey` is exported for custom layouts, along with the `KeypadDigit`, `KeypadKeyValue` and `KeypadAccessoryKey` types.

## Examples

### Decimal

A keypad with a decimal separator key.

![Preview](https://ui.suiss.app/previews/components-keypad--decimal.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Keypad, type KeypadAccessoryKey, type KeypadKeyValue } from "@/components/ui/keypad"
import { Text } from "@/components/ui/text"

function KeyLog({
  accessoryKey,
  disabled,
}: {
  accessoryKey?: KeypadAccessoryKey
  disabled?: boolean
}) {
  const [last, setLast] = React.useState<KeypadKeyValue | null>(null)
  return (
    <View className="items-center gap-6">
      <Text
        role="status"
        accessibilityLiveRegion="polite"
        className="text-sm text-muted-foreground"
      >
        {last ? `Last key: ${last}` : "Press a key"}
      </Text>
      <Keypad
        accessoryKey={accessoryKey}
        disabled={disabled}
        onKeyPress={setLast}
      />
    </View>
  )
}

export function KeypadDecimalExample() {
  return (
    <KeyLog accessoryKey="decimal" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct KeypadDecimalExample: View {
  var body: some View {
    KeypadLog(accessory: .decimal("."))
  }
}

struct KeypadLog: View {
  let accessory: KeypadAccessory

  @State private var last: KeypadKey?

  var body: some View {
    VStack(spacing: 24) {
      Group {
        if let last {
          Text(verbatim: "Last key: \(name(last))")
        } else {
          Text("Press a key")
        }
      }
      .textStyle(.sm)
      .foregroundStyle(EsuissColor.mutedForeground)
      .accessibilityAddTraits(.updatesFrequently)
      Keypad(accessory: accessory) { key in
        last = key
      }
    }
    .frame(maxWidth: .infinity)
  }

  func name(_ key: KeypadKey) -> String {
    switch key {
    case .digit(let value): "\(value)"
    case .decimal: "decimal"
    case .delete: "delete"
    case .biometric: "biometric"
    }
  }
}
```

```kotlin
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.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.Keypad
import com.esuiss.ui.components.KeypadAccessory
import com.esuiss.ui.components.KeypadKey
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun KeypadDecimalExample() {
    KeypadKeyLog(KeypadAccessory.Decimal())
}

fun keypadKeyName(key: KeypadKey): String = when (key) {
    is KeypadKey.Digit -> key.value.toString()
    KeypadKey.Decimal -> "decimal"
    KeypadKey.Biometric -> "biometric"
    KeypadKey.Delete -> "delete"
}

@Composable
fun KeypadKeyLog(accessory: KeypadAccessory, enabled: Boolean = true) {
    var last by rememberSaveable { mutableStateOf<String?>(null) }
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(24.dp),
    ) {
        Text(
            last?.let { "Last key: $it" } ?: "Press a key",
            modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm,
            color = EsuissTheme.colors.mutedForeground,
        )
        Keypad(onKeyPress = { last = keypadKeyName(it) }, accessory = accessory, enabled = enabled)
    }
}
```

### Biometric

A keypad with a biometric key.

![Preview](https://ui.suiss.app/previews/components-keypad--biometric.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Keypad, type KeypadAccessoryKey, type KeypadKeyValue } from "@/components/ui/keypad"
import { Text } from "@/components/ui/text"

function KeyLog({
  accessoryKey,
  disabled,
}: {
  accessoryKey?: KeypadAccessoryKey
  disabled?: boolean
}) {
  const [last, setLast] = React.useState<KeypadKeyValue | null>(null)
  return (
    <View className="items-center gap-6">
      <Text
        role="status"
        accessibilityLiveRegion="polite"
        className="text-sm text-muted-foreground"
      >
        {last ? `Last key: ${last}` : "Press a key"}
      </Text>
      <Keypad
        accessoryKey={accessoryKey}
        disabled={disabled}
        onKeyPress={setLast}
      />
    </View>
  )
}

export function KeypadBiometricExample() {
  return (
    <KeyLog accessoryKey="biometric" />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct KeypadBiometricExample: View {
  var body: some View {
    KeypadLog(accessory: .biometric(Ph.fingerprint.regular))
  }
}

struct KeypadLog: View {
  let accessory: KeypadAccessory

  @State private var last: KeypadKey?

  var body: some View {
    VStack(spacing: 24) {
      Group {
        if let last {
          Text(verbatim: "Last key: \(name(last))")
        } else {
          Text("Press a key")
        }
      }
      .textStyle(.sm)
      .foregroundStyle(EsuissColor.mutedForeground)
      .accessibilityAddTraits(.updatesFrequently)
      Keypad(accessory: accessory) { key in
        last = key
      }
    }
    .frame(maxWidth: .infinity)
  }

  func name(_ key: KeypadKey) -> String {
    switch key {
    case .digit(let value): "\(value)"
    case .decimal: "decimal"
    case .delete: "delete"
    case .biometric: "biometric"
    }
  }
}
```

```kotlin
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.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.Keypad
import com.esuiss.ui.components.KeypadAccessory
import com.esuiss.ui.components.KeypadKey
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun KeypadBiometricExample() {
    KeypadKeyLog(KeypadAccessory.Biometric())
}

fun keypadKeyName(key: KeypadKey): String = when (key) {
    is KeypadKey.Digit -> key.value.toString()
    KeypadKey.Decimal -> "decimal"
    KeypadKey.Biometric -> "biometric"
    KeypadKey.Delete -> "delete"
}

@Composable
fun KeypadKeyLog(accessory: KeypadAccessory, enabled: Boolean = true) {
    var last by rememberSaveable { mutableStateOf<String?>(null) }
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(24.dp),
    ) {
        Text(
            last?.let { "Last key: $it" } ?: "Press a key",
            modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm,
            color = EsuissTheme.colors.mutedForeground,
        )
        Keypad(onKeyPress = { last = keypadKeyName(it) }, accessory = accessory, enabled = enabled)
    }
}
```

### Disabled

A fully disabled keypad.

![Preview](https://ui.suiss.app/previews/components-keypad--disabled.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Keypad, type KeypadAccessoryKey, type KeypadKeyValue } from "@/components/ui/keypad"
import { Text } from "@/components/ui/text"

function KeyLog({
  accessoryKey,
  disabled,
}: {
  accessoryKey?: KeypadAccessoryKey
  disabled?: boolean
}) {
  const [last, setLast] = React.useState<KeypadKeyValue | null>(null)
  return (
    <View className="items-center gap-6">
      <Text
        role="status"
        accessibilityLiveRegion="polite"
        className="text-sm text-muted-foreground"
      >
        {last ? `Last key: ${last}` : "Press a key"}
      </Text>
      <Keypad
        accessoryKey={accessoryKey}
        disabled={disabled}
        onKeyPress={setLast}
      />
    </View>
  )
}

export function KeypadDisabledExample() {
  return (
    <KeyLog accessoryKey="biometric" disabled />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct KeypadDisabledExample: View {
  var body: some View {
    KeypadLog(accessory: .biometric(Ph.fingerprint.regular))
      .disabled(true)
  }
}

struct KeypadLog: View {
  let accessory: KeypadAccessory

  @State private var last: KeypadKey?

  var body: some View {
    VStack(spacing: 24) {
      Group {
        if let last {
          Text(verbatim: "Last key: \(name(last))")
        } else {
          Text("Press a key")
        }
      }
      .textStyle(.sm)
      .foregroundStyle(EsuissColor.mutedForeground)
      .accessibilityAddTraits(.updatesFrequently)
      Keypad(accessory: accessory) { key in
        last = key
      }
    }
    .frame(maxWidth: .infinity)
  }

  func name(_ key: KeypadKey) -> String {
    switch key {
    case .digit(let value): "\(value)"
    case .decimal: "decimal"
    case .delete: "delete"
    case .biometric: "biometric"
    }
  }
}
```

```kotlin
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.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.Keypad
import com.esuiss.ui.components.KeypadAccessory
import com.esuiss.ui.components.KeypadKey
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun KeypadDisabledExample() {
    KeypadKeyLog(KeypadAccessory.Biometric(), enabled = false)
}

fun keypadKeyName(key: KeypadKey): String = when (key) {
    is KeypadKey.Digit -> key.value.toString()
    KeypadKey.Decimal -> "decimal"
    KeypadKey.Biometric -> "biometric"
    KeypadKey.Delete -> "delete"
}

@Composable
fun KeypadKeyLog(accessory: KeypadAccessory, enabled: Boolean = true) {
    var last by rememberSaveable { mutableStateOf<String?>(null) }
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(24.dp),
    ) {
        Text(
            last?.let { "Last key: $it" } ?: "Press a key",
            modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm,
            color = EsuissTheme.colors.mutedForeground,
        )
        Keypad(onKeyPress = { last = keypadKeyName(it) }, accessory = accessory, enabled = enabled)
    }
}
```

## API Reference

### KeypadKey

A single round key that passes text and icon styles to its children.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "plain"` | `"default"` | `default` with a filled background, or `plain` without one. |
| `className` | `string` | - |  |
| `children` | `React.ReactNode` | - |  |

Also accepts the props of `React.ComponentProps<typeof Pressable>`.

### Keypad

The full grid of keys.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onKeyPress` | `(key: KeypadKeyValue) => void` | - | Called with the pressed key value. |
| `accessoryKey` | `KeypadAccessoryKey` | - | Bottom-left key: `decimal` or `biometric`. |
| `decimalSeparator` | `string` | `"."` | Character shown on the decimal key. Defaults to `.`. |
| `biometricIcon` | `PhosphorIcon` | `FingerprintIcon` | Phosphor icon for the biometric key. Defaults to `FingerprintIcon`. |
| `biometricLabel` | `string` | `"Use biometrics"` | Accessibility label for the biometric key. |
| `decimalLabel` | `string` | `"Decimal separator"` | Accessibility label for the decimal key. |
| `deleteLabel` | `string` | `"Delete"` | Accessibility label for the delete key. |
| `disabled` | `boolean` | `false` | Disables every key. |
| `deleteDisabled` | `boolean` | `false` | Disables only the delete key. |
| `keyClassName` | `string` | - | Extra classes applied to each key. |

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

## Accessibility

- Every key has the `button` role and an `accessibilityLabel`: the digit itself, or `decimalLabel`, `biometricLabel` and `deleteLabel` for the other keys.
- Disabled keys set `disabled` in their accessibility state and block presses.
- Translate the default labels (`Decimal separator`, `Use biometrics`, `Delete`) for localized apps.

## Notes

- Filled keys use `bg-secondary`, `opacity-80` when pressed and `opacity-50` when disabled.
- Digits use `text-2xl` with tabular numbers.
- Light and dark colors follow the system appearance.

