Skip to content
Download ZIP

Blue accent palette · 10.5 KB ZIP File receipt View as Markdown View code

Preview

Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.

Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
cmp_quantity_stepper_01 · version 1.0.0 · Blue accent palette
Using the PageSugar MCP server, fetch component cmp_quantity_stepper_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.

Not connected yet? Set up the MCP server

Code

Palette
Blue accent
Version
1.0.0
Digest
Full digest
sha256-419607f08960cae3253517437810d4f6b0fec1266dc5052d8449df168fdfac9d
QuantityStepper.svelte Svelte · 19.1 KB Raw
<!--
	A quantity stepper: a decrease button, a directly editable whole number and an increase button
	joined into one shape, with an optional line under it: a hint in range, the bound's reason once a
	bound is reached, and the allowed range while a typed number is outside it.
	The value is an APG spinbutton (arrows, Page Up and Down, Home and End). Typing is free until
	the field is left or Enter is pressed, then the number is clamped and committed once. The
	buttons stay in the tab order and use aria-disabled at a bound, so focus is never lost and the
	reason is read with them. It holds no stock or cart logic; limits come from the consumer.
-->
<script lang="ts" module>
	export type QuantityStepperSize = 'sm' | 'md';
	export type QuantityStepperLabelVisibility = 'visible' | 'hidden';
	export type QuantityStepperButtonStyle = 'outline' | 'filled';
</script>

<script lang="ts">
	interface Props {
		/** Accessible label, such as 'Quantity' or 'Guests'. */
		label: string;
		/** Bindable whole number. */
		value: number;
		/** Lowest value. */
		min?: number;
		/** Highest value. Omitted, there is no upper bound. */
		max?: number;
		/** How far one press, or one arrow key, moves the value. */
		step?: number;
		/** Called once per committed change: a press, a key, or a typed value on blur or Enter. */
		onChange?: (value: number) => void;
		/** Shown, and announced, once the value reaches max: 'Limit 5 per order'. */
		maxMessage?: string;
		/** Shown, and announced, once the value reaches min: 'At least 1 adult per booking'. */
		minMessage?: string;
		/** Shown under the control while the value is in range: '1 to 10 seats per change'. */
		hint?: string;
		/** Shown while a typed number is outside the bounds. Defaults to 'Enter a number from 1 to 10'. */
		rangeMessage?: string;
		/** Announced when pending starts. */
		pendingLabel?: string;
		/** sm 32 px, md 40 px tall; both 44 px on touch screens. */
		size?: QuantityStepperSize;
		/** 'hidden' keeps the label for screen readers only, for a cart row that names the item. */
		labelVisibility?: QuantityStepperLabelVisibility;
		/** 'outline' divides the buttons from the value with hairlines; 'filled' tints them. */
		buttonStyle?: QuantityStepperButtonStyle;
		/** Names the buttons: 'Decrease quantity of Linen shirt'. */
		itemName?: string;
		/** Full name for the decrease button, replacing the one built from label and itemName. */
		decreaseLabel?: string;
		/** Full name for the increase button, replacing the one built from label and itemName. */
		increaseLabel?: string;
		/** Spoken form of the value: (n) => `${n} tickets`. */
		valueText?: (value: number) => string;
		/** Form field name, so the value submits with a form. */
		name?: string;
		/** Takes the whole control out of use and out of the tab order. */
		disabled?: boolean;
		/** Your update is in flight: the value and buttons hold, still focusable. */
		pending?: boolean;
		/** Input id. Defaults to one generated per instance. */
		id?: string;
		/** Extra classes on the outer wrapper, for placement. */
		class?: string;
	}

	let {
		label,
		value = $bindable(),
		min = 0,
		max,
		step = 1,
		onChange,
		maxMessage,
		minMessage,
		hint,
		rangeMessage,
		pendingLabel = 'Updating',
		size = 'md',
		labelVisibility = 'visible',
		buttonStyle = 'outline',
		itemName,
		decreaseLabel,
		increaseLabel,
		valueText,
		name,
		disabled = false,
		pending = false,
		id,
		class: className
	}: Props = $props();

	const uid = $props.id();
	const inputId = $derived(id ?? `${uid}-input`);
	const labelId = `${uid}-label`;
	const minId = `${uid}-min`;
	const maxId = `${uid}-max`;
	const hintId = `${uid}-hint`;
	const rangeId = `${uid}-range`;

	/* Bounds and step are whole, finite numbers whatever is passed; a max below min becomes min. */
	const LIMIT = Number.MAX_SAFE_INTEGER;
	const whole = (n: number | undefined, fallback: number) =>
		typeof n === 'number' && Number.isFinite(n)
			? Math.min(LIMIT, Math.max(-LIMIT, Math.round(n)))
			: fallback;
	const lower = $derived(whole(min, 0));
	const hasMax = $derived(typeof max === 'number' && Number.isFinite(max));
	const upper = $derived(hasMax ? Math.max(lower, whole(max, LIMIT)) : LIMIT);
	const stepSize = $derived(Math.max(1, Math.abs(whole(step, 1))));
	const clamp = (n: number) => Math.min(upper, Math.max(lower, Math.round(n)));
	/* The committed value, read as a whole number inside the bounds even if it was passed otherwise. */
	const current = $derived(clamp(whole(value, lower)));

	/* What the field shows. It follows value, and holds the typing until it is committed. */
	let draft = $derived(String(current));
	/* A long run of digits saturates at the safe limit instead of becoming Infinity. */
	const typed = $derived(/^-?\d+$/.test(draft) ? whole(Number(draft), 0) : null);
	/* The number the bounds, the message and aria-valuenow describe: the typing while there is one. */
	const shown = $derived(typed ?? current);
	const atMin = $derived(shown <= lower);
	const atMax = $derived(shown >= upper);
	const outOfRange = $derived(typed !== null && typed !== clamp(typed));
	const locked = $derived(disabled || pending);

	/* The field holds the longest bound in tabular digits, so 9 to 10 moves nothing. */
	const digits = $derived(Math.max(2, String(lower).length, hasMax ? String(upper).length : 3));

	/* 'Increase quantity of Linen shirt', and 'by 5' when a press moves more than one. */
	const lowerFirst = (text: string) => text.charAt(0).toLocaleLowerCase() + text.slice(1);
	const of = $derived(
		`${itemName ? ` of ${itemName}` : ''}${stepSize > 1 ? ` by ${stepSize}` : ''}`
	);
	const decreaseName = $derived(decreaseLabel ?? `Decrease ${lowerFirst(label)}${of}`);
	const increaseName = $derived(increaseLabel ?? `Increase ${lowerFirst(label)}${of}`);

	/*
	 * The buttons are square end caps of one shape: hover and press tone the fill (never opacity),
	 * and at a bound or while pending they stay focusable but read as out of reach.
	 */
	const button = $derived([
		'quantity-stepper__button grid w-(--_h) flex-none place-items-center text-(--_ink)',
		'transition-[background-color,color] duration-[120ms] ease-(--_ease) motion-reduce:transition-none',
		'hover:not-aria-disabled:enabled:bg-(--_fill-hover) active:not-aria-disabled:enabled:bg-(--_fill-press)',
		'aria-disabled:text-(--_ink-faint) disabled:text-(--_ink-faint) disabled:cursor-not-allowed',
		pending ? 'aria-disabled:cursor-progress' : 'aria-disabled:cursor-not-allowed',
		/* Focus draws the whole shape's edge (in the style block) and tints this end cap. */
		'focus-visible:bg-(--_fill-press) focus-visible:outline-hidden',
		'forced-colors:focus-visible:outline-2 forced-colors:focus-visible:-outline-offset-2',
		'forced-colors:aria-disabled:text-[GrayText]',
		buttonStyle === 'filled' ? 'bg-(--_fill)' : 'border-(--_divider)'
	]);

	const rangeText = $derived(
		rangeMessage ?? (hasMax ? `Enter a number from ${lower} to ${upper}` : `Enter ${lower} or more`)
	);
	/*
	 * One line under the control says one thing at a time: the bound's reason at a bound (in the
	 * danger colour while a typed number is past it), the range while a typed number is outside it
	 * and the bound has no message, otherwise the hint.
	 */
	const active = $derived.by(() => {
		if (atMax && maxMessage) return 'max';
		if (atMin && minMessage) return 'min';
		if (outOfRange) return 'range';
		return hint ? 'hint' : null;
	});
	/* A button out of reach at a bound is described by that bound's reason. */
	const minActive = $derived(active === 'min');
	const maxActive = $derived(active === 'max');
	const ids = $derived({ max: maxId, min: minId, range: rangeId, hint: hintId });
	/* The line is reserved whenever it can hold something, so reaching a bound moves nothing. */
	const reserved = $derived(!!(hint || minMessage || maxMessage));
	const describedBy = $derived(active ? ids[active] : undefined);

	/* Polite announcements for changes made away from the field, where the value is not read. */
	let announcement = $state('');
	function announce(next: number, withValue: boolean, corrected = false) {
		const bound =
			next >= upper && maxMessage
				? maxMessage
				: next <= lower && minMessage
					? minMessage
					: corrected
						? rangeText
						: '';
		const spoken = withValue ? (valueText?.(next) ?? String(next)) : '';
		announcement = [spoken, bound].filter(Boolean).join('. ');
	}

	/* Pending replaces the glyph of the button last pressed with a spinner, and is announced once. */
	let lastDirection: -1 | 1 = $state(1);
	let wasPending = false;
	$effect(() => {
		if (pending && !wasPending) announcement = pendingLabel;
		wasPending = pending;
	});

	/** Clamp, store and report one change. Returns the stored value. */
	function commit(next: number, withValue: boolean) {
		const clamped = clamp(next);
		if (clamped !== value) {
			value = clamped;
			onChange?.(clamped);
			announce(clamped, withValue);
		}
		draft = String(clamped);
		return clamped;
	}

	function stepBy(delta: number, withValue: boolean) {
		if (locked) return;
		commit(shown + delta, withValue);
	}

	function press(direction: -1 | 1) {
		if (locked || (direction < 0 ? atMin : atMax)) return;
		lastDirection = direction;
		stepBy(direction * stepSize, true);
	}

	function onkeydown(event: KeyboardEvent) {
		if (event.altKey || event.ctrlKey || event.metaKey || event.isComposing || composing) return;
		const moves: Record<string, () => void> = {
			ArrowUp: () => stepBy(stepSize, false),
			ArrowDown: () => stepBy(-stepSize, false),
			PageUp: () => stepBy(stepSize * 10, false),
			PageDown: () => stepBy(-stepSize * 10, false),
			Home: () => !locked && commit(lower, false),
			End: () => !locked && hasMax && commit(upper, false),
			Enter: () => settle()
		};
		const move = moves[event.key];
		if (!move) return;
		/* Enter commits the typing and still submits the form the field is in. */
		if (event.key !== 'Enter') event.preventDefault();
		move();
	}

	/*
	 * An edit that would not read as a whole number (a decimal point, a letter, a second minus) is
	 * refused rather than stripped, so a pasted 1.5 never becomes 15 and -2 still clamps to min.
	 * IME composition is left alone until it ends.
	 */
	let composing = false;
	function accept(field: HTMLInputElement) {
		const text = field.value.trim();
		if (/^-?\d*$/.test(text)) draft = text;
		else field.value = draft;
	}
	function oninput(event: Event & { currentTarget: HTMLInputElement }) {
		if (composing) return;
		accept(event.currentTarget);
	}
	function oncompositionstart() {
		composing = true;
	}
	function oncompositionend(event: CompositionEvent & { currentTarget: HTMLInputElement }) {
		composing = false;
		accept(event.currentTarget);
	}

	/*
	 * Leaving the field commits the typing; an empty field or a lone minus goes back to the value.
	 * While pending or disabled nothing commits, and the field shows the value again.
	 */
	function settle() {
		if (locked || typed === null) {
			draft = String(current);
			return;
		}
		/* A typed number outside the bounds is corrected, and the correction is said aloud. */
		const corrected = typed !== clamp(typed);
		const next = commit(typed, false);
		if (corrected) announce(next, true, true);
	}
</script>

{#snippet glyph(direction: -1 | 1)}
	{#if pending && direction === lastDirection}
		<svg
			class={[
				'animate-spin text-(--_ink) motion-reduce:animate-none',
				size === 'sm' ? 'size-3.5' : 'size-4'
			]}
			viewBox="0 0 16 16"
			fill="none"
			aria-hidden="true"
		>
			<circle
				cx="8"
				cy="8"
				r="5.75"
				stroke="currentColor"
				stroke-opacity="0.25"
				stroke-width="1.75"
			/>
			<path
				d="M13.75 8A5.75 5.75 0 0 0 8 2.25"
				stroke="currentColor"
				stroke-width="1.75"
				stroke-linecap="round"
			/>
		</svg>
	{:else}
		<svg
			class={['quantity-stepper__glyph', size === 'sm' ? 'size-3.5' : 'size-4']}
			viewBox="0 0 16 16"
			fill="none"
			aria-hidden="true"
		>
			<path d="M3.5 8h9" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" />
			{#if direction > 0}
				<path d="M8 3.5v9" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" />
			{/if}
		</svg>
	{/if}
{/snippet}

<div
	class={['quantity-stepper grid min-w-0 justify-items-start gap-2', className]}
	data-size={size}
	data-style={buttonStyle}
	data-invalid={outOfRange ? '' : undefined}
	data-pending={pending ? '' : undefined}
	style:--_digits={digits}
>
	<label
		id={labelId}
		for={inputId}
		class={[
			'leading-5 font-medium text-pretty break-words text-(--_ink)',
			size === 'sm' ? 'text-[13px]' : 'text-sm',
			labelVisibility === 'hidden' && 'sr-only'
		]}>{label}</label
	>

	<div
		class="quantity-stepper__control flex h-(--_h) items-stretch overflow-hidden rounded-(--_radius) border border-(--_edge) bg-(--_surface)"
	>
		<button
			type="button"
			class={[button, 'rounded-s-(--_inner-radius)', buttonStyle === 'outline' && 'border-e']}
			aria-label={decreaseName}
			aria-controls={inputId}
			aria-describedby={minActive ? minId : undefined}
			aria-disabled={!disabled && (pending || atMin) ? 'true' : undefined}
			{disabled}
			onclick={() => press(-1)}
		>
			{@render glyph(-1)}
		</button>
		<input
			id={inputId}
			type="text"
			role="spinbutton"
			inputmode={lower < 0 ? 'text' : 'numeric'}
			autocomplete="off"
			spellcheck="false"
			enterkeyhint="done"
			{name}
			value={draft}
			{disabled}
			readonly={pending}
			aria-valuenow={shown}
			aria-valuemin={lower}
			aria-valuemax={hasMax ? upper : undefined}
			aria-valuetext={valueText?.(shown)}
			aria-invalid={outOfRange ? 'true' : undefined}
			aria-busy={pending ? 'true' : undefined}
			aria-describedby={describedBy}
			class={[
				'quantity-stepper__input w-(--_field) min-w-0 bg-transparent text-center font-semibold tabular-nums outline-none',
				'text-(--_value)',
				/* The number is the control's focal point; 16 px and up also keeps iOS from zooming. */
				size === 'sm' ? 'text-sm pointer-coarse:text-base' : 'text-lg'
			]}
			{oninput}
			{onkeydown}
			{oncompositionstart}
			{oncompositionend}
			onblur={settle}
		/>
		<button
			type="button"
			class={[button, 'rounded-e-(--_inner-radius)', buttonStyle === 'outline' && 'border-s']}
			aria-label={increaseName}
			aria-controls={inputId}
			aria-describedby={maxActive ? maxId : undefined}
			aria-disabled={!disabled && (pending || atMax) ? 'true' : undefined}
			{disabled}
			onclick={() => press(1)}
		>
			{@render glyph(1)}
		</button>
	</div>

	{#if reserved || active}
		<!-- Every message shares one cell, so the line holds its height whichever one is showing. -->
		<div class="grid text-[13px] leading-5">
			{#if hint}
				<p
					id={hintId}
					class={[
						'col-start-1 row-start-1 text-pretty break-words text-(--_muted)',
						active !== 'hint' && 'invisible'
					]}
				>
					{hint}
				</p>
			{/if}
			{#each [['min', minMessage], ['max', maxMessage], ['range', reserved || active === 'range' ? rangeText : undefined]] as const as [kind, text] (kind)}
				{#if text}
					<p
						id={ids[kind]}
						class={[
							'col-start-1 row-start-1 flex items-start gap-1 text-(--_message)',
							active !== kind && 'invisible'
						]}
					>
						{@render mark()}<span class="min-w-0 text-pretty break-words">{text}</span>
					</p>
				{/if}
			{/each}
		</div>
	{/if}

	<span class="sr-only" role="status">{announcement}</span>
</div>

{#snippet mark()}
	<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
		<svg class="size-3.5" viewBox="0 0 16 16" fill="none">
			<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.5" />
			<path d="M8 7.25v3.5" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" />
			<circle cx="8" cy="5" r="1" fill="currentColor" />
		</svg>
	</span>
{/snippet}

<style>
	/* Public tokens: set --quantity-stepper-* on the stepper or any ancestor to retone it. */
	.quantity-stepper {
		--_accent: var(--quantity-stepper-accent, #1d4ed8);
		--_ink: var(--quantity-stepper-ink, #18181b);
		--_muted: var(--quantity-stepper-muted, #52525b);
		/* 3:1 against white: the edge is what marks the field (WCAG 1.4.11). */
		--_hairline: var(--quantity-stepper-hairline, rgb(0 0 0 / 0.44));
		--_surface: var(--quantity-stepper-surface, #ffffff);
		--_danger: var(--quantity-stepper-danger, #b91c1c);

		/* Height per size; the buttons are square, so it is their width too. */
		--_h: 2.5rem;
		--_radius: 0.5rem;
		/* Inner corners share the shape's centre: the outer radius less its one-pixel edge. */
		--_inner-radius: calc(var(--_radius) - 1px);
		/* Tabular digits are 1ch wide each, plus the field's own inline padding. */
		--_field: calc(var(--_digits) * 1ch + 1.5rem);
		--_edge: var(--_hairline);
		--_ring: var(--_accent);
		--_value: var(--_ink);
		--_message: var(--_muted);
		--_divider: color-mix(in oklab, var(--_hairline) 45%, transparent);
		--_ink-faint: color-mix(in oklab, var(--_ink) 32%, var(--_surface));
		--_fill: color-mix(in oklab, var(--_surface), var(--_ink) 5%);
		--_fill-hover: color-mix(in oklab, var(--_surface), var(--_accent) 7%);
		--_fill-press: color-mix(in oklab, var(--_surface), var(--_accent) 12%);
		--_ease: cubic-bezier(0.2, 0, 0, 1);
	}
	.quantity-stepper[data-size='sm'] {
		--_h: 2rem;
		--_radius: 0.375rem;
	}
	@media (pointer: coarse) {
		/* The edge sits outside the targets: 44 px buttons and field inside a 46 px shape. */
		.quantity-stepper[data-size] {
			--_h: calc(2.75rem + 2px);
		}
	}
	.quantity-stepper[data-style='filled'] {
		--_fill-hover: color-mix(in oklab, var(--_surface), var(--_accent) 11%);
		--_fill-press: color-mix(in oklab, var(--_surface), var(--_accent) 16%);
	}

	/* A typed value outside the bounds: the edge, ring and message carry the danger colour. */
	.quantity-stepper[data-invalid] {
		--_edge: var(--_danger);
		--_ring: var(--_danger);
		--_message: var(--_danger);
	}
	.quantity-stepper[data-pending] {
		--_value: var(--_muted);
	}

	/*
	 * The press shrinks the glyph inside an enabled button: a relation between two elements, so
	 * its transition and reduced-motion override live here together.
	 */
	.quantity-stepper__glyph {
		transition: scale 80ms var(--_ease);
	}
	.quantity-stepper__button:not([aria-disabled], :disabled):active .quantity-stepper__glyph {
		scale: 0.86;
	}
	@media (prefers-reduced-motion: reduce) {
		.quantity-stepper__glyph {
			transition: none;
		}
		.quantity-stepper__button:not([aria-disabled], :disabled):active .quantity-stepper__glyph {
			scale: none;
		}
	}

	/*
	 * Focus anywhere in the shape draws a two-pixel edge round all of it, with a soft halo; a
	 * focused button also tints its own end cap, so the edge and the part it belongs to read as one.
	 */
	.quantity-stepper__control {
		transition-property: border-color, box-shadow;
		transition-duration: 150ms;
		transition-timing-function: var(--_ease);
	}
	.quantity-stepper__control:has(input:focus-visible, button:focus-visible) {
		border-color: var(--_ring);
		box-shadow:
			0 0 0 1px var(--_ring),
			0 0 0 4px color-mix(in oklab, var(--_ring) 14%, transparent);
	}
	@media (prefers-reduced-motion: reduce) {
		.quantity-stepper__control {
			transition: none;
		}
	}

	/* Disabled: faded and inert as one piece. */
	.quantity-stepper__control:has(input:disabled) {
		opacity: 0.55;
		cursor: not-allowed;
	}

	@media (forced-colors: active) {
		.quantity-stepper__control:has(input:focus-visible) {
			outline: 2px solid Highlight;
			outline-offset: 2px;
		}
	}
</style>

Give it a label and bind a whole number. The buttons, arrow keys and typed values all clamp to min and max, and onChange runs once per committed change so you can update a cart or booking. It does not check stock, update a cart or accept decimals: limits come from you, and your server must enforce them.

Suggested location
src/lib/components/quantity-stepper-01
Required props
labelvalue

Limitations

  • Whole numbers only. Decimals, units and currency belong in a number or measurement input.
  • Typed values are clamped to min and max but not snapped to step; a typed 7 with step 5 stays 7 and the buttons move it to 2 or 12.
  • The value submits as text under name; parse it on the server and enforce the same bounds there.
  • Holding a button does not repeat. Use the keyboard or type for large jumps.
  • Home and End set the value rather than moving the caret, as the spinbutton pattern asks.
  • Changing value, min or max from your code is not written back: the stepper shows and steps from the value clamped to the new bounds, but value keeps what you set until the next committed change.
  • The line under the control is reserved whenever hint, minMessage or maxMessage is set, so it holds its height across states. With none of them set, the range message for an out-of-range typed number adds a line while it shows.

Example

Svelte
<script lang="ts">
	import QuantityStepper from '$lib/components/quantity-stepper-01/QuantityStepper.svelte';

	let quantity = $state(1);
	let saving = $state(false);

	async function save(next: number) {
		saving = true;
		try {
			await fetch('/cart/linen-shirt', { method: 'PATCH', body: JSON.stringify({ quantity: next }) });
		} finally {
			saving = false;
		}
	}
</script>

<QuantityStepper
	label="Quantity"
	itemName="Linen shirt"
	bind:value={quantity}
	min={1}
	max={5}
	maxMessage="Limit 5 per order"
	pending={saving}
	onChange={save}
/>

The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.

Quantity stepper#

A whole number with a decrease and an increase button, joined into one shape. Use it for cart quantities, guest and ticket counts, and seats.

Committing changes#

value is bindable, and every change the stepper makes is a clamped whole number. onChange runs once for each committed change:

  • a press on either button,
  • an arrow, Page Up, Page Down, Home or End key in the field,
  • a typed value when the field loses focus or Enter is pressed.

Typing itself never calls onChange. An empty field, or one left holding only a minus sign, goes back to the last value on blur. An edit that is not a whole number (a decimal point, a letter) is refused as it is typed or pasted.

The stepper reads value inside the current bounds, but it does not write back when you change value, min or max yourself. If you lower max below the current value, set value too.

Limits and messages#

min defaults to 0 and max to no limit. At a bound the matching button stays focusable but does nothing, and minMessage or maxMessage appears under the control, is announced politely and becomes the button's description. The line is reserved whenever either message is set, so reaching a limit moves nothing on the page.

The stepper only shows limits you pass. Check stock and order rules on your server.

Saving to a server#

Set pending while your save runs. The value is muted and read-only and the buttons ignore presses, so a second change cannot race the first. Clear it when the save settles, and set value back if the server refused the change.

Translating#

Pass decreaseLabel and increaseLabel in the page's language; the built-in names are English ("Decrease quantity of Linen shirt"). Under dir="rtl" the decrease button moves to the right.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
labelstringYesNoneAccessible label, such as 'Quantity', 'Guests' or 'Seats'. Also builds the button names.
valuenumberYesNoneBindable whole number. Changes from buttons, keys and typed commits are clamped to min and max.
minnumberNo0Lowest value. Home jumps here; the decrease button is out of reach at it.
maxnumberNoNoneHighest value. Omitted, there is no upper bound and End does nothing.
stepnumberNo1How far one press or arrow key moves the value; Page Up and Down move ten steps.
onChange(value: number) => voidNoNoneCalled once per committed change: a button press, a key, or a typed value on blur or Enter. Never per keystroke, and not when the value is unchanged.
maxMessagestringNoNoneShown under the control, announced politely and attached to the increase button once the value reaches max, e.g. 'Limit 5 per order'.
minMessagestringNoNoneShown, announced and attached to the decrease button once the value reaches min.
hintstringNoNoneMuted line under the control while the value is in range, linked with aria-describedby: 'Up to 10 seats per change.' A bound message takes its place at a bound.
rangeMessagestringNoNoneShown in the danger colour while a typed number is outside the bounds and that bound has no message of its own. Defaults to 'Enter a number from {min} to {max}' (or 'Enter {min} or more'). Set it to translate.
pendingLabelstringNo'Updating'Announced politely when pending starts. Set it to translate.
size'sm' | 'md'No'md'32 or 40 px tall with square buttons; the value is 14 or 18 px semibold. Both sizes are 44 px on touch screens.
labelVisibility'visible' | 'hidden'No'visible''hidden' keeps the label for screen readers only, for a row that already names the item.
buttonStyle'outline' | 'filled'No'outline''outline' divides the buttons from the value with hairlines; 'filled' gives them a tinted fill instead.
itemNamestringNoNoneAdded to the button names: 'Decrease quantity of Linen shirt'. When step is above 1 the names end 'by 5'.
decreaseLabelstringNoNoneFull name for the decrease button, replacing the one built from label and itemName. Set it to translate.
increaseLabelstringNoNoneFull name for the increase button. Set it to translate.
valueText(value: number) => stringNoNoneSpoken form of the value, set as aria-valuetext and used in announcements: (n) => `${n} tickets`.
namestringNoNoneForm field name, so the value submits with a form.
disabledbooleanNofalseTakes the whole control out of use and out of the tab order. Say why beside it.
pendingbooleanNofalseSet while your update is in flight: the value is muted and read-only, the button last pressed shows a spinner, both buttons stay focusable but do nothing, and pendingLabel is announced.
idstringNoNoneInput id. Defaults to one generated per instance.
classstringNoNoneExtra classes on the outer wrapper, for placement.

Customization#

On this page

Change the copy through props and retone the stepper through six --quantity-stepper-* CSS variables. The button fills, hover and press are mixed from them, so they follow any retone.

  • Accent: --quantity-stepper-accent colours the focus edge and its halo and tints the button hover and press fills. Keep it at 3:1 against the page.
  • Neutrals: --quantity-stepper-ink is the label, value and glyphs, --quantity-stepper-muted the bound message and the pending value, --quantity-stepper-hairline the outer edge (keep it at 3:1, since it marks the field) and --quantity-stepper-surface the fill.
  • Danger: --quantity-stepper-danger colours the edge and message while a typed value is past a bound.
  • Worked retone for a #09090b band: --quantity-stepper-accent: #fafafa; --quantity-stepper-ink: #fafafa; --quantity-stepper-muted: #a1a1aa; --quantity-stepper-hairline: rgb(255 255 255 / 0.35); --quantity-stepper-surface: #18181b; --quantity-stepper-danger: #f87171.
  • Cream page such as a bakery's #fbf7f0: leave the hairline alone (it is alpha black) and set --quantity-stepper-surface: #fffdf9.
  • Sizes: sm for cart rows and tables, md for product pages and booking forms. Hide the label only where the row names the item, and pass itemName so the buttons still say which.
  • Limits: pass the same min and max your server enforces, and a message that says why ('Limit 5 per order', 'A lane takes up to six swimmers').
  • Behaviour: bind value for local state and use onChange to save. Set pending while the save runs so a second press cannot race it.

Public CSS variables

VariableToken
--quantity-stepper-accentaccent
--quantity-stepper-inkink
--quantity-stepper-mutedmuted
--quantity-stepper-hairlinehairline
--quantity-stepper-surfacesurface
--quantity-stepper-dangerdanger

Accessibility#

On this page
  • The value is an <input role="spinbutton"> named by a native <label for>, with aria-valuenow, aria-valuemin, aria-valuemax and, when valueText is set, aria-valuetext. inputmode numeric brings up a number pad without type=number's wheel and letter quirks.
  • Keys follow the APG spinbutton: Up and Down arrows step, Page Up and Page Down move ten steps, Home and End jump to min and max. Enter commits typing and still submits the form.
  • Both buttons stay in the tab order. At a bound, or while pending, they take aria-disabled rather than disabled, so focus is never dropped, and the bound message is their accessible description.
  • Button presses happen away from the value, so a polite status announces the new value and any bound message after each press. Keys in the field announce only a bound message; the screen reader reads the value itself.
  • The line under the control is the field's description: the hint in range, the bound's reason at a bound, the range while a typed number is outside it. Leaving the field with an out-of-range number corrects it and announces the corrected value and the reason.
  • A typed value past a bound sets aria-invalid until it is committed and clamped. Colour is never the only signal: the bound message says what the limit is.
  • Targets are 44 px on touch screens at both sizes, and the field uses 16 px text there so iOS does not zoom.
  • Your responsibility: keep the label meaningful when hidden, pass itemName in lists of items, translate decreaseLabel and increaseLabel, and explain a disabled stepper in nearby text.

Release details#

On this page
Integration
  • Local interaction
  • Requires client-side JavaScript to be interactive
  • Server-side rendering supported
Dependencies
No additional runtime packages beyond Svelte and Tailwind CSS
License

MIT. Default license approval is pending; see the license status before adopting the source.

Version history
  • 1.0.0 (Published) Current release · 22 October 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.