Quantity stepper
This Vue component relies heavily on the CSS component Plus-minus. It keeps using the existing c-plus-minus and c-button framework classes, but wraps them in a reusable Vue component with v-model, keyboard support, normalization of min/max/step, and status/error messaging.
TIP
Pass data-product-id when the consuming app needs to identify the outer wrapper element for analytics, tracking, or tests. If you do not provide it, the attribute is not rendered. The component is prepped with data-testid="quantity-decrease" and data-testid="quantity-increase" on the -/+ buttons for client side testing purposes (e.g. using Playwright).
Live example
Sold in steps of 2. Max 10 per order.
Usage
<script setup>
import { ref } from 'vue'
import { QuantityStepper } from '@tickster/ui-framework/library.mjs'
const quantity = ref(0)
</script>
<template>
<quantity-stepper
v-model="quantity"
:min="0"
:max="10"
label="Ticket quantity"
item-name="tickets" />
</template><script setup>
import { ref } from 'vue'
import { QuantityStepper } from '@tickster/ui-framework/library.mjs'
const quantity = ref(0)
</script>
<template>
<quantity-stepper
v-model="quantity"
:min="0"
:max="10"
:action="`Buy`"
label="Ticket quantity"
item-name="tickets" />
</template><script setup>
import { ref } from 'vue'
import { QuantityStepper } from '@tickster/ui-framework/library.mjs'
const quantity = ref(0)
</script>
<template>
<quantity-stepper
v-model="quantity"
:min="2"
:max="10"
:step="2"
label="Merch quantity"
item-name="shirts"
hint="Sold in steps of 2. Max 10 per order." />
</template><script setup>
import { ref } from 'vue'
import { QuantityStepper } from '@tickster/ui-framework/library.mjs'
const quantity = ref(0)
const l10n = {
quantityUpdated: (value) => `Antal uppdaterat till ${value}`,
minimumReached: (min) => `Minsta antal är ${min}`,
maximumReached: (max) => `Högsta antal är ${max}`,
nonNumericError: () => 'Ange endast siffror.',
belowMinError: (min) => `Minsta antal är ${min}.`,
aboveMaxError: (max) => `Högsta antal är ${max}.`,
stepMismatchError: (step, value) => `Antalet måste öka i steg om ${step}. Använder ${value}.`,
resetStatus: (value) => `Ogiltigt värde. Antalet återställdes till ${value}`,
}
</script>
<template>
<quantity-stepper
v-model="quantity"
:min="0"
:max="10"
:l10n="l10n"
label="Antal"
item-name="biljetter" />
</template><script setup>
import { ref } from 'vue'
import { QuantityStepper } from '@tickster/ui-framework/library.mjs'
const quantity = ref(0)
const isLoading = ref(false)
</script>
<template>
<quantity-stepper
v-model="quantity"
:loading="isLoading"
label="Ticket quantity"
item-name="tickets" />
</template><script setup>
import { ref } from 'vue'
import { QuantityStepper } from '@tickster/ui-framework/library.mjs'
const quantity = ref(7)
const confirmedQuantity = ref(7)
const isSaving = ref(false)
let requestId = 0
async function updateCartQuantity(nextQuantity) {
return fetch('/api/cart/tickets', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
quantity: nextQuantity,
}),
}).then((response) => response.json())
}
async function syncQuantity({ value }) {
const currentRequestId = ++requestId
isSaving.value = true
try {
const response = await updateCartQuantity(value)
if (currentRequestId !== requestId) {
return
}
if (!response.success) {
throw new Error('Quantity unavailable')
}
confirmedQuantity.value = value
quantity.value = value
} catch {
if (currentRequestId !== requestId) {
return
}
quantity.value = confirmedQuantity.value
} finally {
if (currentRequestId === requestId) {
isSaving.value = false
}
}
}
</script>
<template>
<quantity-stepper
v-model="quantity"
:commit-delay="400"
:disabled="isSaving"
:loading="isSaving"
label="Ticket quantity"
item-name="tickets"
@commit="syncQuantity" />
</template><script setup>
import { computed, ref } from 'vue'
import { QuantityStepper } from '@tickster/ui-framework/library.mjs'
const purchaseLimit = 10
const adultQuantity = ref(2)
const childQuantity = ref(1)
const remaining = computed(() => purchaseLimit - adultQuantity.value - childQuantity.value)
const childMax = computed(() => childQuantity.value + remaining.value)
const adultMax = computed(() => adultQuantity.value + remaining.value)
async function findTickets() {
await fetch('/api/tickets/find', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
adults: adultQuantity.value,
children: childQuantity.value,
}),
})
}
</script>
<template>
<quantity-stepper
v-model="adultQuantity"
:auto-commit="false"
:max="adultMax"
label="Adult tickets"
item-name="adult tickets" />
<quantity-stepper
v-model="childQuantity"
:auto-commit="false"
:max="childMax"
class="u-margin-top--large"
label="Children tickets"
item-name="children tickets" />
<button class="c-button c-button--primary u-margin-top--large" type="button" @click="findTickets">
Find tickets
</button>
</template>Localization
Pass an l10n object when the consuming app needs translated status/error text. Each key can be a formatter function.
quantityUpdated(value)minimumReached(min)maximumReached(max)nonNumericError()belowMinError(min)aboveMaxError(max)stepMismatchError(step, value)resetStatus(value)
Loading state
Pass loading when the consuming app is waiting on an async update and wants the stepper root to expose aria-busy and the framework u-loading utility.
Minimum quantity behavior
- When
minis greater than0, the component still allows0as the "not yet selected" state. - The first increment jumps from
0tomin, so a product with:min="2"adds2on the first+. - After the quantity has reached
min, normal validation still applies for typed values between1andmin - 1.
Events and delayed sync
v-modelandchangestay immediate, so the field can update optimistically without waiting for the server.commitis intended for server sync. It emits{ value, previousValue, source }aftercommit-delaymilliseconds of inactivity, or immediately whencommit-delayis0.- Set
auto-commit="false"when the consuming flow should not auto-submit at all, for example when several steppers share one purchase limit and a separate button performs the availability request. - If the consuming app receives an API error or an out-of-stock response, reset the bound
v-modelvalue to the last confirmed server value instead of partially adjusting the quantity. - For stock that can change after render, keep the server as the source of truth. The
maxprop is still a client-side normalization rule, so it should only be used when clamping to the maximum is actually desired.
Accessibility notes
- Keep the
labelprop meaningful. It becomes the input label and the fallback wording for the increment/decrement button labels. - Use
item-namewhen you want button labels like “Increase tickets by 1” while keeping a shorter visible field label. - Validation feedback is exposed through
aria-invalid,aria-errormessage, and a polite live region for quantity/status updates. aria-valuestepis exposed on the spinbutton input so assistive technology can announce the configured step size.
Component source
<!-- Usage: bind with v-model and pass a visible label plus optional min, max, step, and hint props. -->
<script setup>
import { computed, getCurrentInstance, nextTick, onBeforeUnmount, ref, watch } from 'vue'
defineOptions({
name: 'QuantityStepper',
})
const defaultL10n = {
quantityUpdated: (value) => `Quantity updated to ${value}`,
minimumReached: (min) => `Minimum ${min} reached`,
maximumReached: (max) => `Maximum ${max} reached`,
nonNumericError: () => 'Enter digits only.',
belowMinError: (min) => `Minimum quantity is ${min}.`,
aboveMaxError: (max) => `Maximum quantity is ${max}.`,
stepMismatchError: (step, value) => `Quantity must increase in steps of ${step}. Using ${value}.`,
resetStatus: (value) => `Invalid value. Quantity reset to ${value}`,
}
const props = defineProps({
modelValue: {
type: Number,
required: true,
},
label: {
type: String,
default: null,
},
action: {
type: String,
default: null,
},
id: {
type: String,
default: null,
},
dataProductId: {
type: String,
},
name: {
type: String,
default: null,
},
itemName: {
type: String,
default: null,
},
min: {
type: Number,
default: 0,
validator: (value) => Number.isInteger(value) && value >= 0,
},
max: {
type: Number,
default: null,
validator: (value) => value === null || Number.isInteger(value),
},
step: {
type: Number,
default: 1,
validator: (value) => Number.isInteger(value) && value > 0,
},
autoCommit: {
type: Boolean,
default: true,
},
commitDelay: {
type: Number,
default: 0,
validator: (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0,
},
disabled: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
hint: {
type: String,
default: null,
},
l10n: {
type: Object,
default: null,
},
})
const emit = defineEmits({
'update:modelValue': (value) => typeof value === 'number' && !Number.isNaN(value),
change: (payload) => Boolean(payload),
commit: (payload) => Boolean(payload),
invalid: (payload) => Boolean(payload),
focus: (event) => Boolean(event),
blur: (event) => Boolean(event),
})
const instance = getCurrentInstance()
function toNumber(value, fallback) {
const numericValue = Number(value)
if (Number.isNaN(numericValue)) {
return fallback
}
return numericValue
}
function toInteger(value, fallback) {
const numericValue = toNumber(value, fallback)
return Math.floor(numericValue)
}
function resolveEffectiveMax(min, rawMax, step) {
if (rawMax === null) {
return null
}
if (rawMax < min) {
return min
}
const alignedMax = min + Math.floor((rawMax - min) / step) * step
return alignedMax
}
function clampToRange(value, min, max) {
if (max === null) {
return Math.max(value, min)
}
return Math.min(Math.max(value, min), max)
}
function alignToStep(value, min, step) {
return min + Math.floor((value - min) / step) * step
}
function normalizeQuantity(value, min, max, step, allowZeroState = false) {
const safeValue = toInteger(value, min)
if (allowZeroState && safeValue <= 0) {
return 0
}
const clampedValue = clampToRange(safeValue, min, max)
const alignedValue = alignToStep(clampedValue, min, step)
return clampToRange(alignedValue, min, max)
}
function parseDraft(raw) {
if (raw === '') {
return {
isEmpty: true,
value: null,
reason: null,
}
}
if (!/^\d+$/.test(raw)) {
return {
isEmpty: false,
value: null,
reason: 'non-numeric',
}
}
return {
isEmpty: false,
value: Number.parseInt(raw, 10),
reason: null,
}
}
const minValue = computed(() => Math.max(0, toInteger(props.min, 0)))
const stepValue = computed(() => Math.max(1, toInteger(props.step, 1)))
const commitDelayValue = computed(() => Math.max(0, toNumber(props.commitDelay, 0)))
const rawMaxValue = computed(() => (
props.max === null || props.max === undefined
? null
: toInteger(props.max, null)
))
const effectiveMax = computed(() => resolveEffectiveMax(minValue.value, rawMaxValue.value, stepValue.value))
const hasUpperBound = computed(() => effectiveMax.value !== null)
const isNonInteractive = computed(() => props.disabled)
const supportsZeroQuantityState = computed(() => minValue.value > 0)
const minimumCommittedValue = computed(() => (supportsZeroQuantityState.value ? 0 : minValue.value))
function getNormalizedPropValue() {
return normalizeQuantity(props.modelValue, minValue.value, effectiveMax.value, stepValue.value, supportsZeroQuantityState.value)
}
const committedValue = ref(getNormalizedPropValue())
const draftValue = ref(String(committedValue.value))
const isFocused = ref(false)
const errorMessage = ref(null)
const statusMessage = ref('')
const hasSyncedExternalProps = ref(false)
const actionLabelVisible = ref(false)
let pendingCommitTimeoutId = null
let pendingCommitPayload = null
const inputId = computed(() => props.id || `plus-minus-${instance?.uid ?? 'field'}`)
const hintId = computed(() => `${inputId.value}-hint`)
const errorId = computed(() => `${inputId.value}-error`)
const labelTarget = computed(() => (props.itemName || props.label).trim())
const isDraftInvalid = computed(() => isFocused.value && draftValue.value !== '' && !/^\d+$/.test(draftValue.value))
const describedBy = computed(() => {
const ids = []
if (props.hint) {
ids.push(hintId.value)
}
if (errorMessage.value) {
ids.push(errorId.value)
}
return ids.length > 0 ? ids.join(' ') : undefined
})
const canDecrement = computed(() => !isNonInteractive.value && committedValue.value > minimumCommittedValue.value)
const canIncrement = computed(() => !isNonInteractive.value && (effectiveMax.value === null || committedValue.value < effectiveMax.value))
const isActionState = computed(() => Boolean(props.action) && !canDecrement.value)
function announce(message) {
statusMessage.value = ''
if (!message) {
return
}
nextTick(() => {
statusMessage.value = message
})
}
function clearError() {
errorMessage.value = null
}
function resolveMessage(key, ...args) {
const entry = props.l10n?.[key] ?? defaultL10n[key]
if (typeof entry === 'function') {
return entry(...args)
}
return entry ?? ''
}
function clearPendingCommitTimeout() {
if (pendingCommitTimeoutId === null) {
return
}
clearTimeout(pendingCommitTimeoutId)
pendingCommitTimeoutId = null
}
function cancelPendingCommit() {
clearPendingCommitTimeout()
pendingCommitPayload = null
}
function flushPendingCommit() {
if (!pendingCommitPayload) {
return
}
const nextPayload = pendingCommitPayload
clearPendingCommitTimeout()
pendingCommitPayload = null
emit('commit', nextPayload)
}
function scheduleCommit(payload) {
if (!props.autoCommit) {
cancelPendingCommit()
return
}
pendingCommitPayload = pendingCommitPayload
? {
...pendingCommitPayload,
value: payload.value,
source: payload.source,
}
: { ...payload }
if (commitDelayValue.value <= 0) {
flushPendingCommit()
return
}
clearPendingCommitTimeout()
pendingCommitTimeoutId = setTimeout(() => {
flushPendingCommit()
}, commitDelayValue.value)
}
function commitValue(nextValue, source, options = {}) {
const previousValue = committedValue.value
committedValue.value = nextValue
draftValue.value = String(nextValue)
if (options.clearError !== false) {
clearError()
}
if (nextValue === previousValue) {
if (options.statusMessage) {
announce(options.statusMessage)
}
return false
}
emit('update:modelValue', nextValue)
const payload = {
value: nextValue,
previousValue,
source,
}
emit('change', payload)
scheduleCommit(payload)
if (options.statusMessage) {
announce(options.statusMessage)
}
return true
}
function emitInvalid(reason, attemptedValue) {
emit('invalid', {
reason,
attemptedValue,
})
}
function increment(source) {
if (!canIncrement.value) {
if (hasUpperBound.value) {
announce(resolveMessage('maximumReached', effectiveMax.value))
}
return
}
const nextValue = committedValue.value === 0 && supportsZeroQuantityState.value
? minValue.value
: effectiveMax.value === null
? committedValue.value + stepValue.value
: Math.min(committedValue.value + stepValue.value, effectiveMax.value)
commitValue(nextValue, source, {
statusMessage: resolveMessage('quantityUpdated', nextValue),
})
}
function decrement(source) {
if (!canDecrement.value) {
if (!supportsZeroQuantityState.value || committedValue.value !== 0) {
announce(resolveMessage('minimumReached', minimumCommittedValue.value))
}
return
}
const nextValue = supportsZeroQuantityState.value && committedValue.value <= minValue.value
? 0
: Math.max(committedValue.value - stepValue.value, minValue.value)
commitValue(nextValue, source, {
statusMessage: resolveMessage('quantityUpdated', nextValue),
})
}
function commitDraft(source) {
const attemptedRawValue = draftValue.value
const parsedDraft = parseDraft(draftValue.value)
if (parsedDraft.isEmpty) {
commitValue(minimumCommittedValue.value, source, {
statusMessage: resolveMessage('quantityUpdated', minimumCommittedValue.value),
})
return
}
if (parsedDraft.reason === 'non-numeric') {
errorMessage.value = resolveMessage('nonNumericError')
draftValue.value = String(committedValue.value)
emitInvalid('non-numeric', attemptedRawValue)
announce(resolveMessage('resetStatus', committedValue.value))
return
}
const attemptedValue = parsedDraft.value
const normalizedValue = normalizeQuantity(
attemptedValue,
minValue.value,
effectiveMax.value,
stepValue.value,
supportsZeroQuantityState.value
)
if (supportsZeroQuantityState.value && attemptedValue === 0) {
commitValue(0, source, {
statusMessage: resolveMessage('quantityUpdated', 0),
})
return
}
if (attemptedValue < minValue.value) {
errorMessage.value = resolveMessage('belowMinError', minValue.value)
emitInvalid('below-min', attemptedValue)
commitValue(minValue.value, source, {
clearError: false,
statusMessage: resolveMessage('resetStatus', minValue.value),
})
return
}
if (effectiveMax.value !== null && attemptedValue > effectiveMax.value) {
errorMessage.value = resolveMessage('aboveMaxError', effectiveMax.value)
emitInvalid('above-max', attemptedValue)
commitValue(effectiveMax.value, source, {
clearError: false,
statusMessage: resolveMessage('resetStatus', effectiveMax.value),
})
return
}
if (normalizedValue !== attemptedValue) {
errorMessage.value = resolveMessage('stepMismatchError', stepValue.value, normalizedValue)
emitInvalid('step-mismatch', attemptedValue)
commitValue(normalizedValue, source, {
clearError: false,
statusMessage: resolveMessage('resetStatus', normalizedValue),
})
return
}
commitValue(normalizedValue, source, {
statusMessage: resolveMessage('quantityUpdated', normalizedValue),
})
}
function onInput(event) {
draftValue.value = event.target.value
if (draftValue.value === '' || /^\d+$/.test(draftValue.value)) {
clearError()
return
}
errorMessage.value = resolveMessage('nonNumericError')
}
function onFocus(event) {
isFocused.value = true
draftValue.value = String(committedValue.value)
event.target.select()
emit('focus', event)
}
function onClick(event) {
event.target.select()
}
function onBlur(event) {
commitDraft('input-commit')
isFocused.value = false
draftValue.value = String(committedValue.value)
emit('blur', event)
}
function onKeydown(event) {
if (props.disabled) {
return
}
switch (event.key) {
case 'ArrowUp':
event.preventDefault()
increment('keyboard')
break
case 'ArrowDown':
event.preventDefault()
decrement('keyboard')
break
case 'Home':
event.preventDefault()
commitValue(minimumCommittedValue.value, 'keyboard', {
statusMessage: committedValue.value === minimumCommittedValue.value
? resolveMessage('minimumReached', minimumCommittedValue.value)
: resolveMessage('quantityUpdated', minimumCommittedValue.value),
})
break
case 'End':
if (!hasUpperBound.value) {
return
}
event.preventDefault()
commitValue(effectiveMax.value, 'keyboard', {
statusMessage: committedValue.value === effectiveMax.value
? resolveMessage('maximumReached', effectiveMax.value)
: resolveMessage('quantityUpdated', effectiveMax.value),
})
break
case 'Enter':
event.preventDefault()
commitDraft('input-commit')
break
case 'Escape':
event.preventDefault()
draftValue.value = String(committedValue.value)
clearError()
announce('')
break
}
}
watch(commitDelayValue, (nextDelay) => {
if (nextDelay <= 0) {
flushPendingCommit()
}
})
watch(isActionState, (nextIsActionState, previousIsActionState) => {
if (!nextIsActionState) {
actionLabelVisible.value = false
return
}
if (previousIsActionState === false) {
actionLabelVisible.value = false
return
}
actionLabelVisible.value = true
}, { immediate: true })
function onDecrementTransitionEnd(event) {
if (!isActionState.value || actionLabelVisible.value || event.propertyName !== 'opacity') {
return
}
actionLabelVisible.value = true
}
watch(
[() => props.modelValue, minValue, effectiveMax, stepValue],
() => {
const normalizedValue = getNormalizedPropValue()
const previousValue = committedValue.value
if (pendingCommitPayload && pendingCommitPayload.value !== normalizedValue) {
cancelPendingCommit()
}
committedValue.value = normalizedValue
if (!isFocused.value) {
draftValue.value = String(normalizedValue)
}
if (normalizedValue !== props.modelValue) {
emit('update:modelValue', normalizedValue)
if (hasSyncedExternalProps.value && normalizedValue !== previousValue) {
emit('change', {
value: normalizedValue,
previousValue,
source: 'prop-normalize',
})
}
}
hasSyncedExternalProps.value = true
},
{ immediate: true }
)
onBeforeUnmount(() => {
cancelPendingCommit()
})
</script>
<template>
<div
class="c-quantity-stepper"
:class="{ 'is-disabled': props.disabled }"
:data-product-id="props.dataProductId"
:aria-busy="props.loading ? 'true' : undefined"
>
<label v-if="props.label" :for="inputId" class="c-label u-font--bold">{{ props.label }}</label>
<p v-if="props.hint" :id="hintId" class="c-note u-margin-top--none u-margin-bottom--none">{{ props.hint }}</p>
<div
class="c-plus-minus c-quantity-stepper__control"
:class="{ 'is-disabled': props.disabled, 'u-loading': props.loading, 'c-plus-minus--action': isActionState }"
>
<button
type="button"
class="c-button c-quantity-stepper__button--decrement"
data-testid="quantity-decrease"
:disabled="!canDecrement"
:aria-label="`Decrease ${labelTarget} by ${stepValue}`"
:aria-hidden="isActionState ? 'true' : undefined"
@click="decrement('decrement')"
@transitionend="onDecrementTransitionEnd"
>
-
</button>
<input
:id="inputId"
:name="props.name"
:value="isFocused ? draftValue : String(committedValue)"
class="c-plus-minus__amount"
type="text"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="off"
spellcheck="false"
role="spinbutton"
:disabled="props.disabled || isActionState"
:aria-valuenow="committedValue"
:aria-valuemin="minimumCommittedValue"
:aria-valuemax="hasUpperBound ? effectiveMax : undefined"
:aria-valuestep="stepValue"
:aria-invalid="isDraftInvalid ? 'true' : undefined"
:aria-errormessage="isDraftInvalid ? errorId : undefined"
:aria-describedby="describedBy"
:aria-hidden="isActionState ? 'true' : undefined"
:tabindex="isActionState ? -1 : undefined"
@focus="onFocus"
@click="onClick"
@blur="onBlur"
@input="onInput"
@keydown="onKeydown"
/>
<button
type="button"
class="c-button c-quantity-stepper__button--increment"
data-testid="quantity-increase"
:disabled="!canIncrement"
:aria-label="`Increase ${labelTarget} by ${stepValue}`"
@click="increment('increment')"
>
<span v-if="actionLabelVisible">{{ props.action }}</span>
<span v-else>+</span>
</button>
</div>
<p v-if="errorMessage" :id="errorId" class="c-note c-note--negative u-margin-top--none u-margin-bottom--none">{{ errorMessage }}</p>
<div class="c-plus-minus__status u-hidden--visually" role="status" aria-live="polite" aria-atomic="true">
{{ statusMessage }}
</div>
</div>
</template>