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';