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.

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(2)
</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.

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.

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,
        required: true,
    },
    action: {
        type: String,
        default: null,
    },
    id: {
        type: String,
        default: null,
    },
    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) {
    const safeValue = toInteger(value, min)
    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)

function getNormalizedPropValue() {
    return normalizeQuantity(props.modelValue, minValue.value, effectiveMax.value, stepValue.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)
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 > minValue.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 = 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) {
        announce(resolveMessage('minimumReached', minValue.value))
        return
    }

    const nextValue = 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(minValue.value, source, {
            statusMessage: resolveMessage('quantityUpdated', minValue.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)

    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)
    emit('focus', event)
}

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(minValue.value, 'keyboard', {
                statusMessage: committedValue.value === minValue.value
                    ? resolveMessage('minimumReached', minValue.value)
                    : resolveMessage('quantityUpdated', minValue.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(
    [() => 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 }"
        :aria-busy="props.loading ? 'true' : undefined"
    >
        <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"
                :disabled="!canDecrement"
                :aria-label="`Decrease ${labelTarget} by ${stepValue}`"
                :aria-hidden="isActionState ? 'true' : undefined"
                @click="decrement('decrement')"
            >
                -
            </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="minValue"
                :aria-valuemax="hasUpperBound ? effectiveMax : undefined"
                :aria-invalid="isDraftInvalid ? 'true' : undefined"
                :aria-errormessage="isDraftInvalid ? errorId : undefined"
                :aria-describedby="describedBy"
                :aria-hidden="isActionState ? 'true' : undefined"
                :tabindex="isActionState ? -1 : undefined"
                @focus="onFocus"
                @blur="onBlur"
                @input="onInput"
                @keydown="onKeydown"
            />
            <button
                type="button"
                class="c-button c-quantity-stepper__button--increment"
                :disabled="!canIncrement"
                :aria-label="`Increase ${labelTarget} by ${stepValue}`"
                @click="increment('increment')"
            >
                <span v-if="isActionState">{{ 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>