Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Stepper

A numeric value with decrement and increment controls.

A numeric input with decrement/increment controls. iOS renders the classic joined pill — a secondary-container background with a hairline splitting the −/value/+ segments; Android renders tonal outlined circular icon buttons flanking the value. Both platforms share the same markup — the cascade decides the look. The buttons disable at the bounds and the value carries spinbutton semantics (aria-valuenow / min / max).

Installation

npx touchcn add stepper

Usage

<tcn-stepper [(value)]="quantity" [min]="0" [max]="10" label="Quantity" />
import { TcnStepper } from '@/components/ui/stepper';

<TcnStepper value={quantity} onValueChange={setQuantity} min={0} max={10} label="Quantity" />
<script setup lang="ts">
import { TcnStepper } from '@/components/ui/stepper';
</script>

<template>
  <TcnStepper v-model="quantity" :min="0" :max="10" label="Quantity" />
</template>

Press-and-hold auto-repeat is not implemented — it needs timer/pointer lifecycle plumbing in the engine, which a future engine directive/hook can add without changing this markup.

Props

Prop Type Default Description
value number 0 The current value (two-way in Angular).
min number -Infinity Lower bound; the decrement button disables at it.
max number Infinity Upper bound; the increment button disables at it.
step number 1 Amount added or removed per press.
disabled boolean false Disable the whole control.
label string '' Accessible name announced with the value.
Source · Angular
export * from './tcn-stepper';
import { booleanAttribute, Component, computed, ElementRef, inject, input, model, numberAttribute } from '@angular/core';
import { cn } from '@touchcn/core';

/**
 * Numeric stepper — a value with decrement/increment controls. iOS renders the
 * classic joined pill (secondary-container background, a hairline splitting the
 * −/+ segments with the value between them); MD renders tonal circular icon
 * buttons flanking the value. Both share this markup; the cascade decides the
 * look. The buttons disable at the bounds and the value carries spinbutton
 * semantics (`aria-valuenow` / `min` / `max`) for assistive tech.
 *
 * Press-and-hold auto-repeat is not implemented (it needs timer/pointer
 * lifecycle plumbing in the engine); a future engine directive can add it
 * without changing this markup.
 */
@Component({
  selector: 'tcn-stepper',
  template: `
    <button
      type="button"
      class="tcn-stepper-button"
      data-action="decrement"
      [attr.aria-label]="decrementLabel()"
      [disabled]="disabled() || value() <= min()"
      (click)="decrement()"
    >
      <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path d="M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
      </svg>
    </button>
    <span
      class="tcn-stepper-value"
      role="spinbutton"
      [attr.aria-valuenow]="value()"
      [attr.aria-valuemin]="isFinite(min()) ? min() : null"
      [attr.aria-valuemax]="isFinite(max()) ? max() : null"
      [attr.aria-label]="label() || null"
      [attr.aria-disabled]="disabled() || null"
    >
      {{ value() }}
    </span>
    <button
      type="button"
      class="tcn-stepper-button"
      data-action="increment"
      [attr.aria-label]="incrementLabel()"
      [disabled]="disabled() || value() >= max()"
      (click)="increment()"
    >
      <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
      </svg>
    </button>
  `,
  host: {
    role: 'group',
    '[class]': 'classes()',
    '[attr.aria-label]': 'label() || null',
  },
})
export class TcnStepper {
  private readonly authored = inject(ElementRef<HTMLElement>).nativeElement.getAttribute('class') ?? '';

  readonly value = model(0);
  readonly min = input(-Infinity, { transform: numberAttribute });
  readonly max = input(Infinity, { transform: numberAttribute });
  readonly step = input(1, { transform: numberAttribute });
  readonly disabled = input(false, { transform: booleanAttribute });
  readonly label = input('');
  readonly decrementLabel = input('Decrease');
  readonly incrementLabel = input('Increase');

  protected readonly classes = computed(() => cn('tcn-stepper', this.authored));

  protected readonly isFinite = Number.isFinite;

  decrement(): void {
    this.setValue(this.value() - this.step());
  }

  increment(): void {
    this.setValue(this.value() + this.step());
  }

  private setValue(next: number): void {
    if (this.disabled()) {
      return;
    }
    this.value.set(Math.min(this.max(), Math.max(this.min(), next)));
  }
}
Source · React
export * from './tcn-stepper';
import { cn } from '@touchcn/core';

export interface TcnStepperProps {
  value: number;
  onValueChange(value: number): void;
  min?: number;
  max?: number;
  step?: number;
  disabled?: boolean;
  /** Accessible name for the value (announced by assistive tech). */
  label?: string;
  decrementLabel?: string;
  incrementLabel?: string;
  className?: string;
}

/**
 * Numeric stepper — a value with decrement/increment controls. iOS renders the
 * classic joined pill (secondary-container background, a hairline splitting the
 * −/+ segments with the value between them); MD renders tonal circular icon
 * buttons flanking the value. Both share this markup; the cascade decides the
 * look. The buttons disable at the bounds and the value carries spinbutton
 * semantics (`aria-valuenow` / `min` / `max`) for assistive tech.
 *
 * Press-and-hold auto-repeat is not implemented (it needs timer/pointer
 * lifecycle plumbing in the engine); a future engine hook can add it without
 * changing this markup.
 */
export function TcnStepper({
  value,
  onValueChange,
  min = -Infinity,
  max = Infinity,
  step = 1,
  disabled = false,
  label,
  decrementLabel = 'Decrease',
  incrementLabel = 'Increase',
  className,
}: TcnStepperProps) {
  const setValue = (next: number) => {
    if (!disabled) {
      onValueChange(Math.min(max, Math.max(min, next)));
    }
  };

  return (
    <div className={cn('tcn-stepper', className)} role="group" aria-label={label || undefined}>
      <button
        type="button"
        className="tcn-stepper-button"
        data-action="decrement"
        aria-label={decrementLabel}
        disabled={disabled || value <= min}
        onClick={() => setValue(value - step)}
      >
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
          <path d="M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
        </svg>
      </button>
      <span
        className="tcn-stepper-value"
        role="spinbutton"
        aria-valuenow={Number.isFinite(value) ? value : undefined}
        aria-valuemin={Number.isFinite(min) ? min : undefined}
        aria-valuemax={Number.isFinite(max) ? max : undefined}
        aria-label={label || undefined}
        aria-disabled={disabled || undefined}
      >
        {value}
      </span>
      <button
        type="button"
        className="tcn-stepper-button"
        data-action="increment"
        aria-label={incrementLabel}
        disabled={disabled || value >= max}
        onClick={() => setValue(value + step)}
      >
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
          <path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
        </svg>
      </button>
    </div>
  );
}
Source · Vue
<script setup lang="ts">
/**
 * Numeric stepper — a value with decrement/increment controls. iOS renders the
 * classic joined pill (secondary-container background, a hairline splitting the
 * −/+ segments with the value between them); MD renders tonal circular icon
 * buttons flanking the value. Both share this markup; the cascade decides the
 * look. The buttons disable at the bounds and the value carries spinbutton
 * semantics (`aria-valuenow` / `min` / `max`) for assistive tech. Two-way bound
 * with `v-model`.
 *
 * Press-and-hold auto-repeat is not implemented (it needs timer/pointer
 * lifecycle plumbing in the engine); a future engine hook can add it without
 * changing this markup.
 */
const props = withDefaults(
  defineProps<{
    min?: number;
    max?: number;
    step?: number;
    disabled?: boolean;
    /** Accessible name for the value (announced by assistive tech). */
    label?: string;
    decrementLabel?: string;
    incrementLabel?: string;
  }>(),
  {
    min: -Infinity,
    max: Infinity,
    step: 1,
    disabled: false,
    decrementLabel: 'Decrease',
    incrementLabel: 'Increase',
  },
);

const model = defineModel<number>({ required: true });

const setValue = (next: number): void => {
  if (!props.disabled) {
    model.value = Math.min(props.max, Math.max(props.min, next));
  }
};
</script>

<template>
  <div class="tcn-stepper" role="group" :aria-label="label || undefined">
    <button
      type="button"
      class="tcn-stepper-button"
      data-action="decrement"
      :aria-label="decrementLabel"
      :disabled="disabled || model <= min"
      @click="setValue(model - step)"
    >
      <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path d="M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
      </svg>
    </button>
    <span
      class="tcn-stepper-value"
      role="spinbutton"
      :aria-valuenow="Number.isFinite(model) ? model : undefined"
      :aria-valuemin="Number.isFinite(min) ? min : undefined"
      :aria-valuemax="Number.isFinite(max) ? max : undefined"
      :aria-label="label || undefined"
      :aria-disabled="disabled || undefined"
    >
      {{ model }}
    </span>
    <button
      type="button"
      class="tcn-stepper-button"
      data-action="increment"
      :aria-label="incrementLabel"
      :disabled="disabled || model >= max"
      @click="setValue(model + step)"
    >
      <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
      </svg>
    </button>
  </div>
</template>
export { default as TcnStepper } from './TcnStepper.vue';

Last updated on July 24, 2026

Was this page helpful?