# Stepper

A numeric field with minus and plus buttons that step the value, accelerating while held.

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

Source: https://ui.suiss.app/docs/interactions/stepper

**React**

**Example**

```tsx
import {
  Stepper,
  StepperDecrement,
  StepperGroup,
  StepperIncrement,
  StepperInput,
  StepperSeparator,
} from "@/components/interactions/stepper"

export default function StepperDemo() {
  return (
    <Stepper defaultValue={1} min={0} max={10}>
      <StepperInput aria-label="Quantity" />
      <StepperGroup>
        <StepperDecrement />
        <StepperSeparator />
        <StepperIncrement />
      </StepperGroup>
    </Stepper>
  )
}
```

`Stepper` recreates the iOS stepper on top of a number field primitive. It pairs an editable `StepperInput` with a segmented `StepperGroup` of `StepperDecrement` and `StepperIncrement` buttons. Each press changes the value by `step`; holding a button repeats the change after a short delay and speeds up over time. Each button disables itself when the value reaches `min` or `max`.

Use it for small, bounded quantities such as copies, servings, or scale, where people usually adjust a value by a few steps.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/stepper
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add interactions stepper
```

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

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

2. Copy the source into `components/interactions/stepper.tsx`:

```tsx title="components/interactions/stepper.tsx"
"use client"

import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"
import { MinusIcon, PlusIcon } from "@phosphor-icons/react"
import * as React from "react"
import { cn } from "@/lib/utils"

type StepperContextProps = {
  canChange: (direction: 1 | -1) => boolean
  change: (direction: 1 | -1) => boolean
}

const StepperContext = React.createContext<StepperContextProps | null>(null)

function useStepper() {
  const context = React.useContext(StepperContext)

  if (!context) {
    throw new Error("useStepper must be used within a <Stepper />")
  }

  return context
}

const START_DELAY = 400
const FIRST_TICK = 180
const MIN_TICK = 35
const ACCELERATION = 0.86

function decimals(number: number) {
  const [mantissa = "", exponent = "0"] = String(number)
    .toLowerCase()
    .split("e")
  const fraction = mantissa.split(".")[1]?.length ?? 0
  return Math.max(0, fraction - Number(exponent))
}

function Stepper({
  value: valueProp,
  defaultValue = 0,
  onValueChange,
  min = Number.NEGATIVE_INFINITY,
  max = Number.POSITIVE_INFINITY,
  step = 1,
  disabled = false,
  readOnly = false,
  className,
  ...props
}: Omit<
  NumberFieldPrimitive.Root.Props,
  "value" | "defaultValue" | "onValueChange" | "step"
> & {
  step?: number
  value?: number
  defaultValue?: number
  onValueChange?: (value: number) => void
}) {
  const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue)
  const value = valueProp ?? uncontrolledValue
  const valueRef = React.useRef(value)
  valueRef.current = value

  const setValue = React.useCallback(
    (next: number) => {
      valueRef.current = next
      if (valueProp === undefined) setUncontrolledValue(next)
      onValueChange?.(next)
    },
    [valueProp, onValueChange]
  )

  const canChange = React.useCallback(
    (direction: 1 | -1) =>
      !disabled &&
      !readOnly &&
      (direction === 1 ? valueRef.current < max : valueRef.current > min),
    [disabled, readOnly, min, max]
  )

  const change = React.useCallback(
    (direction: 1 | -1) => {
      if (!canChange(direction)) return false
      const precision = Math.max(decimals(step), decimals(valueRef.current))
      const next = Number(
        (valueRef.current + direction * step).toFixed(precision)
      )
      setValue(Math.min(Math.max(next, min), max))
      return true
    },
    [canChange, step, min, max, setValue]
  )

  return (
    <StepperContext.Provider value={{ canChange, change }}>
      <NumberFieldPrimitive.Root
        data-slot="stepper"
        value={value}
        onValueChange={(next) => {
          if (next !== null) setValue(next)
        }}
        min={min}
        max={max}
        step={step}
        disabled={disabled}
        readOnly={readOnly}
        className={cn("flex items-center gap-3", className)}
        {...props}
      />
    </StepperContext.Provider>
  )
}

function StepperInput({
  className,
  ...props
}: NumberFieldPrimitive.Input.Props) {
  return (
    <NumberFieldPrimitive.Input
      data-slot="stepper-input"
      className={cn(
        "h-8 w-16 min-w-0 rounded-md bg-transparent px-1 text-end text-base tabular-nums outline-none focus-visible:ring-3 focus-visible:ring-ring/50 data-disabled:opacity-50",
        className
      )}
      {...props}
    />
  )
}

function StepperGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      role="group"
      data-slot="stepper-group"
      className={cn(
        "inline-flex h-8 shrink-0 items-center gap-[3px] rounded-lg bg-secondary p-[3px]",
        className
      )}
      {...props}
    />
  )
}

function StepperButton({
  direction,
  className,
  children,
  ...props
}: Omit<React.ComponentProps<"button">, "onClick"> & { direction: 1 | -1 }) {
  const { canChange, change } = useStepper()
  const timerRef = React.useRef(0)

  const stop = React.useCallback(() => {
    window.clearTimeout(timerRef.current)
  }, [])

  React.useEffect(() => stop, [stop])

  const repeat = (count: number) => {
    timerRef.current = window.setTimeout(
      () => {
        if (change(direction)) repeat(count + 1)
      },
      count === 0
        ? START_DELAY
        : Math.max(MIN_TICK, FIRST_TICK * ACCELERATION ** count)
    )
  }

  return (
    <button
      type="button"
      data-slot={direction === 1 ? "stepper-increment" : "stepper-decrement"}
      disabled={!canChange(direction)}
      onPointerDown={(event) => {
        if (event.button !== 0) return
        event.currentTarget.setPointerCapture(event.pointerId)
        stop()
        if (change(direction)) repeat(0)
      }}
      onPointerUp={stop}
      onPointerCancel={stop}
      onLostPointerCapture={stop}
      onClick={(event) => {
        if (event.detail === 0) change(direction)
      }}
      onContextMenu={(event) => event.preventDefault()}
      className={cn(
        "relative flex h-full w-9 touch-manipulation items-center justify-center rounded-md bg-card text-card-foreground shadow-sm outline-none select-none after:absolute after:inset-x-0 after:-inset-y-2.25 after:content-[''] focus-visible:ring-3 focus-visible:ring-ring/50 active:shadow-none disabled:shadow-none dark:bg-border dark:shadow-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 disabled:opacity-50",
        className
      )}
      {...props}
    >
      {children}
    </button>
  )
}

function StepperDecrement({
  children,
  ...props
}: Omit<React.ComponentProps<"button">, "onClick">) {
  return (
    <StepperButton direction={-1} aria-label="Decrease" {...props}>
      {children ?? (
        <MinusIcon />
      )}
    </StepperButton>
  )
}

function StepperIncrement({
  children,
  ...props
}: Omit<React.ComponentProps<"button">, "onClick">) {
  return (
    <StepperButton direction={1} aria-label="Increase" {...props}>
      {children ?? (
        <PlusIcon />
      )}
    </StepperButton>
  )
}

function StepperSeparator({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      aria-hidden
      data-slot="stepper-separator"
      className={cn("h-4.5 w-px shrink-0 bg-border", className)}
      {...props}
    />
  )
}

export {
  Stepper,
  StepperDecrement,
  StepperGroup,
  StepperIncrement,
  StepperInput,
  StepperSeparator,
}
```

3. Update the import paths to match your project.

## Usage

```tsx
import { Stepper, StepperInput, StepperGroup, StepperDecrement, StepperIncrement, StepperSeparator } from "@/components/interactions/stepper"
```

```tsx
<Stepper defaultValue={1} min={0} max={10}>
  <StepperInput aria-label="Quantity" />
  <StepperGroup>
    <StepperDecrement />
    <StepperSeparator />
    <StepperIncrement />
  </StepperGroup>
</Stepper>
```

- Compose `StepperInput` and a `StepperGroup` containing `StepperDecrement`, `StepperSeparator`, and `StepperIncrement` inside `Stepper`.
- The value is uncontrolled by default; pass `value` and `onValueChange` to control it.
- Other number field props, such as `min`, `max`, `disabled`, and `format`, pass through to the root.
- The buttons render minus and plus icons by default; pass children to replace them.

## Examples

### Print

A print settings list with labelled steppers, a percent format with a step of `5`, and a disabled row.

**Example**

```tsx
import * as React from "react"
import {
  Stepper,
  StepperDecrement,
  StepperGroup,
  StepperIncrement,
  StepperInput,
  StepperSeparator,
} from "@/components/interactions/stepper"

function StepperRow({
  label,
  ...props
}: React.ComponentProps<typeof Stepper> & { label: string }) {
  const labelId = React.useId()

  return (
    <div className="flex min-h-11 items-center justify-between gap-4 px-4 py-1.5">
      <span id={labelId} className="text-base">
        {label}
      </span>
      <Stepper {...props}>
        <StepperInput aria-labelledby={labelId} />
        <StepperGroup>
          <StepperDecrement />
          <StepperSeparator />
          <StepperIncrement />
        </StepperGroup>
      </Stepper>
    </div>
  )
}

function PrintExample() {
  const [copies, setCopies] = React.useState(1)
  const [scale, setScale] = React.useState(100)

  return (
    <div className="mx-auto flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-3">
      <div className="divide-y divide-border overflow-hidden rounded-2xl border">
        <StepperRow
          label="Copies"
          value={copies}
          onValueChange={setCopies}
          min={1}
          max={999}
        />
        <StepperRow
          label="Scale"
          value={scale}
          onValueChange={setScale}
          min={25}
          max={400}
          step={5}
          format={{ style: "unit", unit: "percent" }}
        />
        <StepperRow label="Pages per sheet" defaultValue={1} disabled />
      </div>
      <p role="status" className="text-sm text-muted-foreground">
        {copies} {copies === 1 ? "copy" : "copies"} at {scale}%
      </p>
    </div>
  )
}

export default function StepperPrint() {
  return <PrintExample />
}
```

## API Reference

- `Stepper`
- `StepperInput`
- `StepperGroup`
  - `StepperDecrement`
  - `StepperSeparator`
  - `StepperIncrement`

### Stepper

The Number Field root that holds the value and its limits.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `step` | `number` | `1` | The amount each press adds or subtracts. |
| `value` | `number` | - | The current value, for controlled use. |
| `defaultValue` | `number` | `0` | The initial value when uncontrolled. |
| `onValueChange` | `(value: number) => void` | - | Called with the new value when it changes. |

Also accepts the props of the primitive part it wraps.

### StepperInput

The editable text field that shows the value.

Also accepts the props of the primitive part it wraps.

### StepperGroup

The segmented container for the decrement and increment buttons.

Also accepts the props of `React.ComponentProps<"div">`.

### StepperDecrement

The button that decreases the value by one step.

Also accepts the props of `React.ComponentProps<"button">`.

### StepperIncrement

The button that increases the value by one step.

Also accepts the props of `React.ComponentProps<"button">`.

### StepperSeparator

A thin divider between the two buttons.

Also accepts the props of `React.ComponentProps<"span">`.

## Accessibility

- Label `StepperInput` with `aria-label` or `aria-labelledby`.
- The arrow keys step the value from the input, as provided by the number field primitive.
- The buttons are labelled `Decrease` and `Increase`, and are disabled at the limits or when the stepper is disabled.
- `StepperGroup` has `role="group"`, and `StepperSeparator` is `aria-hidden`.

## Notes

- Parts use `data-slot` values `stepper`, `stepper-input`, `stepper-group`, `stepper-decrement`, `stepper-increment`, and `stepper-separator`.
- Values are rounded to the precision of `step`, so decimal steps do not accumulate floating-point errors.
- The buttons suppress the long-press context menu so holding them on touch devices keeps stepping.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx
import { Stepper, StepperDecrement, StepperGroup, StepperIncrement, StepperInput, StepperSeparator } from "@/components/interactions/stepper"

export function StepperExample() {
  return (
    <Stepper defaultValue={1} min={0} max={10}>
      <StepperInput accessibilityLabel="Quantity" />
      <StepperGroup>
        <StepperDecrement />
        <StepperSeparator />
        <StepperIncrement />
      </StepperGroup>
    </Stepper>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct StepperParts: View {
  var body: some View {
    StepperInput()
    StepperGroup {
      StepperDecrement()
      StepperSeparator()
      StepperIncrement()
    }
  }
}

struct StepperQuantity: View {
  @State private var value = 1

  var body: some View {
    ValueStepper(value: $value, in: 0...10) {
      StepperParts()
    }
    .accessibilityLabel(Text("Quantity"))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.esuiss.ui.interactions.Stepper
import com.esuiss.ui.interactions.StepperDecrement
import com.esuiss.ui.interactions.StepperGroup
import com.esuiss.ui.interactions.StepperIncrement
import com.esuiss.ui.interactions.StepperInput
import com.esuiss.ui.interactions.StepperSeparator

@Composable
fun StepperControls() {
    StepperGroup {
        StepperDecrement()
        StepperSeparator()
        StepperIncrement()
    }
}

@Composable
fun StepperDefault() {
    var value by rememberSaveable { mutableDoubleStateOf(1.0) }
    Row {
        Stepper(value = value, onValueChange = { value = it }, min = 0.0, max = 10.0) {
            StepperInput(contentDescription = "Quantity")
            StepperControls()
        }
    }
}
```

`Stepper` is a number field laid out as a right-aligned value followed by a field holding minus and plus buttons, inset from its edges. Each step snaps to `step` and stays within `min` and `max`, and a button that can go no further is shown as disabled.

Pressing and holding a button steps once, then repeats every 60 ms after 400 ms. The value can also be typed. Use it for quantities, copies or percentages in settings rows.

## Installation

**React Native**

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

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

**SwiftUI**

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

**Jetpack Compose**

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

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

### 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), and the primitives `components/primitives/number-field.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/interactions/stepper.tsx`:

```tsx title="uniwind/components/interactions/stepper.tsx"
import { cn } from "cn"
import { MinusIcon, PlusIcon } from "phosphor-react-native"
import type * as React from "react"
import { View, type ViewProps } from "react-native"
import * as NumberFieldPrimitive from "@/components/primitives/number-field"
import { resolveClassName } from "@/components/primitives/use-render"
import { Icon } from "@/components/ui/icon"

function Stepper({
  value,
  defaultValue = 0,
  onValueChange,
  className,
  ...props
}: Omit<
  React.ComponentProps<typeof NumberFieldPrimitive.Root>,
  "value" | "defaultValue" | "onValueChange"
> & {
  value?: number
  defaultValue?: number
  onValueChange?: (
    value: number,
    eventDetails: NumberFieldPrimitive.ChangeEventDetails
  ) => void
}) {
  return (
    <NumberFieldPrimitive.Root
      value={value}
      defaultValue={defaultValue}
      onValueChange={(next, details) => {
        if (next === null) {
          details.cancel()
          return
        }
        onValueChange?.(next, details)
      }}
      className={(state) =>
        cn("flex-row items-center gap-3", resolveClassName(className, state))
      }
      {...props}
    />
  )
}

function StepperInput({
  className,
  ...props
}: React.ComponentProps<typeof NumberFieldPrimitive.Input>) {
  return (
    <NumberFieldPrimitive.Input
      className={(state) =>
        cn(
          "h-8 w-16 min-w-0 rounded-md bg-transparent px-1 py-0 text-right text-[16px] text-foreground tabular-nums",
          state.disabled && "opacity-50",
          resolveClassName(className, state)
        )
      }
      cursorColorClassName="accent-primary"
      selectionColorClassName="accent-primary"
      {...props}
    />
  )
}

function StepperGroup({
  className,
  ...props
}: React.ComponentProps<typeof NumberFieldPrimitive.Group>) {
  return (
    <NumberFieldPrimitive.Group
      className={(state) =>
        cn(
          "h-8 shrink-0 flex-row items-center gap-[3px] rounded-lg bg-secondary p-[3px]",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function StepperDecrement({
  className,
  children,
  ...props
}: React.ComponentProps<typeof NumberFieldPrimitive.Decrement>) {
  const { canDecrement } = NumberFieldPrimitive.useRootContext()
  return (
    <NumberFieldPrimitive.Decrement
      hitSlop={{ top: 9, bottom: 9 }}
      className={(state) =>
        cn(
          "relative h-full w-9 items-center justify-center rounded-md bg-card shadow-sm dark:bg-border dark:shadow-none",
          state.disabled || !canDecrement
            ? "opacity-50 shadow-none"
            : "active:opacity-80 active:shadow-none",
          resolveClassName(className, state)
        )
      }
      {...props}
    >
      {children ?? (
        <Icon as={MinusIcon} weight="bold" className="size-4 text-foreground" />
      )}
    </NumberFieldPrimitive.Decrement>
  )
}

function StepperIncrement({
  className,
  children,
  ...props
}: React.ComponentProps<typeof NumberFieldPrimitive.Increment>) {
  const { canIncrement } = NumberFieldPrimitive.useRootContext()
  return (
    <NumberFieldPrimitive.Increment
      hitSlop={{ top: 9, bottom: 9 }}
      className={(state) =>
        cn(
          "relative h-full w-9 items-center justify-center rounded-md bg-card shadow-sm dark:bg-border dark:shadow-none",
          state.disabled || !canIncrement
            ? "opacity-50 shadow-none"
            : "active:opacity-80 active:shadow-none",
          resolveClassName(className, state)
        )
      }
      {...props}
    >
      {children ?? (
        <Icon as={PlusIcon} weight="bold" className="size-4 text-foreground" />
      )}
    </NumberFieldPrimitive.Increment>
  )
}

function StepperSeparator({ className, ...props }: ViewProps) {
  return (
    <View
      accessible={false}
      importantForAccessibility="no"
      className={cn("h-4.5 w-px shrink-0 bg-border", className)}
      {...props}
    />
  )
}

export {
  Stepper,
  StepperDecrement,
  StepperGroup,
  StepperIncrement,
  StepperInput,
  StepperSeparator,
}
```

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), and the primitives `components/primitives/number-field.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/interactions/stepper.tsx`:

```tsx title="nativewind/components/interactions/stepper.tsx"
import { cn } from "cn"
import { cssInterop } from "nativewind"
import { MinusIcon, PlusIcon } from "phosphor-react-native"
import type * as React from "react"
import { TextInput, View, type ViewProps } from "react-native"
import * as NumberFieldPrimitive from "@/components/primitives/number-field"
import { resolveClassName } from "@/components/primitives/use-render"
import { Icon } from "@/components/ui/icon"

declare module "react-native" {
  interface TextInputProps {
    cursorColorClassName?: string
    selectionColorClassName?: string
  }
}

cssInterop(TextInput, {
  className: { target: "style", nativeStyleToProp: { textAlign: true } },
  placeholderClassName: {
    target: false,
    nativeStyleToProp: { color: "placeholderTextColor" },
  },
  cursorColorClassName: {
    target: false,
    nativeStyleToProp: { color: "cursorColor" },
  },
  selectionColorClassName: {
    target: false,
    nativeStyleToProp: { color: "selectionColor" },
  },
})

function Stepper({
  value,
  defaultValue = 0,
  onValueChange,
  className,
  ...props
}: Omit<
  React.ComponentProps<typeof NumberFieldPrimitive.Root>,
  "value" | "defaultValue" | "onValueChange"
> & {
  value?: number
  defaultValue?: number
  onValueChange?: (
    value: number,
    eventDetails: NumberFieldPrimitive.ChangeEventDetails
  ) => void
}) {
  return (
    <NumberFieldPrimitive.Root
      value={value}
      defaultValue={defaultValue}
      onValueChange={(next, details) => {
        if (next === null) {
          details.cancel()
          return
        }
        onValueChange?.(next, details)
      }}
      className={(state) =>
        cn("flex-row items-center gap-3", resolveClassName(className, state))
      }
      {...props}
    />
  )
}

function StepperInput({
  className,
  ...props
}: React.ComponentProps<typeof NumberFieldPrimitive.Input>) {
  return (
    <NumberFieldPrimitive.Input
      className={(state) =>
        cn(
          "h-8 w-16 min-w-0 rounded-md bg-transparent px-1 py-0 text-right text-[16px] text-foreground tabular-nums",
          state.disabled && "opacity-50",
          resolveClassName(className, state)
        )
      }
      cursorColorClassName="text-primary"
      selectionColorClassName="text-primary"
      {...props}
    />
  )
}

function StepperGroup({
  className,
  ...props
}: React.ComponentProps<typeof NumberFieldPrimitive.Group>) {
  return (
    <NumberFieldPrimitive.Group
      className={(state) =>
        cn(
          "h-8 shrink-0 flex-row items-center gap-[3px] rounded-lg bg-secondary p-[3px]",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function StepperDecrement({
  className,
  children,
  ...props
}: React.ComponentProps<typeof NumberFieldPrimitive.Decrement>) {
  const { canDecrement } = NumberFieldPrimitive.useRootContext()
  return (
    <NumberFieldPrimitive.Decrement
      hitSlop={{ top: 9, bottom: 9 }}
      className={(state) =>
        cn(
          "relative h-full w-9 items-center justify-center rounded-md bg-card shadow dark:bg-border dark:shadow-none",
          state.disabled || !canDecrement
            ? "opacity-50 shadow-none"
            : "active:opacity-80 active:shadow-none",
          resolveClassName(className, state)
        )
      }
      {...props}
    >
      {children ?? (
        <Icon as={MinusIcon} weight="bold" className="size-4 text-foreground" />
      )}
    </NumberFieldPrimitive.Decrement>
  )
}

function StepperIncrement({
  className,
  children,
  ...props
}: React.ComponentProps<typeof NumberFieldPrimitive.Increment>) {
  const { canIncrement } = NumberFieldPrimitive.useRootContext()
  return (
    <NumberFieldPrimitive.Increment
      hitSlop={{ top: 9, bottom: 9 }}
      className={(state) =>
        cn(
          "relative h-full w-9 items-center justify-center rounded-md bg-card shadow dark:bg-border dark:shadow-none",
          state.disabled || !canIncrement
            ? "opacity-50 shadow-none"
            : "active:opacity-80 active:shadow-none",
          resolveClassName(className, state)
        )
      }
      {...props}
    >
      {children ?? (
        <Icon as={PlusIcon} weight="bold" className="size-4 text-foreground" />
      )}
    </NumberFieldPrimitive.Increment>
  )
}

function StepperSeparator({ className, ...props }: ViewProps) {
  return (
    <View
      accessible={false}
      importantForAccessibility="no"
      className={cn("h-[1.125rem] w-px shrink-0 bg-border", className)}
      {...props}
    />
  )
}

export {
  Stepper,
  StepperDecrement,
  StepperGroup,
  StepperIncrement,
  StepperInput,
  StepperSeparator,
}
```

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), and the primitives `components/primitives/number-field.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/interactions/stepper.tsx`:

```tsx title="stylesheet/components/interactions/stepper.tsx"
import { MinusIcon, PlusIcon } from "phosphor-react-native"
import type * as React from "react"
import {
  Pressable,
  type StyleProp,
  StyleSheet,
  type TextStyle,
  View,
  type ViewProps,
  type ViewStyle,
} from "react-native"
import * as NumberFieldPrimitive from "@/components/primitives/number-field"
import { resolveStyle } from "@/components/primitives/use-render"
import { Icon } from "@/components/ui/icon"
import {
  fontSize,
  radius,
  shadow,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

const styles = StyleSheet.create({
  root: { flexDirection: "row", alignItems: "center", gap: 12 },
  input: {
    fontSize: fontSize.base.fontSize,
    height: 32,
    width: 64,
    minWidth: 0,
    borderRadius: radius.md,
    backgroundColor: "transparent",
    paddingHorizontal: 4,
    paddingVertical: 0,
    textAlign: "right",
    fontVariant: ["tabular-nums"],
  },
  group: {
    height: 32,
    flexShrink: 0,
    flexDirection: "row",
    alignItems: "center",
    gap: 3,
    borderRadius: radius.lg,
    padding: 3,
  },
  button: {
    position: "relative",
    height: "100%",
    width: 36,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.md,
  },
  separator: { height: 18, width: 1, flexShrink: 0 },
})

function buttonStyle(
  colors: ThemeColors,
  isDark: boolean,
  disabled: boolean,
  pressed: boolean
): StyleProp<ViewStyle> {
  return [
    isDark
      ? { backgroundColor: colors.border }
      : [{ backgroundColor: colors.card }, shadow.sm],
    pressed && { opacity: 0.8 },
    disabled && { opacity: 0.5 },
  ]
}

function Stepper({
  value,
  defaultValue = 0,
  onValueChange,
  style,
  ...props
}: Omit<
  React.ComponentProps<typeof NumberFieldPrimitive.Root>,
  "value" | "defaultValue" | "onValueChange"
> & {
  value?: number
  defaultValue?: number
  onValueChange?: (
    value: number,
    eventDetails: NumberFieldPrimitive.ChangeEventDetails
  ) => void
}) {
  return (
    <NumberFieldPrimitive.Root
      value={value}
      defaultValue={defaultValue}
      onValueChange={(next, details) => {
        if (next === null) {
          details.cancel()
          return
        }
        onValueChange?.(next, details)
      }}
      style={(state) => [
        styles.root,
        resolveStyle(style, state) as StyleProp<ViewStyle>,
      ]}
      {...props}
    />
  )
}

function StepperInput({
  style,
  ...props
}: React.ComponentProps<typeof NumberFieldPrimitive.Input>) {
  const { colors } = useTheme()
  return (
    <NumberFieldPrimitive.Input
      style={(state) => [
        styles.input,
        { color: colors.foreground },
        state.disabled && { opacity: 0.5 },
        resolveStyle(style, state) as StyleProp<TextStyle>,
      ]}
      cursorColor={colors.primary}
      selectionColor={colors.primary}
      {...props}
    />
  )
}

function StepperGroup({
  style,
  ...props
}: React.ComponentProps<typeof NumberFieldPrimitive.Group>) {
  const { colors } = useTheme()
  return (
    <NumberFieldPrimitive.Group
      style={(state) => [
        styles.group,
        { backgroundColor: colors.secondary },
        resolveStyle(style, state) as StyleProp<ViewStyle>,
      ]}
      {...props}
    />
  )
}

function StepperDecrement({
  style,
  children,
  ...props
}: React.ComponentProps<typeof NumberFieldPrimitive.Decrement>) {
  const { colors, isDark } = useTheme()
  const { canDecrement } = NumberFieldPrimitive.useRootContext()
  return (
    <NumberFieldPrimitive.Decrement
      hitSlop={{ top: 9, bottom: 9 }}
      render={(renderProps, state) => (
        <Pressable
          {...renderProps}
          style={({ pressed }) => [
            styles.button,
            buttonStyle(
              colors,
              isDark,
              state.disabled || !canDecrement,
              pressed
            ),
            resolveStyle(style, state) as StyleProp<ViewStyle>,
          ]}
        />
      )}
      {...props}
    >
      {children ?? (
        <Icon
          as={MinusIcon}
          weight="bold"
          size={16}
          color={colors.foreground}
        />
      )}
    </NumberFieldPrimitive.Decrement>
  )
}

function StepperIncrement({
  style,
  children,
  ...props
}: React.ComponentProps<typeof NumberFieldPrimitive.Increment>) {
  const { colors, isDark } = useTheme()
  const { canIncrement } = NumberFieldPrimitive.useRootContext()
  return (
    <NumberFieldPrimitive.Increment
      hitSlop={{ top: 9, bottom: 9 }}
      render={(renderProps, state) => (
        <Pressable
          {...renderProps}
          style={({ pressed }) => [
            styles.button,
            buttonStyle(
              colors,
              isDark,
              state.disabled || !canIncrement,
              pressed
            ),
            resolveStyle(style, state) as StyleProp<ViewStyle>,
          ]}
        />
      )}
      {...props}
    >
      {children ?? (
        <Icon as={PlusIcon} weight="bold" size={16} color={colors.foreground} />
      )}
    </NumberFieldPrimitive.Increment>
  )
}

function StepperSeparator({ style, ...props }: ViewProps) {
  const { colors } = useTheme()
  return (
    <View
      accessible={false}
      importantForAccessibility="no"
      style={[styles.separator, { backgroundColor: colors.border }, style]}
      {...props}
    />
  )
}

export {
  Stepper,
  StepperDecrement,
  StepperGroup,
  StepperIncrement,
  StepperInput,
  StepperSeparator,
}
```

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/Interactions/Stepper.swift"
import PhosphorSwift
import SwiftUI

enum StepperMath {
  static func canChange(_ value: Double, direction: Int, bounds: ClosedRange<Double>) -> Bool {
    direction > 0 ? value < bounds.upperBound : value > bounds.lowerBound
  }

  static func next(_ value: Double, direction: Int, step: Double, bounds: ClosedRange<Double>) -> Double? {
    guard canChange(value, direction: direction, bounds: bounds) else { return nil }
    let precision = max(0, -Int(log10(step).rounded(.down)))
    let factor = pow(10, Double(precision))
    let raw = ((value + Double(direction) * step) * factor).rounded() / factor
    return min(max(raw, bounds.lowerBound), bounds.upperBound)
  }

  static func delay(after count: Int) -> Duration {
    if count == 0 { return .milliseconds(400) }
    return .milliseconds(Int(max(35, 180 * pow(0.86, Double(count)))))
  }
}

struct StepperFormat {
  var format: (Double) -> String
  var parse: (String) -> Double?
}

struct StepperContext {
  var value: Double
  var enabled: Bool
  var format: (Double) -> String
  var parse: (String) -> Double?
  var canChange: @MainActor (Int) -> Bool
  var change: @MainActor (Int) -> Bool
  var set: @MainActor (Double) -> Void
}

extension EnvironmentValues {
  @Entry var stepper: StepperContext?
}

public struct ValueStepper<Content: View>: View {
  let value: Binding<Double>
  let bounds: ClosedRange<Double>
  let step: Double
  let style: (Locale) -> StepperFormat
  let content: Content

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

  public init<F: ParseableFormatStyle>(
    value: Binding<Double>,
    in bounds: ClosedRange<Double> = -Double.infinity...Double.infinity,
    step: Double = 1,
    format: F,
    @ViewBuilder content: () -> Content
  ) where F.FormatInput == Double, F.FormatOutput == String {
    self.value = value
    self.bounds = bounds
    self.step = step
    style = { _ in StepperFormat(format: { format.format($0) }, parse: { try? format.parseStrategy.parse($0) }) }
    self.content = content()
  }

  public init(
    value: Binding<Double>,
    in bounds: ClosedRange<Double> = -Double.infinity...Double.infinity,
    step: Double = 1,
    @ViewBuilder content: () -> Content
  ) {
    self.value = value
    self.bounds = bounds
    self.step = step
    style = { locale in
      let format = FloatingPointFormatStyle<Double>.number.locale(locale)
      return StepperFormat(format: { format.format($0) }, parse: { try? format.parseStrategy.parse($0) })
    }
    self.content = content()
  }

  public init(
    value: Binding<Int>,
    in bounds: ClosedRange<Int>? = nil,
    step: Int = 1,
    @ViewBuilder content: () -> Content
  ) {
    let double = Binding<Double>(
      get: { Double(value.wrappedValue) },
      set: { value.wrappedValue = Int($0.rounded()) }
    )
    let range = bounds.map { Double($0.lowerBound)...Double($0.upperBound) } ?? -Double.infinity...Double.infinity
    self.value = double
    self.bounds = range
    self.step = Double(step)
    style = { locale in
      let format = IntegerFormatStyle<Int>.number.locale(locale)
      return StepperFormat(format: { format.format(Int($0.rounded())) }, parse: { (try? format.parseStrategy.parse($0)).map(Double.init) })
    }
    self.content = content()
  }

  public var body: some View {
    let binding = value
    let bounds = bounds
    let step = step
    let enabled = isEnabled
    let resolved = style(locale)
    let ticks = $ticks
    HStack(spacing: 12) {
      content
    }
    .environment(\.stepper, StepperContext(
      value: binding.wrappedValue,
      enabled: enabled,
      format: resolved.format,
      parse: resolved.parse,
      canChange: { direction in
        enabled && StepperMath.canChange(binding.wrappedValue, direction: direction, bounds: bounds)
      },
      change: { direction in
        guard enabled, let next = StepperMath.next(binding.wrappedValue, direction: direction, step: step, bounds: bounds) else { return false }
        binding.wrappedValue = next
        ticks.wrappedValue += 1
        return true
      },
      set: { next in
        binding.wrappedValue = min(max(next, bounds.lowerBound), bounds.upperBound)
      }
    ))
    .hapticFeedback(.selection, trigger: ticks.wrappedValue)
    .accessibilityElement(children: .contain)
  }
}

public struct StepperInput: View {
  @Environment(\.stepper) private var stepper
  @State private var text = ""
  @FocusState private var focused: Bool

  public init() {}

  public var body: some View {
    let enabled = stepper?.enabled ?? true
    TextField("", text: $text)
      .focused($focused)
      .keyboardType(.decimalPad)
      .multilineTextAlignment(.trailing)
      .scaledFont(TextStyle.base)
      .monospacedDigit()
      .foregroundStyle(EsuissColor.foreground)
      .tint(EsuissColor.primary)
      .opacity(enabled ? 1 : 0.5)
      .padding(.horizontal, 4)
      .frame(width: 64, height: 32)
      .contentShape(RoundedRectangle(cornerRadius: Radius.md, style: .continuous))
      .onAppear { sync() }
      .onChange(of: stepper?.value) { _, _ in
        if !focused { sync() }
      }
      .onChange(of: focused) { _, isFocused in
        if !isFocused { commit() }
      }
      .onSubmit { commit() }
      .accessibilityValue(Text(verbatim: stepper.map { $0.format($0.value) } ?? ""))
      .accessibilityAdjustableAction { direction in
        _ = stepper?.change(direction == .increment ? 1 : -1)
      }
  }

  private func sync() {
    guard let stepper else { return }
    text = stepper.format(stepper.value)
  }

  private func commit() {
    guard let stepper else { return }
    if let parsed = stepper.parse(text) ?? Double(text) {
      stepper.set(parsed)
    }
    text = stepper.format(stepper.value)
  }
}

public struct StepperGroup<Content: View>: View {
  let content: Content

  public init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }

  public var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.lg, style: .continuous)
    HStack(spacing: 3) {
      content
    }
    .padding(3)
    .frame(height: 32)
    .background(EsuissColor.secondary, in: shape)
    .fixedSize()
    .accessibilityElement(children: .contain)
  }
}

@MainActor
final class StepperPress {
  var handled = false
  var repeating: Task<Void, Never>?
}

struct StepperButton<Label: View>: View {
  let direction: Int
  let label: Label

  @Environment(\.stepper) private var stepper
  @State private var press = StepperPress()

  var body: some View {
    let enabled = stepper?.canChange(direction) ?? false
    Button {
      if press.handled {
        press.handled = false
      } else {
        _ = stepper?.change(direction)
      }
    } label: {
      label
    }
    .buttonStyle(StepperButtonStyle(direction: direction, press: press))
    .disabled(!enabled)
    .accessibilityAction {
      _ = stepper?.change(direction)
    }
  }
}

private struct StepperButtonStyle: ButtonStyle {
  let direction: Int
  let press: StepperPress

  func makeBody(configuration: Configuration) -> some View {
    StepperButtonBody(direction: direction, pressed: configuration.isPressed, press: press, label: configuration.label)
  }
}

private struct StepperButtonBody<Label: View>: View {
  let direction: Int
  let pressed: Bool
  let press: StepperPress
  let label: Label

  @Environment(\.stepper) private var stepper
  @Environment(\.isEnabled) private var isEnabled
  @Environment(\.colorScheme) private var colorScheme

  var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.md, style: .continuous)
    label
      .frame(width: 16, height: 16)
      .foregroundStyle(EsuissColor.foreground)
      .frame(width: 36)
      .frame(maxHeight: .infinity)
      .background {
        if colorScheme == .dark {
          shape.fill(EsuissColor.border)
        } else {
          shape.fill(EsuissColor.card).shadow(.thumb)
        }
      }
      .opacity(isEnabled ? (pressed ? 0.8 : 1) : 0.5)
      .padding(.vertical, 9)
      .contentShape(Rectangle())
      .padding(.vertical, -9)
      .onChange(of: pressed) { _, isPressed in
        press.repeating?.cancel()
        press.repeating = nil
        guard isPressed else {
          let current = press
          Task { @MainActor in
            try? await Task.sleep(for: .milliseconds(350))
            current.handled = false
          }
          return
        }
        guard let stepper, stepper.change(direction) else { return }
        press.handled = true
        press.repeating = Task { @MainActor in
          var count = 0
          while !Task.isCancelled {
            try? await Task.sleep(for: StepperMath.delay(after: count))
            guard !Task.isCancelled, stepper.change(direction) else { return }
            count += 1
          }
        }
      }
      .onDisappear {
        press.repeating?.cancel()
      }
  }
}

public struct StepperDecrement<Label: View>: View {
  let label: Label

  public init(@ViewBuilder label: () -> Label) {
    self.label = label()
  }

  public var body: some View {
    StepperButton(direction: -1, label: label)
      .accessibilityLabel(Text("Decrease"))
  }
}

extension StepperDecrement where Label == Image {
  public init() {
    self.init { Ph.minus.bold }
  }
}

public struct StepperIncrement<Label: View>: View {
  let label: Label

  public init(@ViewBuilder label: () -> Label) {
    self.label = label()
  }

  public var body: some View {
    StepperButton(direction: 1, label: label)
      .accessibilityLabel(Text("Increase"))
  }
}

extension StepperIncrement where Label == Image {
  public init() {
    self.init { Ph.plus.bold }
  }
}

public struct StepperSeparator: View {
  public init() {}

  public var body: some View {
    EsuissColor.border
      .frame(width: 1, height: 18)
      .accessibilityHidden(true)
  }
}
```

- `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/interactions/Stepper.kt"
package com.esuiss.ui.interactions

import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.bold.Minus
import com.adamglin.phosphoricons.bold.Plus
import com.esuiss.ui.components.FormShadowSm
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.LocalContentColor
import com.esuiss.ui.components.LocalIconSize
import com.esuiss.ui.components.rememberHaptics
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import com.esuiss.ui.theme.shadow
import java.text.NumberFormat
import java.text.ParsePosition
import kotlin.math.floor
import kotlin.math.log10
import kotlin.math.max
import kotlin.math.pow
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

private const val StartDelay = 400L

private const val FirstTick = 180.0

private const val MinTick = 35.0

private const val Acceleration = 0.86

@Immutable
private class StepperContext(
    val value: Double,
    val enabled: Boolean,
    val format: NumberFormat,
    val canChange: (Int) -> Boolean,
    val change: (Int) -> Boolean,
    val commit: (Double) -> Unit,
)

private val LocalStepper = staticCompositionLocalOf<StepperContext?> { null }

private fun stepperContext(): Nothing = error("Stepper parts must be used within a Stepper")

@Composable
fun Stepper(
    value: Double,
    onValueChange: (Double) -> Unit,
    modifier: Modifier = Modifier,
    min: Double = Double.NEGATIVE_INFINITY,
    max: Double = Double.POSITIVE_INFINITY,
    step: Double = 1.0,
    enabled: Boolean = true,
    format: NumberFormat = remember { NumberFormat.getNumberInstance() },
    content: @Composable RowScope.() -> Unit,
) {
    val currentValue by rememberUpdatedState(value)
    val currentOnValueChange by rememberUpdatedState(onValueChange)
    val currentHaptics by rememberUpdatedState(rememberHaptics())
    val canChange: (Int) -> Boolean = { direction ->
        enabled && if (direction > 0) currentValue < max else currentValue > min
    }
    val context = StepperContext(
        value = value,
        enabled = enabled,
        format = format,
        canChange = canChange,
        change = { direction ->
            if (!canChange(direction)) {
                false
            } else {
                val precision = max(0, -floor(log10(step)).toInt())
                val factor = 10.0.pow(precision)
                val next = Math.round((currentValue + direction * step) * factor) / factor
                currentHaptics.selection()
                currentOnValueChange(next.coerceIn(min, max))
                true
            }
        },
        commit = { next -> currentOnValueChange(next.coerceIn(min, max)) },
    )
    CompositionLocalProvider(LocalStepper provides context) {
        Row(
            modifier = modifier,
            horizontalArrangement = Arrangement.spacedBy(12.dp),
            verticalAlignment = Alignment.CenterVertically,
            content = content,
        )
    }
}

@Composable
fun StepperInput(
    modifier: Modifier = Modifier,
    contentDescription: String? = null,
) {
    val stepper = LocalStepper.current ?: stepperContext()
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val focused by interaction.collectIsFocusedAsState()
    val formatted = stepper.format.format(stepper.value)
    var text by remember { mutableStateOf(formatted) }
    LaunchedEffect(formatted, focused) {
        if (!focused) text = formatted
    }
    val focusManager = LocalFocusManager.current
    val commit = {
        val position = ParsePosition(0)
        val parsed = stepper.format.parse(text.trim(), position)
        if (parsed != null && position.index > 0) stepper.commit(parsed.toDouble())
        text = stepper.format.format(stepper.value)
    }
    LaunchedEffect(focused) {
        if (!focused && text != formatted) commit()
    }
    CompositionLocalProvider(
        LocalTextSelectionColors provides TextSelectionColors(colors.primary, colors.primary.copy(alpha = 0.3f)),
    ) {
        BasicTextField(
            value = text,
            onValueChange = { text = it },
            modifier = modifier
                .height(32.dp)
                .width(64.dp)
                .alpha(if (stepper.enabled) 1f else 0.5f)
                .padding(horizontal = 4.dp)
                .then(
                    if (contentDescription != null) {
                        Modifier.semantics { this.contentDescription = contentDescription }
                    } else {
                        Modifier
                    },
                ),
            enabled = stepper.enabled,
            singleLine = true,
            textStyle = Typography.base.copy(
                color = colors.foreground,
                textAlign = TextAlign.End,
                fontFeatureSettings = "tnum",
            ),
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done),
            keyboardActions = KeyboardActions(onDone = { focusManager.clearFocusAndCommit(commit) }),
            interactionSource = interaction,
            cursorBrush = SolidColor(colors.primary),
            decorationBox = { inner ->
                Box(contentAlignment = Alignment.CenterEnd) { inner() }
            },
        )
    }
}

private fun FocusManager.clearFocusAndCommit(commit: () -> Unit) {
    commit()
    clearFocus()
}

@Composable
fun StepperGroup(
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit,
) {
    Row(
        modifier = modifier
            .height(32.dp)
            .background(EsuissTheme.colors.secondary, RoundedCornerShape(Radius.lg))
            .padding(3.dp),
        horizontalArrangement = Arrangement.spacedBy(3.dp),
        verticalAlignment = Alignment.CenterVertically,
        content = content,
    )
}

@Composable
fun StepperDecrement(
    modifier: Modifier = Modifier,
    contentDescription: String = "Decrease",
    content: (@Composable () -> Unit)? = null,
) {
    StepperButton(-1, PhosphorIcons.Bold.Minus, contentDescription, modifier, content)
}

@Composable
fun StepperIncrement(
    modifier: Modifier = Modifier,
    contentDescription: String = "Increase",
    content: (@Composable () -> Unit)? = null,
) {
    StepperButton(1, PhosphorIcons.Bold.Plus, contentDescription, modifier, content)
}

@Composable
fun StepperSeparator(modifier: Modifier = Modifier) {
    Box(
        modifier = modifier
            .width(1.dp)
            .height(18.dp)
            .background(EsuissTheme.colors.border),
    )
}

@Composable
private fun StepperButton(
    direction: Int,
    icon: ImageVector,
    contentDescription: String,
    modifier: Modifier,
    content: (@Composable () -> Unit)?,
) {
    val stepper = LocalStepper.current ?: stepperContext()
    val colors = EsuissTheme.colors
    val enabled = stepper.canChange(direction)
    val currentStepper by rememberUpdatedState(stepper)
    val scope = rememberCoroutineScope()
    var pressed by remember { mutableStateOf(false) }
    val shape = RoundedCornerShape(Radius.md)
    val surface = when {
        colors.isDark -> Modifier.background(colors.border, shape)
        else -> Modifier.shadow(FormShadowSm, shape).background(colors.card, shape)
    }
    Box(
        modifier = modifier
            .fillMaxHeight()
            .width(36.dp)
            .alpha(
                when {
                    !enabled -> 0.5f
                    pressed -> 0.8f
                    else -> 1f
                },
            )
            .then(surface)
            .semantics {
                this.contentDescription = contentDescription
                role = Role.Button
                if (enabled) {
                    onClick {
                        currentStepper.change(direction)
                    }
                } else {
                    disabled()
                }
            }
            .pointerInput(enabled) {
                if (!enabled) return@pointerInput
                awaitEachGesture {
                    awaitFirstDown()
                    pressed = true
                    var repeat: Job? = null
                    if (currentStepper.change(direction)) {
                        repeat = scope.launch {
                            delay(StartDelay)
                            var count = 1
                            while (currentStepper.change(direction)) {
                                delay(max(MinTick, FirstTick * Acceleration.pow(count)).toLong())
                                count++
                            }
                        }
                    }
                    waitForUpOrCancellation()
                    repeat?.cancel()
                    pressed = false
                }
            },
        contentAlignment = Alignment.Center,
    ) {
        val tint = colors.foreground
        CompositionLocalProvider(LocalContentColor provides tint, LocalIconSize provides 16.dp) {
            if (content != null) content() else Icon(icon, contentDescription = null, size = 16.dp, tint = tint)
        }
    }
}
```

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

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

## Usage

```tsx
import { Stepper, StepperInput, StepperGroup, StepperDecrement, StepperIncrement, StepperSeparator } from "@/components/interactions/stepper"
```

```tsx
<Stepper defaultValue={1} min={0} max={10}>
  <StepperInput accessibilityLabel="Quantity" />
  <StepperGroup>
    <StepperDecrement />
    <StepperSeparator />
    <StepperIncrement />
  </StepperGroup>
</Stepper>
```

Compose `StepperInput` and a `StepperGroup` containing `StepperDecrement`, `StepperSeparator` and `StepperIncrement` inside `Stepper`.

Use it uncontrolled with `defaultValue` (`0` by default) or controlled with `value` and `onValueChange`. Clearing the field never reports `null`; the change is cancelled instead. `min`, `max`, `step`, `format` (`Intl.NumberFormatOptions`), `locale` and `disabled` are passed to the number field.

## Examples

### Print

A print settings list with copies, a percentage scale stepping by 5, and a disabled row.

![Preview](https://ui.suiss.app/previews/interactions-stepper--print.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Stepper, StepperDecrement, StepperGroup, StepperIncrement, StepperInput, StepperSeparator } from "@/components/interactions/stepper"
import { Text } from "@/components/ui/text"

function StepperRow({
  label,
  ...props
}: React.ComponentProps<typeof Stepper> & { label: string }) {
  const labelId = React.useId()

  return (
    <View className="min-h-11 flex-row items-center justify-between gap-4 px-4 py-1.5">
      <Text nativeID={labelId} className="text-base">
        {label}
      </Text>
      <Stepper {...props}>
        <StepperInput accessibilityLabelledBy={labelId} />
        <StepperGroup>
          <StepperDecrement />
          <StepperSeparator />
          <StepperIncrement />
        </StepperGroup>
      </Stepper>
    </View>
  )
}

function PrintExample() {
  const [copies, setCopies] = React.useState(1)
  const [scale, setScale] = React.useState(100)

  return (
    <View className="w-full gap-3">
      <View className="overflow-hidden rounded-2xl border border-border">
        <StepperRow
          label="Copies"
          value={copies}
          onValueChange={setCopies}
          min={1}
          max={999}
        />
        <View className="h-px bg-border" />
        <StepperRow
          label="Scale"
          value={scale}
          onValueChange={setScale}
          min={25}
          max={400}
          step={5}
          format={{ style: "unit", unit: "percent" }}
        />
        <View className="h-px bg-border" />
        <StepperRow label="Pages per sheet" defaultValue={1} disabled />
      </View>
      <Text
        accessibilityLiveRegion="polite"
        className="text-sm text-muted-foreground"
      >
        {copies} {copies === 1 ? "copy" : "copies"} at {scale}%
      </Text>
    </View>
  )
}

export function StepperPrintExample() {
  return (
    <PrintExample />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct StepperParts: View {
  var body: some View {
    StepperInput()
    StepperGroup {
      StepperDecrement()
      StepperSeparator()
      StepperIncrement()
    }
  }
}

struct StepperRow<Content: View>: View {
  let label: LocalizedStringKey
  @ViewBuilder let content: () -> Content

  var body: some View {
    HStack(spacing: 16) {
      Text(label)
        .textStyle(.base)
        .foregroundStyle(EsuissColor.foreground)
        .frame(maxWidth: .infinity, alignment: .leading)
      content()
        .accessibilityLabel(Text(label))
    }
    .padding(.horizontal, 16)
    .padding(.vertical, 6)
    .frame(minHeight: 44)
  }
}

struct StepperPrint: View {
  @State private var copies = 1
  @State private var scale = 100.0
  @State private var pages = 1

  var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    VStack(alignment: .leading, spacing: 12) {
      VStack(spacing: 0) {
        StepperRow(label: "Copies") {
          ValueStepper(value: $copies, in: 1...999) { StepperParts() }
        }
        EsuissColor.border.frame(height: 1)
        StepperRow(label: "Scale") {
          ValueStepper(
            value: $scale,
            in: 25...400,
            step: 5,
            format: FloatingPointFormatStyle<Double>.Percent().scale(1)
          ) { StepperParts() }
        }
        EsuissColor.border.frame(height: 1)
        StepperRow(label: "Pages per sheet") {
          ValueStepper(value: $pages) { StepperParts() }
            .disabled(true)
        }
      }
      .clipShape(shape)
      .overlay { shape.strokeBorder(EsuissColor.border) }
      Text(verbatim: "\(copies) \(copies == 1 ? "copy" : "copies") at \(Int(scale))%")
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .accessibilityAddTraits(.updatesFrequently)
    }
  }
}
```

```kotlin
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.Text
import com.esuiss.ui.interactions.Stepper
import com.esuiss.ui.interactions.StepperDecrement
import com.esuiss.ui.interactions.StepperGroup
import com.esuiss.ui.interactions.StepperIncrement
import com.esuiss.ui.interactions.StepperInput
import com.esuiss.ui.interactions.StepperSeparator
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import java.text.DecimalFormat
import java.text.NumberFormat

@Composable
fun StepperControls() {
    StepperGroup {
        StepperDecrement()
        StepperSeparator()
        StepperIncrement()
    }
}

@Composable
fun StepperRow(
    label: String,
    value: Double,
    onValueChange: (Double) -> Unit,
    min: Double = Double.NEGATIVE_INFINITY,
    max: Double = Double.POSITIVE_INFINITY,
    step: Double = 1.0,
    enabled: Boolean = true,
    format: NumberFormat = remember { NumberFormat.getNumberInstance() },
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .heightIn(min = 44.dp)
            .padding(horizontal = 16.dp, vertical = 6.dp),
        horizontalArrangement = Arrangement.spacedBy(16.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Text(label, modifier = Modifier.weight(1f), style = Typography.base)
        Stepper(value = value, onValueChange = onValueChange, min = min, max = max, step = step, enabled = enabled, format = format) {
            StepperInput(contentDescription = label)
            StepperControls()
        }
    }
}

@Composable
fun StepperPrint() {
    val colors = EsuissTheme.colors
    var copies by rememberSaveable { mutableDoubleStateOf(1.0) }
    var scale by rememberSaveable { mutableDoubleStateOf(100.0) }
    var pages by rememberSaveable { mutableDoubleStateOf(1.0) }
    val percent = remember { DecimalFormat("0'%'") }
    val shape = RoundedCornerShape(Radius.xl2)
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Column(
            Modifier
                .fillMaxWidth()
                .clip(shape)
                .border(1.dp, colors.border, shape),
        ) {
            StepperRow("Copies", copies, { copies = it }, min = 1.0, max = 999.0)
            Box(Modifier.fillMaxWidth().height(1.dp).background(colors.border))
            StepperRow("Scale", scale, { scale = it }, min = 25.0, max = 400.0, step = 5.0, format = percent)
            Box(Modifier.fillMaxWidth().height(1.dp).background(colors.border))
            StepperRow("Pages per sheet", pages, { pages = it }, enabled = false)
        }
        Text(
            "${copies.toInt()} ${if (copies == 1.0) "copy" else "copies"} at ${scale.toInt()}%",
            modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm.copy(color = colors.mutedForeground),
        )
    }
}
```

## API Reference

### Stepper

The root that holds the value and limits.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | - | The current value when controlled. |
| `defaultValue` | `number` | `0` | The initial value when uncontrolled. Defaults to `0`. |
| `onValueChange` | `( value: number, eventDetails: NumberFieldPrimitive.ChangeEventDetails ) => void` | - | Called with the new number and change details. |

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

### StepperInput

The editable, right-aligned value.

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

### StepperGroup

The rounded field that holds the buttons.

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

### StepperDecrement

The minus button.

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

### StepperIncrement

The plus button.

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

### StepperSeparator

A thin divider between the buttons.

Also accepts the props of `ViewProps`.

## Accessibility

- The minus and plus buttons have the `button` role.
- Label the value with `accessibilityLabel` or `accessibilityLabelledBy` on `StepperInput`.
- The separator is hidden from screen readers.

## Notes

- A selection haptic plays on each step, if the app provides an adapter through `HapticsProvider`.
- The group uses `bg-secondary`. Buttons use `bg-card` with a small shadow (`bg-border` in dark mode) and `opacity-80` when pressed; a disabled button or one at its limit uses `opacity-50`.
- Disabled text uses `opacity-50`.

