# Amount Input

A large, locale-formatted money display with an on-screen keypad for entering an amount.

Amount Input 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/amount-input

**React Native, SwiftUI, Jetpack Compose**

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

```tsx
import { AmountInput } from "@/components/ui/amount-input"

export function AmountInputExample() {
  return (
    <AmountInput currency="USD" locale="en-US" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct AmountInputExample: View {
  var body: some View {
    AmountInputDemo(currency: "USD", locale: "en-US")
  }
}

struct AmountInputDemo: View {
  let currency: String
  let locale: String
  var max: Double?

  @State private var value: String

  init(currency: String, locale: String, initial: String = "", max: Double? = nil) {
    self.currency = currency
    self.locale = locale
    self.max = max
    _value = State(initialValue: initial)
  }

  var body: some View {
    AmountInput(value: $value, currency: currency, max: max)
      .environment(\.locale, Locale(identifier: locale))
  }
}
```

```kotlin
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.Modifier
import com.esuiss.ui.components.AmountInput
import java.util.Locale

@Composable
fun AmountInputExample() {
    AmountInputStory("USD", "en-US")
}

@Composable
fun AmountInputStory(
    currency: String,
    locale: String,
    defaultValue: String = "",
    max: Double? = null,
    enabled: Boolean = true,
) {
    var value by rememberSaveable { mutableStateOf(defaultValue) }
    AmountInput(
        modifier = Modifier.fillMaxWidth(),
        value = value,
        onValueChange = { next, _ -> value = next },
        currency = currency,
        locale = Locale.forLanguageTag(locale),
        max = max,
        enabled = enabled,
    )
}
```

`AmountInput` combines a big formatted amount with a `Keypad`. The amount is formatted with `Intl.NumberFormat` for the given `locale` and `currency`, the currency symbol is drawn smaller, and the font size steps down as the number gets longer.

The decimal key appears only when the currency allows fractions, using the locale's decimal separator. A key that would exceed the limits is rejected with a shake and an error haptic; accepted keys play a selection haptic.

## Installation

**React Native**

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

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 amount-input
```

**SwiftUI**

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

**Jetpack Compose**

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

The CLI also adds what it builds on: [Keypad](https://ui.suiss.app/docs/components/keypad), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn react-native-reanimated react-native-worklets
```

2. Add the pieces it builds on: [Keypad](https://ui.suiss.app/docs/components/keypad), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/haptics.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

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

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/amount-input.tsx`:

```tsx title="uniwind/components/ui/amount-input.tsx"
import { cn } from "cn"
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 { AnimatedView } from "@/components/ui/animated-view"
import { Keypad, type KeypadKeyValue } from "@/components/ui/keypad"
import { Text } from "@/components/ui/text"

type AmountPart = { type: string; value: string }

const MAX_DIGITS = 15
const AMOUNT_PATTERN = /^(\D*?)(\d[\d\s.,'\u2019\u00a0\u202f]*\d|\d)(\D*)$/
const DECIMAL_PATTERN = /1(\D+)5/
const NON_NUMERIC_PATTERN = /[^\d.]/g
const LEADING_ZEROS_PATTERN = /^0+(?=\d)/

const SHAKE_STEP = 400 / 6

function createFormatter(
  locale: string | undefined,
  currency: string | undefined,
  fractionDigits: number
) {
  const digits = {
    minimumFractionDigits: fractionDigits,
    maximumFractionDigits: fractionDigits,
  }
  try {
    return new Intl.NumberFormat(
      locale,
      currency ? { ...digits, style: "currency", currency } : digits
    )
  } catch {
    return new Intl.NumberFormat(undefined, digits)
  }
}

function toParts(formatter: Intl.NumberFormat, amount: number): AmountPart[] {
  if (typeof formatter.formatToParts === "function") {
    return formatter.formatToParts(amount)
  }
  const text = formatter.format(amount)
  const match = text.match(AMOUNT_PATTERN)
  if (!match) return [{ type: "integer", value: text }]
  const [, prefix = "", number = "", suffix = ""] = match
  const parts: AmountPart[] = []
  if (prefix.trim()) parts.push({ type: "currency", value: prefix.trim() })
  if (prefix !== prefix.trimEnd()) parts.push({ type: "literal", value: " " })
  parts.push({ type: "integer", value: number })
  if (suffix !== suffix.trimStart()) parts.push({ type: "literal", value: " " })
  if (suffix.trim()) parts.push({ type: "currency", value: suffix.trim() })
  return parts
}

function currencyFractionDigits(
  locale: string | undefined,
  currency: string | undefined
) {
  if (!currency) return 2
  try {
    return (
      new Intl.NumberFormat(locale, {
        style: "currency",
        currency,
      }).resolvedOptions().maximumFractionDigits ?? 2
    )
  } catch {
    return 2
  }
}

function decimalSeparatorOf(locale: string | undefined) {
  const formatter = createFormatter(locale, undefined, 1)
  const parts = toParts(formatter, 1.5)
  const decimal = parts.find((part) => part.type === "decimal")?.value
  if (decimal) return decimal
  return formatter.format(1.5).match(DECIMAL_PATTERN)?.[1] ?? "."
}

function toAmount(value: string) {
  if (!value || value === ".") return 0
  const amount = Number(value)
  return Number.isFinite(amount) ? amount : 0
}

function sanitize(value: string, fractionDigits: number) {
  const [integer = "", ...rest] = value
    .replace(NON_NUMERIC_PATTERN, "")
    .split(".")
  const fraction = rest.join("").slice(0, fractionDigits)
  const trimmed = integer.replace(LEADING_ZEROS_PATTERN, "")
  if (fractionDigits === 0 || rest.length === 0) return trimmed
  return `${trimmed || "0"}.${fraction}`
}

function nextValue(
  current: string,
  key: KeypadKeyValue,
  fractionDigits: number,
  max: number | undefined
) {
  if (key === "biometric") return current
  if (key === "delete") return current.slice(0, -1)
  const [integer = "", fraction] = current.split(".")
  if (key === "decimal") {
    if (fractionDigits === 0 || fraction !== undefined) return current
    return `${integer || "0"}.`
  }
  if (fraction !== undefined && fraction.length >= fractionDigits) {
    return current
  }
  if (integer.length + (fraction?.length ?? 0) >= MAX_DIGITS) return current
  const next =
    fraction === undefined && integer === "0" ? key : `${current}${key}`
  if (max !== undefined && toAmount(next) > max) return current
  return next
}

function formatParts(
  value: string,
  locale: string | undefined,
  currency: string | undefined,
  decimalSeparator: string
) {
  const fraction = value.split(".")[1]
  const fractionDigits = fraction?.length ?? 0
  const parts = toParts(
    createFormatter(locale, currency, fractionDigits),
    toAmount(value)
  )
  if (fraction === undefined || fractionDigits > 0) return parts
  let last = -1
  parts.forEach((part, index) => {
    if (part.type === "integer" || part.type === "group") last = index
  })
  const decimal = { type: "decimal", value: decimalSeparator }
  if (last === -1) return [...parts, decimal]
  return [...parts.slice(0, last + 1), decimal, ...parts.slice(last + 1)]
}

function amountSizeClassName(characters: number) {
  if (characters > 11) return { amount: "text-4xl", symbol: "text-2xl" }
  if (characters > 8) return { amount: "text-5xl", symbol: "text-3xl" }
  return { amount: "text-6xl", symbol: "text-4xl" }
}

function AmountInput({
  value: valueProp,
  defaultValue = "",
  onValueChange,
  currency,
  locale,
  maxFractionDigits: maxFractionDigitsProp,
  max,
  placeholder = "0",
  disabled = false,
  deleteLabel,
  decimalLabel,
  className,
  amountClassName,
  keypadClassName,
  accessibilityLabel,
  ...props
}: React.ComponentProps<typeof View> & {
  value?: string
  defaultValue?: string
  onValueChange?: (value: string, amount: number) => void
  currency?: string
  locale?: string
  maxFractionDigits?: number
  max?: number
  placeholder?: string
  disabled?: boolean
  deleteLabel?: string
  decimalLabel?: string
  amountClassName?: string
  keypadClassName?: string
}) {
  const haptics = useHaptics()
  const maxFractionDigits = Math.max(
    0,
    maxFractionDigitsProp ?? currencyFractionDigits(locale, currency)
  )
  const decimalSeparator = React.useMemo(
    () => decimalSeparatorOf(locale),
    [locale]
  )
  const [uncontrolled, setUncontrolled] = React.useState(() =>
    sanitize(defaultValue, maxFractionDigits)
  )
  const isControlled = valueProp !== undefined
  const value = isControlled ? valueProp : uncontrolled
  const valueRef = React.useRef(value)
  valueRef.current = value

  const offset = useSharedValue(0)

  const reject = () => {
    haptics.notification("error")
    const step = { duration: SHAKE_STEP, reduceMotion: ReduceMotion.System }
    offset.value = withSequence(
      withTiming(-12, step),
      withTiming(10, step),
      withTiming(-8, step),
      withTiming(6, step),
      withTiming(-3, step),
      withTiming(0, step)
    )
  }

  const shakeStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: offset.value }],
  }))

  const press = (key: KeypadKeyValue) => {
    const current = valueRef.current
    const next = nextValue(current, key, maxFractionDigits, max)
    if (next === current) {
      if (key !== "delete" && key !== "biometric" && current !== "0") {
        reject()
      }
      return
    }
    valueRef.current = next
    if (!isControlled) setUncontrolled(next)
    onValueChange?.(next, toAmount(next))
    haptics.selection()
  }

  const empty = value === ""
  const parts = empty
    ? formatParts("0", locale, currency, decimalSeparator).map((part) =>
        part.type === "integer" ? { ...part, value: placeholder } : part
      )
    : formatParts(value, locale, currency, decimalSeparator)
  const text = parts.map((part) => part.value).join("")
  const size = amountSizeClassName(text.length)
  const color = empty || disabled ? "text-muted-foreground" : "text-foreground"

  return (
    <View className={cn("items-center gap-8", className)} {...props}>
      <AnimatedView
        className="h-18 w-full items-center justify-center px-4"
        style={shakeStyle}
      >
        <Text
          numberOfLines={1}
          adjustsFontSizeToFit
          minimumFontScale={0.5}
          accessibilityLabel={
            accessibilityLabel ? `${accessibilityLabel}, ${text}` : text
          }
          accessibilityLiveRegion="polite"
          className={cn(
            "font-semibold tabular-nums tracking-tight",
            size.amount,
            color,
            amountClassName
          )}
        >
          {parts.map((part) =>
            part.type === "currency" ? (
              <Text
                key="currency"
                className={cn("font-semibold", size.symbol, color)}
              >
                {part.value}
              </Text>
            ) : (
              part.value
            )
          )}
        </Text>
      </AnimatedView>
      <Keypad
        accessoryKey={maxFractionDigits > 0 ? "decimal" : undefined}
        decimalSeparator={decimalSeparator}
        decimalLabel={decimalLabel}
        deleteLabel={deleteLabel}
        disabled={disabled}
        deleteDisabled={empty}
        onKeyPress={press}
        className={keypadClassName}
      />
    </View>
  )
}

export { AmountInput }
```

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn react-native-reanimated react-native-worklets
```

2. Add the pieces it builds on: [Keypad](https://ui.suiss.app/docs/components/keypad), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/haptics.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

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

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/amount-input.tsx`:

```tsx title="nativewind/components/ui/amount-input.tsx"
import { cn } from "cn"
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 { AnimatedView } from "@/components/ui/animated-view"
import { Keypad, type KeypadKeyValue } from "@/components/ui/keypad"
import { Text } from "@/components/ui/text"

type AmountPart = { type: string; value: string }

const MAX_DIGITS = 15
const AMOUNT_PATTERN = /^(\D*?)(\d[\d\s.,'\u2019\u00a0\u202f]*\d|\d)(\D*)$/
const DECIMAL_PATTERN = /1(\D+)5/
const NON_NUMERIC_PATTERN = /[^\d.]/g
const LEADING_ZEROS_PATTERN = /^0+(?=\d)/

const SHAKE_STEP = 400 / 6

function createFormatter(
  locale: string | undefined,
  currency: string | undefined,
  fractionDigits: number
) {
  const digits = {
    minimumFractionDigits: fractionDigits,
    maximumFractionDigits: fractionDigits,
  }
  try {
    return new Intl.NumberFormat(
      locale,
      currency ? { ...digits, style: "currency", currency } : digits
    )
  } catch {
    return new Intl.NumberFormat(undefined, digits)
  }
}

function toParts(formatter: Intl.NumberFormat, amount: number): AmountPart[] {
  if (typeof formatter.formatToParts === "function") {
    return formatter.formatToParts(amount)
  }
  const text = formatter.format(amount)
  const match = text.match(AMOUNT_PATTERN)
  if (!match) return [{ type: "integer", value: text }]
  const [, prefix = "", number = "", suffix = ""] = match
  const parts: AmountPart[] = []
  if (prefix.trim()) parts.push({ type: "currency", value: prefix.trim() })
  if (prefix !== prefix.trimEnd()) parts.push({ type: "literal", value: " " })
  parts.push({ type: "integer", value: number })
  if (suffix !== suffix.trimStart()) parts.push({ type: "literal", value: " " })
  if (suffix.trim()) parts.push({ type: "currency", value: suffix.trim() })
  return parts
}

function currencyFractionDigits(
  locale: string | undefined,
  currency: string | undefined
) {
  if (!currency) return 2
  try {
    return (
      new Intl.NumberFormat(locale, {
        style: "currency",
        currency,
      }).resolvedOptions().maximumFractionDigits ?? 2
    )
  } catch {
    return 2
  }
}

function decimalSeparatorOf(locale: string | undefined) {
  const formatter = createFormatter(locale, undefined, 1)
  const parts = toParts(formatter, 1.5)
  const decimal = parts.find((part) => part.type === "decimal")?.value
  if (decimal) return decimal
  return formatter.format(1.5).match(DECIMAL_PATTERN)?.[1] ?? "."
}

function toAmount(value: string) {
  if (!value || value === ".") return 0
  const amount = Number(value)
  return Number.isFinite(amount) ? amount : 0
}

function sanitize(value: string, fractionDigits: number) {
  const [integer = "", ...rest] = value
    .replace(NON_NUMERIC_PATTERN, "")
    .split(".")
  const fraction = rest.join("").slice(0, fractionDigits)
  const trimmed = integer.replace(LEADING_ZEROS_PATTERN, "")
  if (fractionDigits === 0 || rest.length === 0) return trimmed
  return `${trimmed || "0"}.${fraction}`
}

function nextValue(
  current: string,
  key: KeypadKeyValue,
  fractionDigits: number,
  max: number | undefined
) {
  if (key === "biometric") return current
  if (key === "delete") return current.slice(0, -1)
  const [integer = "", fraction] = current.split(".")
  if (key === "decimal") {
    if (fractionDigits === 0 || fraction !== undefined) return current
    return `${integer || "0"}.`
  }
  if (fraction !== undefined && fraction.length >= fractionDigits) {
    return current
  }
  if (integer.length + (fraction?.length ?? 0) >= MAX_DIGITS) return current
  const next =
    fraction === undefined && integer === "0" ? key : `${current}${key}`
  if (max !== undefined && toAmount(next) > max) return current
  return next
}

function formatParts(
  value: string,
  locale: string | undefined,
  currency: string | undefined,
  decimalSeparator: string
) {
  const fraction = value.split(".")[1]
  const fractionDigits = fraction?.length ?? 0
  const parts = toParts(
    createFormatter(locale, currency, fractionDigits),
    toAmount(value)
  )
  if (fraction === undefined || fractionDigits > 0) return parts
  let last = -1
  parts.forEach((part, index) => {
    if (part.type === "integer" || part.type === "group") last = index
  })
  const decimal = { type: "decimal", value: decimalSeparator }
  if (last === -1) return [...parts, decimal]
  return [...parts.slice(0, last + 1), decimal, ...parts.slice(last + 1)]
}

function amountSizeClassName(characters: number) {
  if (characters > 11) return { amount: "text-4xl", symbol: "text-2xl" }
  if (characters > 8) return { amount: "text-5xl", symbol: "text-3xl" }
  return { amount: "text-6xl", symbol: "text-4xl" }
}

function AmountInput({
  value: valueProp,
  defaultValue = "",
  onValueChange,
  currency,
  locale,
  maxFractionDigits: maxFractionDigitsProp,
  max,
  placeholder = "0",
  disabled = false,
  deleteLabel,
  decimalLabel,
  className,
  amountClassName,
  keypadClassName,
  accessibilityLabel,
  ...props
}: React.ComponentProps<typeof View> & {
  value?: string
  defaultValue?: string
  onValueChange?: (value: string, amount: number) => void
  currency?: string
  locale?: string
  maxFractionDigits?: number
  max?: number
  placeholder?: string
  disabled?: boolean
  deleteLabel?: string
  decimalLabel?: string
  amountClassName?: string
  keypadClassName?: string
}) {
  const haptics = useHaptics()
  const maxFractionDigits = Math.max(
    0,
    maxFractionDigitsProp ?? currencyFractionDigits(locale, currency)
  )
  const decimalSeparator = React.useMemo(
    () => decimalSeparatorOf(locale),
    [locale]
  )
  const [uncontrolled, setUncontrolled] = React.useState(() =>
    sanitize(defaultValue, maxFractionDigits)
  )
  const isControlled = valueProp !== undefined
  const value = isControlled ? valueProp : uncontrolled
  const valueRef = React.useRef(value)
  valueRef.current = value

  const offset = useSharedValue(0)

  const reject = () => {
    haptics.notification("error")
    const step = { duration: SHAKE_STEP, reduceMotion: ReduceMotion.System }
    offset.value = withSequence(
      withTiming(-12, step),
      withTiming(10, step),
      withTiming(-8, step),
      withTiming(6, step),
      withTiming(-3, step),
      withTiming(0, step)
    )
  }

  const shakeStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: offset.value }],
  }))

  const press = (key: KeypadKeyValue) => {
    const current = valueRef.current
    const next = nextValue(current, key, maxFractionDigits, max)
    if (next === current) {
      if (key !== "delete" && key !== "biometric" && current !== "0") {
        reject()
      }
      return
    }
    valueRef.current = next
    if (!isControlled) setUncontrolled(next)
    onValueChange?.(next, toAmount(next))
    haptics.selection()
  }

  const empty = value === ""
  const parts = empty
    ? formatParts("0", locale, currency, decimalSeparator).map((part) =>
        part.type === "integer" ? { ...part, value: placeholder } : part
      )
    : formatParts(value, locale, currency, decimalSeparator)
  const text = parts.map((part) => part.value).join("")
  const size = amountSizeClassName(text.length)
  const color = empty || disabled ? "text-muted-foreground" : "text-foreground"

  return (
    <View className={cn("items-center gap-8", className)} {...props}>
      <AnimatedView
        className="h-[4.5rem] w-full items-center justify-center px-4"
        style={shakeStyle}
      >
        <Text
          numberOfLines={1}
          adjustsFontSizeToFit
          minimumFontScale={0.5}
          accessibilityLabel={
            accessibilityLabel ? `${accessibilityLabel}, ${text}` : text
          }
          accessibilityLiveRegion="polite"
          className={cn(
            "font-semibold tabular-nums tracking-tight",
            size.amount,
            color,
            amountClassName
          )}
        >
          {parts.map((part) =>
            part.type === "currency" ? (
              <Text
                key="currency"
                className={cn("font-semibold", size.symbol, color)}
              >
                {part.value}
              </Text>
            ) : (
              part.value
            )
          )}
        </Text>
      </AnimatedView>
      <Keypad
        accessoryKey={maxFractionDigits > 0 ? "decimal" : undefined}
        decimalSeparator={decimalSeparator}
        decimalLabel={decimalLabel}
        deleteLabel={deleteLabel}
        disabled={disabled}
        deleteDisabled={empty}
        onKeyPress={press}
        className={keypadClassName}
      />
    </View>
  )
}

export { AmountInput }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install react-native-reanimated react-native-worklets
```

2. Add the pieces it builds on: [Keypad](https://ui.suiss.app/docs/components/keypad), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/haptics.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

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

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/amount-input.tsx`:

```tsx title="stylesheet/components/ui/amount-input.tsx"
import * as React from "react"
import {
  type StyleProp,
  StyleSheet,
  type TextStyle,
  View,
  type ViewStyle,
} from "react-native"
import {
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withSequence,
  withTiming,
} from "react-native-reanimated"
import { useHaptics } from "@/components/primitives/haptics"
import { AnimatedView } from "@/components/ui/animated-view"
import { Keypad, type KeypadKeyValue } from "@/components/ui/keypad"
import { Text } from "@/components/ui/text"
import { fontSize, useTheme } from "@/components/ui/theme"

type AmountPart = { type: string; value: string }

const MAX_DIGITS = 15

const SHAKE_STEP = 400 / 6

const AMOUNT_PATTERN = /^(\D*?)(\d[\d\s.,'\u2019\u00a0\u202f]*\d|\d)(\D*)$/

const DECIMAL_PATTERN = /1(\D+)5/

const LEADING_ZEROS = /^0+(?=\d)/

function createFormatter(
  locale: string | undefined,
  currency: string | undefined,
  fractionDigits: number
) {
  const digits = {
    minimumFractionDigits: fractionDigits,
    maximumFractionDigits: fractionDigits,
  }
  try {
    return new Intl.NumberFormat(
      locale,
      currency ? { ...digits, style: "currency", currency } : digits
    )
  } catch {
    return new Intl.NumberFormat(undefined, digits)
  }
}

function toParts(formatter: Intl.NumberFormat, amount: number): AmountPart[] {
  if (typeof formatter.formatToParts === "function") {
    return formatter.formatToParts(amount)
  }
  const text = formatter.format(amount)
  const match = text.match(AMOUNT_PATTERN)
  if (!match) return [{ type: "integer", value: text }]
  const [, prefix = "", number = "", suffix = ""] = match
  const parts: AmountPart[] = []
  if (prefix.trim()) parts.push({ type: "currency", value: prefix.trim() })
  if (prefix !== prefix.trimEnd()) parts.push({ type: "literal", value: " " })
  parts.push({ type: "integer", value: number })
  if (suffix !== suffix.trimStart()) parts.push({ type: "literal", value: " " })
  if (suffix.trim()) parts.push({ type: "currency", value: suffix.trim() })
  return parts
}

function currencyFractionDigits(
  locale: string | undefined,
  currency: string | undefined
) {
  if (!currency) return 2
  try {
    return (
      new Intl.NumberFormat(locale, {
        style: "currency",
        currency,
      }).resolvedOptions().maximumFractionDigits ?? 2
    )
  } catch {
    return 2
  }
}

function decimalSeparatorOf(locale: string | undefined) {
  const formatter = createFormatter(locale, undefined, 1)
  const parts = toParts(formatter, 1.5)
  const decimal = parts.find((part) => part.type === "decimal")?.value
  if (decimal) return decimal
  return formatter.format(1.5).match(DECIMAL_PATTERN)?.[1] ?? "."
}

function toAmount(value: string) {
  if (!value || value === ".") return 0
  const amount = Number(value)
  return Number.isFinite(amount) ? amount : 0
}

function sanitize(value: string, fractionDigits: number) {
  const [integer = "", ...rest] = value.replace(/[^\d.]/g, "").split(".")
  const fraction = rest.join("").slice(0, fractionDigits)
  const trimmed = integer.replace(LEADING_ZEROS, "")
  if (fractionDigits === 0 || rest.length === 0) return trimmed
  return `${trimmed || "0"}.${fraction}`
}

function nextValue(
  current: string,
  key: KeypadKeyValue,
  fractionDigits: number,
  max: number | undefined
) {
  if (key === "biometric") return current
  if (key === "delete") return current.slice(0, -1)
  const [integer = "", fraction] = current.split(".")
  if (key === "decimal") {
    if (fractionDigits === 0 || fraction !== undefined) return current
    return `${integer || "0"}.`
  }
  if (fraction !== undefined && fraction.length >= fractionDigits) {
    return current
  }
  if (integer.length + (fraction?.length ?? 0) >= MAX_DIGITS) return current
  const next =
    fraction === undefined && integer === "0" ? key : `${current}${key}`
  if (max !== undefined && toAmount(next) > max) return current
  return next
}

function formatParts(
  value: string,
  locale: string | undefined,
  currency: string | undefined,
  decimalSeparator: string
) {
  const fraction = value.split(".")[1]
  const fractionDigits = fraction?.length ?? 0
  const parts = toParts(
    createFormatter(locale, currency, fractionDigits),
    toAmount(value)
  )
  if (fraction === undefined || fractionDigits > 0) return parts
  let last = -1
  parts.forEach((part, index) => {
    if (part.type === "integer" || part.type === "group") last = index
  })
  const decimal = { type: "decimal", value: decimalSeparator }
  if (last === -1) return [...parts, decimal]
  return [...parts.slice(0, last + 1), decimal, ...parts.slice(last + 1)]
}

const styles = StyleSheet.create({
  root: { alignItems: "center", gap: 32 },
  display: {
    height: 72,
    width: "100%",
    alignItems: "center",
    justifyContent: "center",
    paddingHorizontal: 16,
  },
  amount: { fontWeight: "600", fontVariant: ["tabular-nums"] },
  amountLarge: { fontSize: 60, lineHeight: 60, letterSpacing: -1.5 },
  amountMedium: { fontSize: 48, lineHeight: 48, letterSpacing: -1.2 },
  amountSmall: { fontSize: 36, lineHeight: 40, letterSpacing: -0.9 },
  symbol: { fontWeight: "600" },
  symbolLarge: { fontSize: 36, lineHeight: 40 },
  symbolMedium: { ...fontSize["3xl"] },
  symbolSmall: { ...fontSize["2xl"] },
})

function amountSizeStyle(characters: number) {
  if (characters > 11) {
    return { amount: styles.amountSmall, symbol: styles.symbolSmall }
  }
  if (characters > 8) {
    return { amount: styles.amountMedium, symbol: styles.symbolMedium }
  }
  return { amount: styles.amountLarge, symbol: styles.symbolLarge }
}

function AmountInput({
  value: valueProp,
  defaultValue = "",
  onValueChange,
  currency,
  locale,
  maxFractionDigits: maxFractionDigitsProp,
  max,
  placeholder = "0",
  disabled = false,
  deleteLabel,
  decimalLabel,
  style,
  amountStyle,
  keypadStyle,
  accessibilityLabel,
  ...props
}: React.ComponentProps<typeof View> & {
  value?: string
  defaultValue?: string
  onValueChange?: (value: string, amount: number) => void
  currency?: string
  locale?: string
  maxFractionDigits?: number
  max?: number
  placeholder?: string
  disabled?: boolean
  deleteLabel?: string
  decimalLabel?: string
  amountStyle?: StyleProp<TextStyle>
  keypadStyle?: StyleProp<ViewStyle>
}) {
  const { colors } = useTheme()
  const haptics = useHaptics()
  const maxFractionDigits = Math.max(
    0,
    maxFractionDigitsProp ?? currencyFractionDigits(locale, currency)
  )
  const decimalSeparator = React.useMemo(
    () => decimalSeparatorOf(locale),
    [locale]
  )
  const [uncontrolled, setUncontrolled] = React.useState(() =>
    sanitize(defaultValue, maxFractionDigits)
  )
  const isControlled = valueProp !== undefined
  const value = isControlled ? valueProp : uncontrolled
  const valueRef = React.useRef(value)
  valueRef.current = value

  const offset = useSharedValue(0)

  const reject = () => {
    haptics.notification("error")
    const step = { duration: SHAKE_STEP, reduceMotion: ReduceMotion.System }
    offset.value = withSequence(
      withTiming(-12, step),
      withTiming(10, step),
      withTiming(-8, step),
      withTiming(6, step),
      withTiming(-3, step),
      withTiming(0, step)
    )
  }

  const shakeStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: offset.value }],
  }))

  const press = (key: KeypadKeyValue) => {
    const current = valueRef.current
    const next = nextValue(current, key, maxFractionDigits, max)
    if (next === current) {
      if (key !== "delete" && key !== "biometric" && current !== "0") {
        reject()
      }
      return
    }
    valueRef.current = next
    if (!isControlled) setUncontrolled(next)
    onValueChange?.(next, toAmount(next))
    haptics.selection()
  }

  const empty = value === ""
  const parts = empty
    ? formatParts("0", locale, currency, decimalSeparator).map((part) =>
        part.type === "integer" ? { ...part, value: placeholder } : part
      )
    : formatParts(value, locale, currency, decimalSeparator)
  const text = parts.map((part) => part.value).join("")
  const size = amountSizeStyle(text.length)
  const color = empty || disabled ? colors.mutedForeground : colors.foreground

  return (
    <View style={[styles.root, style]} {...props}>
      <AnimatedView style={[styles.display, shakeStyle]}>
        <Text
          numberOfLines={1}
          adjustsFontSizeToFit
          minimumFontScale={0.5}
          accessibilityLabel={
            accessibilityLabel ? `${accessibilityLabel}, ${text}` : text
          }
          accessibilityLiveRegion="polite"
          style={[styles.amount, size.amount, { color }, amountStyle]}
        >
          {parts.map((part) =>
            part.type === "currency" ? (
              <Text
                key="currency"
                style={[styles.symbol, size.symbol, { color }]}
              >
                {part.value}
              </Text>
            ) : (
              part.value
            )
          )}
        </Text>
      </AnimatedView>
      <Keypad
        accessoryKey={maxFractionDigits > 0 ? "decimal" : undefined}
        decimalSeparator={decimalSeparator}
        decimalLabel={decimalLabel}
        deleteLabel={deleteLabel}
        disabled={disabled}
        deleteDisabled={empty}
        onKeyPress={press}
        style={keypadStyle}
      />
    </View>
  )
}

export { AmountInput }
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

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

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

4. Copy the source and every file it uses:

```swift title="swift/Components/AmountInput.swift"
import SwiftUI

struct AmountFormat {
  let locale: Locale
  let currency: String?
  let fractionDigits: Int
  let decimalSeparator: String
  let prefix: String
  let suffix: String

  init(locale: Locale, currency: String?, maxFractionDigits: Int? = nil) {
    let formatter = NumberFormatter()
    formatter.locale = locale
    formatter.numberStyle = currency == nil ? .decimal : .currency
    if let currency { formatter.currencyCode = currency }
    self.locale = locale
    self.currency = currency
    fractionDigits = max(0, maxFractionDigits ?? (currency == nil ? 2 : formatter.maximumFractionDigits))
    decimalSeparator = formatter.decimalSeparator ?? "."
    prefix = currency == nil ? "" : formatter.positivePrefix ?? ""
    suffix = currency == nil ? "" : formatter.positiveSuffix ?? ""
  }

  func number(_ value: String) -> String {
    let parts = value.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: false)
    let integer = parts.first.map(String.init) ?? ""
    let fraction = parts.count > 1 ? String(parts[1]) : ""
    let formatter = NumberFormatter()
    formatter.locale = locale
    formatter.numberStyle = .decimal
    formatter.usesGroupingSeparator = true
    formatter.minimumFractionDigits = fraction.count
    formatter.maximumFractionDigits = fraction.count
    let source = (integer.isEmpty ? "0" : integer) + (fraction.isEmpty ? "" : "." + fraction)
    let decimal = NSDecimalNumber(string: source, locale: Locale(identifier: "en_US_POSIX"))
    var text = formatter.string(from: decimal == .notANumber ? 0 : decimal) ?? source
    if parts.count > 1 && fraction.isEmpty {
      text += formatter.decimalSeparator ?? "."
    }
    return text
  }

  func spoken(_ value: String) -> String {
    prefix + number(value.isEmpty ? "0" : value) + suffix
  }
}

enum AmountMath {
  static let maxIntegerDigits = 15

  static func amount(_ value: String) -> Double {
    Double(value) ?? 0
  }

  static func rejects(_ key: KeypadKey, value: String) -> Bool {
    switch key {
    case .digit(0): value != "0"
    case .digit, .decimal: true
    case .delete, .biometric: false
    }
  }

  static func press(_ key: KeypadKey, value: String, fractionDigits: Int, max: Double?) -> String? {
    let next: String
    switch key {
    case .digit(let digit):
      if let dot = value.firstIndex(of: ".") {
        guard value[value.index(after: dot)...].count < fractionDigits else { return nil }
        next = value + String(digit)
      } else if value == "0" {
        guard digit != 0 else { return nil }
        next = String(digit)
      } else {
        guard value.count < maxIntegerDigits else { return nil }
        next = value + String(digit)
      }
    case .decimal:
      guard fractionDigits > 0, !value.contains(".") else { return nil }
      next = (value.isEmpty ? "0" : value) + "."
    case .delete:
      guard !value.isEmpty else { return nil }
      next = String(value.dropLast())
    case .biometric:
      return nil
    }
    if let max, amount(next) > max { return nil }
    return next
  }
}

struct AmountDisplay: View {
  let value: String
  let format: AmountFormat
  let placeholder: String
  let label: LocalizedStringKey

  @Environment(\.isEnabled) private var isEnabled
  @ScaledMetric(relativeTo: .largeTitle) private var scale: CGFloat = 1

  var body: some View {
    let empty = value.isEmpty
    Text(text(empty: empty))
      .monospacedDigit()
      .lineLimit(1)
      .minimumScaleFactor(0.3)
      .opacity(isEnabled ? 1 : 0.5)
      .frame(maxWidth: .infinity)
      .accessibilityElement(children: .ignore)
      .accessibilityLabel(Text(label))
      .accessibilityValue(Text(verbatim: format.spoken(value)))
  }

  private func text(empty: Bool) -> AttributedString {
    let symbolFont = Font.system(size: TextStyle.xl3.size * scale, weight: .semibold)
    let numberFont = Font.system(size: 60 * scale, weight: .semibold)
    let symbolColor: Color = EsuissColor.mutedForeground
    let numberColor: Color = empty ? EsuissColor.mutedForeground : EsuissColor.foreground
    var prefix = AttributedString(format.prefix)
    prefix.font = symbolFont
    prefix.foregroundColor = symbolColor
    var number = AttributedString(empty ? placeholder : format.number(value))
    number.font = numberFont
    number.foregroundColor = numberColor
    var suffix = AttributedString(format.suffix)
    suffix.font = symbolFont
    suffix.foregroundColor = symbolColor
    return prefix + number + suffix
  }
}

public struct AmountInput: View {
  @Binding var value: String
  let currency: String?
  let maxFractionDigits: Int?
  let max: Double?
  let placeholder: String
  let label: LocalizedStringKey
  let deleteLabel: LocalizedStringKey
  let decimalLabel: LocalizedStringKey
  let onValueChange: ((String, Double) -> Void)?

  @Environment(\.locale) private var locale
  @Environment(\.isEnabled) private var isEnabled
  @State private var rejections = 0

  public init(
    value: Binding<String>,
    currency: String? = nil,
    maxFractionDigits: Int? = nil,
    max: Double? = nil,
    placeholder: String = "0",
    label: LocalizedStringKey = "Amount",
    deleteLabel: LocalizedStringKey = "Delete",
    decimalLabel: LocalizedStringKey = "Decimal separator",
    onValueChange: ((String, Double) -> Void)? = nil
  ) {
    _value = value
    self.currency = currency
    self.maxFractionDigits = maxFractionDigits
    self.max = max
    self.placeholder = placeholder
    self.label = label
    self.deleteLabel = deleteLabel
    self.decimalLabel = decimalLabel
    self.onValueChange = onValueChange
  }

  public var body: some View {
    let format = AmountFormat(locale: locale, currency: currency, maxFractionDigits: maxFractionDigits)
    VStack(spacing: 32) {
      AmountDisplay(value: value, format: format, placeholder: placeholder, label: label)
        .shake(trigger: rejections)
      Keypad(
        accessory: format.fractionDigits > 0 ? .decimal(format.decimalSeparator, label: decimalLabel) : .none,
        deleteLabel: deleteLabel,
        canDelete: !value.isEmpty
      ) { key in
        guard isEnabled else { return false }
        guard let next = AmountMath.press(key, value: value, fractionDigits: format.fractionDigits, max: max) else {
          if AmountMath.rejects(key, value: value) { rejections += 1 }
          return false
        }
        value = next
        onValueChange?(next, AmountMath.amount(next))
        return true
      }
    }
    .hapticFeedback(.error, trigger: rejections)
  }
}
```

- `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/Keypad.swift`: see [Keypad](https://ui.suiss.app/docs/components/keypad.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/AmountInput.kt"
package com.esuiss.ui.components

import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale
import kotlinx.coroutines.launch

private const val AmountMaxIntegerDigits = 15

private const val AmountFontSize = 60f

private const val AmountMinFontSize = 28f

private class AmountFormat(
    val prefix: String,
    val suffix: String,
    val decimal: Char,
    val fractionDigits: Int,
    val integer: NumberFormat,
)

private fun amountFormat(currency: String?, locale: Locale, maxFractionDigits: Int?): AmountFormat {
    val integer = NumberFormat.getIntegerInstance(locale).apply { isGroupingUsed = true }
    if (currency == null) {
        return AmountFormat(
            prefix = "",
            suffix = "",
            decimal = DecimalFormatSymbols.getInstance(locale).decimalSeparator,
            fractionDigits = (maxFractionDigits ?: 2).coerceAtLeast(0),
            integer = integer,
        )
    }
    val code = Currency.getInstance(currency)
    val format = NumberFormat.getCurrencyInstance(locale).apply { this.currency = code }
    val decimal = format as? DecimalFormat
    return AmountFormat(
        prefix = decimal?.positivePrefix ?: code.getSymbol(locale),
        suffix = decimal?.positiveSuffix ?: "",
        decimal = decimal?.decimalFormatSymbols?.monetaryDecimalSeparator
            ?: DecimalFormatSymbols.getInstance(locale).decimalSeparator,
        fractionDigits = (maxFractionDigits ?: code.defaultFractionDigits).coerceAtLeast(0),
        integer = integer,
    )
}

private fun AmountFormat.number(value: String): String {
    val whole = value.substringBefore('.')
    val grouped = whole.toLongOrNull()?.let { integer.format(it) } ?: whole
    return if (value.contains('.')) grouped + decimal + value.substringAfter('.') else grouped
}

private fun amountOf(value: String): Double = value.trimEnd('.').toDoubleOrNull() ?: 0.0

@Composable
fun AmountInput(
    modifier: Modifier = Modifier,
    value: String? = null,
    onValueChange: ((value: String, amount: Double) -> Unit)? = null,
    defaultValue: String = "",
    currency: String? = null,
    locale: Locale = Locale.getDefault(),
    maxFractionDigits: Int? = null,
    max: Double? = null,
    placeholder: String = "0",
    enabled: Boolean = true,
    decimalLabel: String = "Decimal separator",
    deleteLabel: String = "Delete",
) {
    val colors = EsuissTheme.colors
    val format = remember(currency, locale, maxFractionDigits) { amountFormat(currency, locale, maxFractionDigits) }
    var internal by remember { mutableStateOf(defaultValue) }
    val current = value ?: internal
    val haptics = rememberHaptics()
    val reduced = formReducedMotion()
    val offset = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()
    val reject = {
        haptics.perform(HapticFeedbackType.Reject)
        if (!reduced) scope.launch { offset.shake() }
    }
    val update: (String) -> Unit = { next ->
        if (next != current) {
            if (max != null && amountOf(next) > max) {
                reject()
            } else {
                if (value == null) internal = next
                onValueChange?.invoke(next, amountOf(next))
            }
        }
    }
    val empty = current.isEmpty()
    val number = if (empty) placeholder else format.number(current)
    val foreground = if (empty) colors.mutedForeground else colors.foreground
    val symbol = colors.mutedForeground
    val text: AnnotatedString = buildAnnotatedString {
        if (format.prefix.isNotEmpty()) withStyle(SpanStyle(fontSize = 0.5.em, color = symbol)) { append(format.prefix) }
        append(number)
        if (format.suffix.isNotEmpty()) withStyle(SpanStyle(fontSize = 0.5.em, color = symbol)) { append(format.suffix) }
    }
    val base = Typography.xl3.copy(
        color = foreground,
        fontSize = AmountFontSize.sp,
        lineHeight = 1.1.em,
        letterSpacing = 0.em,
        fontWeight = FontWeight.SemiBold,
        fontFeatureSettings = "tnum",
        textAlign = TextAlign.Center,
    )
    val measurer = rememberTextMeasurer()
    Column(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(32.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        BoxWithConstraints(
            modifier = Modifier
                .fillMaxWidth()
                .height(72.dp)
                .graphicsLayer {
                    translationX = offset.value.dp.toPx()
                    alpha = if (enabled) 1f else 0.5f
                }
                .clearAndSetSemantics {
                    contentDescription = text.text
                    liveRegion = LiveRegionMode.Polite
                },
            contentAlignment = Alignment.Center,
        ) {
            val available = constraints.maxWidth
            val width = remember(text, base, available) {
                measurer.measure(text, base.copy(textAlign = TextAlign.Start), maxLines = 1, softWrap = false).size.width
            }
            val fontSize = if (width > available && width > 0) {
                (AmountFontSize * available / width).coerceAtLeast(AmountMinFontSize)
            } else {
                AmountFontSize
            }
            BasicText(text, style = base.copy(fontSize = fontSize.sp), maxLines = 1, softWrap = false)
        }
        Keypad(
            onKeyPress = { key ->
                when (key) {
                    is KeypadKey.Digit -> {
                        val digit = key.value.toString()
                        val fraction = current.substringAfter('.', "")
                        val whole = current.substringBefore('.')
                        when {
                            current.contains('.') -> if (fraction.length < format.fractionDigits) update(current + digit) else reject()
                            whole == "0" -> update(digit)
                            whole.length >= AmountMaxIntegerDigits -> reject()
                            else -> update(current + digit)
                        }
                    }
                    KeypadKey.Decimal -> when {
                        format.fractionDigits == 0 || current.contains('.') -> reject()
                        current.isEmpty() -> update("0.")
                        else -> update("$current.")
                    }
                    KeypadKey.Delete -> if (current.isNotEmpty()) update(current.dropLast(1))
                    KeypadKey.Biometric -> Unit
                }
            },
            accessory = if (format.fractionDigits > 0) KeypadAccessory.Decimal(format.decimal.toString(), decimalLabel) else KeypadAccessory.None,
            deleteLabel = deleteLabel,
            enabled = enabled,
        )
    }
}
```

- `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/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

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

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

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

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

## Usage

```tsx
import { AmountInput } from "@/components/ui/amount-input"
```

```tsx
<AmountInput currency="USD" locale="en-US" />
```

The value is a plain numeric string such as `"1250.5"`. Use `defaultValue` for uncontrolled state, or `value` with `onValueChange`, which receives the string and the parsed number.

Fraction digits default to the currency's own precision (for example 0 for JPY); override with `maxFractionDigits`. Use `max` to cap the amount. Input is limited to 15 digits.

Pass `deleteLabel` and `decimalLabel` to localize the keypad's spoken labels.

## Examples

### Filled

Starts with a value through `defaultValue`.

![Preview](https://ui.suiss.app/previews/components-amount-input--filled.light.webp)

```tsx
import { AmountInput } from "@/components/ui/amount-input"

export function AmountInputFilledExample() {
  return (
    <AmountInput currency="USD" locale="en-US" defaultValue="1250.5" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct AmountInputFilledExample: View {
  var body: some View {
    AmountInputDemo(currency: "USD", locale: "en-US", initial: "1250.5")
  }
}

struct AmountInputDemo: View {
  let currency: String
  let locale: String
  var max: Double?

  @State private var value: String

  init(currency: String, locale: String, initial: String = "", max: Double? = nil) {
    self.currency = currency
    self.locale = locale
    self.max = max
    _value = State(initialValue: initial)
  }

  var body: some View {
    AmountInput(value: $value, currency: currency, max: max)
      .environment(\.locale, Locale(identifier: locale))
  }
}
```

```kotlin
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.Modifier
import com.esuiss.ui.components.AmountInput
import java.util.Locale

@Composable
fun AmountInputFilledExample() {
    AmountInputStory("USD", "en-US", "1250.5")
}

@Composable
fun AmountInputStory(
    currency: String,
    locale: String,
    defaultValue: String = "",
    max: Double? = null,
    enabled: Boolean = true,
) {
    var value by rememberSaveable { mutableStateOf(defaultValue) }
    AmountInput(
        modifier = Modifier.fillMaxWidth(),
        value = value,
        onValueChange = { next, _ -> value = next },
        currency = currency,
        locale = Locale.forLanguageTag(locale),
        max = max,
        enabled = enabled,
    )
}
```

### Controlled

Controlled with `value` and `onValueChange`.

![Preview](https://ui.suiss.app/previews/components-amount-input--controlled.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { AmountInput } from "@/components/ui/amount-input"
import { Text } from "@/components/ui/text"

function ControlledDemo() {
  const [value, setValue] = React.useState("")
  const [amount, setAmount] = React.useState(0)
  return (
    <View className="items-center gap-4">
      <AmountInput
        currency="USD"
        locale="en-US"
        value={value}
        onValueChange={(next, nextAmount) => {
          setValue(next)
          setAmount(nextAmount)
        }}
      />
      <Text className="text-sm text-muted-foreground">
        {`value "${value}" · amount ${amount}`}
      </Text>
    </View>
  )
}

export function AmountInputControlledExample() {
  return (
    <ControlledDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct AmountInputControlledDemo: View {
  @State private var value = ""
  @State private var amount: Double = 0

  var body: some View {
    VStack(spacing: 16) {
      AmountInput(value: $value, currency: "USD") { _, next in
        amount = next
      }
      .environment(\.locale, Locale(identifier: "en-US"))
      Text(verbatim: "value \"\(value)\" · amount \(amountText)")
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
    }
    .frame(maxWidth: .infinity)
  }

  var amountText: String {
    amount.rounded() == amount && abs(amount) < 1e15 ? String(Int64(amount)) : String(amount)
  }
}
```

```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.mutableDoubleStateOf
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.unit.dp
import com.esuiss.ui.components.AmountInput
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
import java.util.Locale

@Composable
fun AmountInputControlled() {
    var value by rememberSaveable { mutableStateOf("") }
    var amount by rememberSaveable { mutableDoubleStateOf(0.0) }
    Column(
        Modifier.fillMaxWidth(),
        verticalArrangement = Arrangement.spacedBy(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        AmountInput(
            modifier = Modifier.fillMaxWidth(),
            value = value,
            onValueChange = { next, nextAmount ->
                value = next
                amount = nextAmount
            },
            currency = "USD",
            locale = Locale.forLanguageTag("en-US"),
        )
        val shown = if (amount % 1.0 == 0.0) amount.toLong().toString() else amount.toString()
        Text(
            "value \"$value\" · amount $shown",
            style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
        )
    }
}
```

### Long amount

A long amount that shrinks to fit.

![Preview](https://ui.suiss.app/previews/components-amount-input--long-amount.light.webp)

```tsx
import { AmountInput } from "@/components/ui/amount-input"

export function AmountInputLongAmountExample() {
  return (
    <AmountInput currency="USD" locale="en-US" defaultValue="98765432109.99" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct AmountInputLongAmountExample: View {
  var body: some View {
    AmountInputDemo(currency: "USD", locale: "en-US", initial: "98765432109.99")
  }
}

struct AmountInputDemo: View {
  let currency: String
  let locale: String
  var max: Double?

  @State private var value: String

  init(currency: String, locale: String, initial: String = "", max: Double? = nil) {
    self.currency = currency
    self.locale = locale
    self.max = max
    _value = State(initialValue: initial)
  }

  var body: some View {
    AmountInput(value: $value, currency: currency, max: max)
      .environment(\.locale, Locale(identifier: locale))
  }
}
```

```kotlin
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.Modifier
import com.esuiss.ui.components.AmountInput
import java.util.Locale

@Composable
fun AmountInputLongAmountExample() {
    AmountInputStory("USD", "en-US", "98765432109.99")
}

@Composable
fun AmountInputStory(
    currency: String,
    locale: String,
    defaultValue: String = "",
    max: Double? = null,
    enabled: Boolean = true,
) {
    var value by rememberSaveable { mutableStateOf(defaultValue) }
    AmountInput(
        modifier = Modifier.fillMaxWidth(),
        value = value,
        onValueChange = { next, _ -> value = next },
        currency = currency,
        locale = Locale.forLanguageTag(locale),
        max = max,
        enabled = enabled,
    )
}
```

### Euro

EUR in the `de-DE` locale with a comma decimal separator.

![Preview](https://ui.suiss.app/previews/components-amount-input--euro.light.webp)

```tsx
import { AmountInput } from "@/components/ui/amount-input"

export function AmountInputEuroExample() {
  return (
    <AmountInput currency="EUR" locale="de-DE" defaultValue="1234.5" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct AmountInputEuroExample: View {
  var body: some View {
    AmountInputDemo(currency: "EUR", locale: "de-DE", initial: "1234.5")
  }
}

struct AmountInputDemo: View {
  let currency: String
  let locale: String
  var max: Double?

  @State private var value: String

  init(currency: String, locale: String, initial: String = "", max: Double? = nil) {
    self.currency = currency
    self.locale = locale
    self.max = max
    _value = State(initialValue: initial)
  }

  var body: some View {
    AmountInput(value: $value, currency: currency, max: max)
      .environment(\.locale, Locale(identifier: locale))
  }
}
```

```kotlin
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.Modifier
import com.esuiss.ui.components.AmountInput
import java.util.Locale

@Composable
fun AmountInputEuroExample() {
    AmountInputStory("EUR", "de-DE", "1234.5")
}

@Composable
fun AmountInputStory(
    currency: String,
    locale: String,
    defaultValue: String = "",
    max: Double? = null,
    enabled: Boolean = true,
) {
    var value by rememberSaveable { mutableStateOf(defaultValue) }
    AmountInput(
        modifier = Modifier.fillMaxWidth(),
        value = value,
        onValueChange = { next, _ -> value = next },
        currency = currency,
        locale = Locale.forLanguageTag(locale),
        max = max,
        enabled = enabled,
    )
}
```

### Turkish lira

TRY in the `tr-TR` locale.

![Preview](https://ui.suiss.app/previews/components-amount-input--turkish-lira.light.webp)

```tsx
import { AmountInput } from "@/components/ui/amount-input"

export function AmountInputTurkishLiraExample() {
  return (
    <AmountInput currency="TRY" locale="tr-TR" defaultValue="2500.75" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct AmountInputTurkishLiraExample: View {
  var body: some View {
    AmountInputDemo(currency: "TRY", locale: "tr-TR", initial: "2500.75")
  }
}

struct AmountInputDemo: View {
  let currency: String
  let locale: String
  var max: Double?

  @State private var value: String

  init(currency: String, locale: String, initial: String = "", max: Double? = nil) {
    self.currency = currency
    self.locale = locale
    self.max = max
    _value = State(initialValue: initial)
  }

  var body: some View {
    AmountInput(value: $value, currency: currency, max: max)
      .environment(\.locale, Locale(identifier: locale))
  }
}
```

```kotlin
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.Modifier
import com.esuiss.ui.components.AmountInput
import java.util.Locale

@Composable
fun AmountInputTurkishLiraExample() {
    AmountInputStory("TRY", "tr-TR", "2500.75")
}

@Composable
fun AmountInputStory(
    currency: String,
    locale: String,
    defaultValue: String = "",
    max: Double? = null,
    enabled: Boolean = true,
) {
    var value by rememberSaveable { mutableStateOf(defaultValue) }
    AmountInput(
        modifier = Modifier.fillMaxWidth(),
        value = value,
        onValueChange = { next, _ -> value = next },
        currency = currency,
        locale = Locale.forLanguageTag(locale),
        max = max,
        enabled = enabled,
    )
}
```

### Yen

JPY, which has no decimal key.

![Preview](https://ui.suiss.app/previews/components-amount-input--yen.light.webp)

```tsx
import { AmountInput } from "@/components/ui/amount-input"

export function AmountInputYenExample() {
  return (
    <AmountInput currency="JPY" locale="ja-JP" defaultValue="15000" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct AmountInputYenExample: View {
  var body: some View {
    AmountInputDemo(currency: "JPY", locale: "ja-JP", initial: "15000")
  }
}

struct AmountInputDemo: View {
  let currency: String
  let locale: String
  var max: Double?

  @State private var value: String

  init(currency: String, locale: String, initial: String = "", max: Double? = nil) {
    self.currency = currency
    self.locale = locale
    self.max = max
    _value = State(initialValue: initial)
  }

  var body: some View {
    AmountInput(value: $value, currency: currency, max: max)
      .environment(\.locale, Locale(identifier: locale))
  }
}
```

```kotlin
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.Modifier
import com.esuiss.ui.components.AmountInput
import java.util.Locale

@Composable
fun AmountInputYenExample() {
    AmountInputStory("JPY", "ja-JP", "15000")
}

@Composable
fun AmountInputStory(
    currency: String,
    locale: String,
    defaultValue: String = "",
    max: Double? = null,
    enabled: Boolean = true,
) {
    var value by rememberSaveable { mutableStateOf(defaultValue) }
    AmountInput(
        modifier = Modifier.fillMaxWidth(),
        value = value,
        onValueChange = { next, _ -> value = next },
        currency = currency,
        locale = Locale.forLanguageTag(locale),
        max = max,
        enabled = enabled,
    )
}
```

### With max

Capped at 500 with `max`; larger entries are rejected.

![Preview](https://ui.suiss.app/previews/components-amount-input--with-max.light.webp)

```tsx
import { AmountInput } from "@/components/ui/amount-input"

export function AmountInputWithMaxExample() {
  return (
    <AmountInput currency="USD" locale="en-US" max={500} />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct AmountInputWithMaxExample: View {
  var body: some View {
    AmountInputDemo(currency: "USD", locale: "en-US", max: 500)
  }
}

struct AmountInputDemo: View {
  let currency: String
  let locale: String
  var max: Double?

  @State private var value: String

  init(currency: String, locale: String, initial: String = "", max: Double? = nil) {
    self.currency = currency
    self.locale = locale
    self.max = max
    _value = State(initialValue: initial)
  }

  var body: some View {
    AmountInput(value: $value, currency: currency, max: max)
      .environment(\.locale, Locale(identifier: locale))
  }
}
```

```kotlin
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.Modifier
import com.esuiss.ui.components.AmountInput
import java.util.Locale

@Composable
fun AmountInputWithMaxExample() {
    AmountInputStory("USD", "en-US", max = 500.0)
}

@Composable
fun AmountInputStory(
    currency: String,
    locale: String,
    defaultValue: String = "",
    max: Double? = null,
    enabled: Boolean = true,
) {
    var value by rememberSaveable { mutableStateOf(defaultValue) }
    AmountInput(
        modifier = Modifier.fillMaxWidth(),
        value = value,
        onValueChange = { next, _ -> value = next },
        currency = currency,
        locale = Locale.forLanguageTag(locale),
        max = max,
        enabled = enabled,
    )
}
```

### Disabled

A disabled input with a dimmed amount and keypad.

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

```tsx
import { AmountInput } from "@/components/ui/amount-input"

export function AmountInputDisabledExample() {
  return (
    <AmountInput currency="USD" locale="en-US" defaultValue="42" disabled />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct AmountInputDisabledExample: View {
  var body: some View {
    AmountInputDemo(currency: "USD", locale: "en-US", initial: "42")
      .disabled(true)
  }
}

struct AmountInputDemo: View {
  let currency: String
  let locale: String
  var max: Double?

  @State private var value: String

  init(currency: String, locale: String, initial: String = "", max: Double? = nil) {
    self.currency = currency
    self.locale = locale
    self.max = max
    _value = State(initialValue: initial)
  }

  var body: some View {
    AmountInput(value: $value, currency: currency, max: max)
      .environment(\.locale, Locale(identifier: locale))
  }
}
```

```kotlin
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.Modifier
import com.esuiss.ui.components.AmountInput
import java.util.Locale

@Composable
fun AmountInputDisabledExample() {
    AmountInputStory("USD", "en-US", "42", enabled = false)
}

@Composable
fun AmountInputStory(
    currency: String,
    locale: String,
    defaultValue: String = "",
    max: Double? = null,
    enabled: Boolean = true,
) {
    var value by rememberSaveable { mutableStateOf(defaultValue) }
    AmountInput(
        modifier = Modifier.fillMaxWidth(),
        value = value,
        onValueChange = { next, _ -> value = next },
        currency = currency,
        locale = Locale.forLanguageTag(locale),
        max = max,
        enabled = enabled,
    )
}
```

## API Reference

### AmountInput

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` | - | Controlled numeric string. |
| `defaultValue` | `string` | `""` | Initial numeric string when uncontrolled. |
| `onValueChange` | `(value: string, amount: number) => void` | - | Called with the new string and its numeric amount. |
| `currency` | `string` | - | ISO currency code used for formatting. |
| `locale` | `string` | - | Locale used for formatting and the decimal separator. |
| `maxFractionDigits` | `number` | - | Maximum digits after the decimal separator. |
| `max` | `number` | - | Largest allowed amount. |
| `placeholder` | `string` | `"0"` | Shown in place of the integer when empty. Defaults to `"0"`. |
| `disabled` | `boolean` | `false` | Dims the amount and disables the keypad. |
| `deleteLabel` | `string` | - | Accessibility label for the delete key. |
| `decimalLabel` | `string` | - | Accessibility label for the decimal key. |
| `amountClassName` | `string` | - | Extra classes for the amount text. |
| `keypadClassName` | `string` | - | Extra classes for the keypad. |

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

## Accessibility

- The amount text is a polite live region, so changes are announced.
- Its label is the formatted amount, prefixed with `accessibilityLabel` when given.
- Keypad keys announce their digit; the delete key is disabled while the value is empty.

## Notes

- The shake animation respects the system Reduce Motion setting.
- Uses tabular numerals so digits do not shift as they change.
- Uses `text-foreground` and `text-muted-foreground` tokens; light and dark follow the system appearance.

