suiss/ui
  • Docs
  • Components
  • Patterns
  • Interactions
  • Charts
  • Blocks
GitHubGet started
DocsComponentsPatternsInteractionsChartsBlocks
Interactions15
  • Overview
  • Card Stack
  • Image Viewer
  • Island
  • OTP Autofill
  • Preview Menu
  • Pull to Refresh
  • Reorder List
  • Search Reveal
  • Section Index
  • Selection List
  • Stepper
  • Swipe Actions
  • Tip
  • Top Banner
  • Undo Bar

suiss/ui · Interaction

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.

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

$ pnpm dlx shadcn@latest add @esuiss/stepper

Works in any project with a components.json. See Installation for the one-line registry setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add interactions stepper

Manual

  1. Install the dependencies and add the cn helper:
$ pnpm add @base-ui/react @phosphor-icons/react
  1. Copy the source into components/interactions/stepper.tsx:
  1. Update the import paths to match your project.

Usage

import { Stepper, StepperInput, StepperGroup, StepperDecrement, StepperIncrement, StepperSeparator } 
<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.

API Reference

Anatomy6 parts
<Stepper>
<StepperInput />
<StepperGroup>
<StepperDecrement />
<StepperSeparator />
<StepperIncrement />
</StepperGroup>
</Stepper>
01

Stepper

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

PropTypeDefaultDescription
stepnumber1The amount each press adds or subtracts.
valuenumber-The current value, for controlled use.
defaultValuenumber0The 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.

02

StepperInput

The editable text field that shows the value.

Also accepts the props of the primitive part it wraps.

03

StepperGroup

The segmented container for the decrement and increment buttons.

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

04

StepperDecrement

The button that decreases the value by one step.

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

05

StepperIncrement

The button that increases the value by one step.

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

06

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.

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

$ pnpm dlx shadcn@latest add @esuiss-rn/stepper

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

$ pnpm dlx @esuiss/ui@latest add stepper
$ pnpm dlx @esuiss/ui@latest add stepper --platform swift
NextSwipe ActionsPreviousSelection List

On this page

  1. 01Installation
    • CLI
    • Manual
  2. 02Usage
  3. 03Examples
    • Print
  4. 04API Reference
    • Stepper
    • StepperInput
    • StepperGroup
    • StepperDecrement
    • StepperIncrement
    • StepperSeparator
  5. 05Accessibility
  6. 06Notes
  7. 07Installation
    • Manual
  8. 08Usage
  9. 09Examples
    • Print
  10. 10API Reference
    • Stepper
    • StepperInput
    • StepperGroup
    • StepperDecrement
    • StepperIncrement
    • StepperSeparator
  11. 11Accessibility
  12. 12Notes
from
"@/components/interactions/stepper"
defaultValue
=
{
1
}
min
=
{
0
}
max
=
{
10
}>
<StepperInput aria-label="Quantity" />
<StepperGroup>
<StepperDecrement />
<StepperSeparator />
<StepperIncrement />
</StepperGroup>
</Stepper>
$ pnpm dlx @esuiss/ui@latest add stepper --platform kotlin

The CLI also adds what it builds on: Icon.

Manual

  1. Install the dependencies:
npx expo install cn phosphor-react-native react-native-svg
  1. Add the pieces it builds on: Icon, and the primitives components/primitives/number-field.tsx, components/primitives/use-render.tsx.

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

  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install cn nativewind phosphor-react-native react-native-svg
  1. Add the pieces it builds on: Icon, and the primitives components/primitives/number-field.tsx, components/primitives/use-render.tsx.

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

  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install phosphor-react-native react-native-svg
  1. Add the pieces it builds on: Icon, and the primitives components/primitives/number-field.tsx, components/primitives/use-render.tsx.

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

  1. Update the import paths to match your project.
  1. Target iOS 17 or later.

  2. Add the Swift package to your app:

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

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

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

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

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

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

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

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

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

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

extension EnvironmentValues {
  @Entry public var hapticsEnabled = true
}

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

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

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

public struct Separator: View {
  let orientation: Axis

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

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

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

  var style: TextStyle {
    switch self {
    case .h1: .xl3
    case .h2: .xl2
    case .h3: .xl
    case .h4, .lead, .large: .lg
    case .code, .small, .muted: .sm
    default: .base
    }
  }
  1. Add the files to your app target.
  1. Set minSdk to 24 or higher and add the dependencies:
build.gradle.kts
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
  1. Copy the theme files every view uses:
kotlin/theme/Theme.kt
package com.esuiss.ui.theme

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

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

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

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

val LocalContentStyle = compositionLocalOf { TextStyle.Default }

val LocalContentColor = compositionLocalOf { Color.Unspecified }

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

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

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

val LocalHapticsEnabled = staticCompositionLocalOf { true }

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

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

@Composable
fun Icon(
    imageVector: ImageVector,
  1. Update the com.esuiss.ui package names to match your project.

Usage

import { Stepper, StepperInput, StepperGroup, StepperDecrement, StepperIncrement, StepperSeparator } from "@/components/interactions/stepper"
<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.

iOS
Stepper (print) on iOS in light modeStepper (print) on iOS in dark mode
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 />
  )
}
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)
    }
  }
}
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

01

Stepper

The root that holds the value and limits.

PropTypeDefaultDescription
valuenumber-The current value when controlled.
defaultValuenumber0The 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>.

02

StepperInput

The editable, right-aligned value.

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

03

StepperGroup

The rounded field that holds the buttons.

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

04

StepperDecrement

The minus button.

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

05

StepperIncrement

The plus button.

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

06

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.
iOS
Stepper on iOS in light modeStepper on iOS in dark mode
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>
  )
}
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"))
  }
}
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()
        }
    }
}
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>
  )
}
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)
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 />
}
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>,
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
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)
  }
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
  }
}
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,