Skip to content

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

vue
<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>
vue
<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>
vue
<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>
vue
<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>
vue
<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>
vue
<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>
vue
<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 min is greater than 0, the component still allows 0 as the "not yet selected" state.
  • The first increment jumps from 0 to min, so a product with :min="2" adds 2 on the first +.
  • After the quantity has reached min, normal validation still applies for typed values between 1 and min - 1.

Events and delayed sync

  • v-model and change stay immediate, so the field can update optimistically without waiting for the server.
  • commit is intended for server sync. It emits { value, previousValue, source } after commit-delay milliseconds of inactivity, or immediately when commit-delay is 0.
  • 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-model value 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 max prop 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 label prop meaningful. It becomes the input label and the fallback wording for the increment/decrement button labels.
  • Use item-name when 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-valuestep is exposed on the spinbutton input so assistive technology can announce the configured step size.

Component source

vue
<!-- 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>