Hey! This is Mica writing, a human not an AI agent (nothing against them, not discrimination of any kind is allowed here). Just a brief disclaimer before starting: this post was non AI-generated (I am not going to lie, I tried to use it for some research but since the errors I found were not properly documented the AI invented answers, lol) because I felt the urgent need to start using my brain and hands to craft something from scratch. If you are a human, let's connect, at the end you will find the ways to reach me. I am on a doom scrolling detox so you won't find me on instagram or twitter now. So, I have a question: Is there anybody out there?
Introduction
If you work on an e-commerce, a booking (hotels, flights, trains, etc) platform, a restaurant reservation system maybe you are familiar with the quantity spinbutton component. It is a common UI pattern that allows users to select a quantity of items they want to purchase or book. It is configured by two buttons and one input; one button for increasing the quantity, another button to decrease the quantity and an input where the user is able to type the desired amount or increase/decrease the quantity using the Up Arrow or Down Arrow accordingly. Sounds simple, and yes it is, but it is also a critical flow in the user journey. If it's badly implemented, you can not only be potentially losing sales (translation: losing money, which is important) but also creating a frustrating experience for users with disabilities (which should be more important than money, but those are my morals and ethics, lol). My goal in this article is setting a clear basis of a non-negotiable structure and behavior of the quantity spinbutton pattern and from there, explore different variations depending on the use case.
Disclaimer: This is not a written-in-stone guide, I can make mistakes, so please let me know if you disagree with something.
Expected behaviors
Keyboard
| Key | Action |
|---|---|
| Up Arrow | Increases the value by its step value |
| Down Arrow | Decreases the value by its step value |
| Home | If the spinbutton has a minimum value, sets the value to its minimum |
| End | If the spinbutton has a maximum value, sets the value to its maximum |
| Command or Fn + Right Arrow | Substitute for Apple's keyboards that do not have a End key to set the value to its maximum |
| Command or Fn + Left Arrow | Substitute for Apple's keyboards that do not have a End key to set the value to its minimum |
Screen Readers
NVDA Navigation
You can download NVDA here and it's only available for the Windows operating system. It is compatible with Chrome, Firefox and Edge browsers.
Long story short, an NVDA modifier key is needed to navigate using this screen reader. The modifier key can be Insert key (by default) or it can be remapped to the Caps Lock in the settings. Two modes for different purposes can be found:
- Browse Mode: Browse mode is used when reading documents or web pages.
- Focus Mode: Focus mode is used when the user enters a form or other fields that require user input.
NVDA automatically switches between Browse and Focus modes, but the user can toggle them using Insert + Space Bar.
| Command | Task |
|--------|-----|
| Down Arrow (Browse Mode) | Read next item |
| Enter or Space Bar | Activate button |
| Down Arrow (Focus Mode) | Decrement by step |
| Up Arrow (Focus Mode) | Increment by step |
| Home | Jump to aria-valuemin (via your custom handler) |
| End | Jump to aria-valuemax (via your custom handler) |
| Tab | Commit value, leave the field, return to browse mode |
VoiceOver Navigation
If you are a macOS user, VoiceOver comes built into the operating system of your MacBook. It's compatible with the Safari browser.
VoiceOver uses the Control and Option keys before each command. This combination is called VO and these keys can be locked/unlocked by pressing Control + Option + ;(semicolon) all together.
| Command | Task |
|---|---|
| Tab/Shift + Tab | Go to next/previous focusable item (link, button, input, etc.) |
| VO + Space Bar | Activate a link or form control |
| VO + Right Arrow | Move VO cursor to next element (not limited to focusable items) |
| VO + Left Arrow | Move VO cursor to previous element |
| VO + Shift + Down Arrow | Once the VO cursor is on the spinbutton, this combination enters the spinbutton |
| Up Arrow | Once inside the spinbutton, increment by step |
| Down Arrow | Once inside the spinbutton, decrement by step |
| Home | Jump to aria-valuemin (via your custom handler) |
| End | Jump to aria-valuemax (via your custom handler) |
Gestures VoiceOver - iOS
If you are an iOS user, VoiceOver is the screen reader by default on the operating system of your iPhone. It's compatible with the Safari browser. Unlike VoiceOver on macOS, VoiceOver on iOS, the navigation is controlled by finger gestures.
| Gesture | Task |
|---|---|
| Swipe next | Read next item |
| Swipe left | Read previous item |
| Double-tap | Activate (link, button) |
| Swipe up | If the VO cursor is over the spinbutton, increase the value |
| Swipe down | If the VO cursor is over the spinbutton, decrease the value |
Gestures Talkback - Android
If you are an Android user, TalkBack is the default screen reader on the operating system of your mobile. It's compatible with Chrome, Firefox and Edge. Like VoiceOver for iOS, the navigation is controlled by gestures with the fingers.
| Gesture | Task |
|---|---|
| Swipe next | Read next item |
| Swipe left | Read previous item |
| Double-tap | Activate (link, button) |
| Swipe up | If the VO cursor is over the spinbutton, increase the value |
| Swipe down | If the VO cursor is over the spinbutton, decrease the value |
Pattern A: APG (ARIA Authoring Practices Guide) Spinbutton by W3C
Currently everyone is following the APG (ARIA Authoring Practices Guide) as if it were the bible and not as what it is: a guide, a very useful guide, btw, THE guide. Everything written there is considered a commandment written in stone by non-accessibility experts (the people behind W3C are doing an amazing work and I only have gratitude and admiration towards them) and this might be a problem. The intention is good but the execution is not. When you first land on the APG Spin Button Pattern, the first thing you see is an alert with the following warning:
The code in this example is not intended for production environments. Before using it for any purpose, read this to understand why.
and then a list of reasons why you should be critical of the pattern provided. In this case, the most important one is:
Robust accessibility can be further optimized by choosing implementation patterns that maximize use of semantic HTML and heeding the warning that No ARIA is better than Bad ARIA.
So, what does this mean? Well, a couple of important things:
- Always be critical of everything (a rule for life, tho)
- Keep in mind the necessities and limitations of your product/business, and (more importantly) comply with the regulations and legislation in order to guarantee freedom of navigation through your product for users with disabilities.
- There is always (well, almost 80% always) an HTML equivalent.
After this brief introduction to the APG, I would like to dive into its Spinbutton pattern. W3C (The World Wide Web Consortium) presents us a solution using the WAI-ARIA role="spinbutton" on the input tag instead of using the native HTML solutions provided by the tag itself, the buttons are removed from the focus flow because they have a tabindex="-1" but they are still operable via mouse and by using screen readers. Only the tag with the role="spinbutton" is not fully semantic by itself and we will need to add the keyboard and screen reader functionalities programmatically and make it semantic by adding properties and states to the input element.
In the accessibility field we have a mantra: No ARIA is better than Bad ARIA. You should be extra careful when using ARIA because, if it is not used correctly, you will end up creating more issues than solving them. Again, the intention is good but the outcome is more barriers. Last month, WebAIM org launched its annual report for 2026 about the state of accessibility in over 1 million pages: The WebAIM Million. The report shows that in 2026, accessibility errors increased by 10.1% according to the WCAG 2.2 Level A/AA conformance failures and concluded with the following reflection:
The 2026 WebAIM Million analysis found notable increases in both the number of detected accessibility errors and number of pages with WCAG conformance failures, reversing a trend of gradual accessibility improvements in recent years. A primary concern is the significant increase in home page complexity and ARIA code, both of which correlate to increased detectable errors. These trends likely reflect broader shifts in web development including increased reliance on 3rd party frameworks and libraries and automated or AI-assisted coding practices (“vibe coding”). Home pages are getting larger and more technologically complex at an alarming rate, making accessibility more difficult to achieve and maintain. A key takeaway from this year's report is improving accessibility at scale will require both better practices and simpler systems. Alternatively, complex systems need to do a better job of focusing on accessibility fundamentals.
It's not a surprise that creating components using AI without questioning the output will end up creating more barriers for users with disabilities, and sloppier websites overall. Also, the lack of native and accessible examples on the web makes the AI coding agents return inaccessible components, but okay not everything is the fault of the AI, to be honest.
Functional Example – APG
you can find the functional example here
APG code
<input
id="add-to-bag"
role="spinbutton"
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
spellCheck="false"
aria-valuemin={MIN}
aria-valuemax={MAX}
aria-valuenow={isValid ? numeric : undefined}
aria-valuetext={value}
aria-invalid={!isValid || undefined}
aria-describedby={isValid ? 'help-add-to-bag' : 'help-add-to-bag error-add-to-bag'}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
-
id: to relate the<input />with the<label>element -
role="spinbutton": this is a WAI-ARIA role that allows the user to increase and decrease a value within a given range. This role can be used on<input>or on a non-semantic element such as<div>. The APG pattern for thespinbuttoncomponent proposes the<input>with atype="text"making the development more challenging. The expected keyboard behavior for aspinbuttonis being able to increment or decrement the value by pressing the up/down arrow accordingly. Since this is not a native interaction of therole="spinbutton"or of the<input type="text" />, we have to programmatically cover these behaviors. Thespinbuttonelement with type text generally comes with two buttons (one for decrement and the other to increment the value) that should be excluded from the navigation flow by addingtabIndex={-1}. -
type="text": implies that thespinbuttonwill receive a value of type text, which is a pain because it means that we have to transform the type text to type number later. -
inputMode="numeric": since the input is type text, we have to make the browsers know which virtual keyboard they will have to display. This is vital for mobile navigation because without the value numeric the virtual keyboard display to enter a value will be a QWERTY instead of a numeric input keyboard. -
pattern="[0-9]*": again, since the input istype="text", we have to use a regex to only admit values of type number. (This attribute was not on the APG example, but I cannot help myself). -
autocomplete="off": it is used on inputs that take a text or numeric value as input to prevent the browser from automatically entering a value into this field. (again, my take, not on APG example). -
aria-valuemin="[NUMBER]": sets the minimum value allowed for the spinbutton. The default is 0, if no value is passed. -
aria-valuemax="[NUMBER]": sets the maximum value allowed for the spinbutton. The default is 100, if no value is passed. -
aria-valuenow="[number]": here things start to get interesting. By definition, this attribute defines the current value for a range widget (meter, scrollbar, slider and spinbutton). Since we are not using a semantic solution, this value should be updated programmatically. Also, this value is intended for numeric values and APG provides an example where thespinbuttonis being considered as a text, so we have to also transform the value from type string to type number. This is not everything! Since thespinbuttonis not correctly typed, the announcement of the current value will be done as a percent by VoiceOver (macOS and iOS) announces it as a percent, TalkBack on Pixel 10 does not announce it at all, and NVDA announces it correctly. You can test it on the APG example. -
aria-invalid="[boolean]": indicates that the current value is invalid. -
aria-describedby="[IDREF]": this attribute is used to establish a relationship between widgets or groups and the text that describes them.
Conclusion for the APG Spinbutton Pattern
To articulate a conclusion for this pattern, I drew on Core Accessibility API Mappings 1.2 which is a guide that specifies how WAI-ARIA roles, states, and properties are expected to be exposed by user agents (browsers, in this case) via platform accessibility APIs.
Since a spinbutton is considered a range widget, it's implicitly expected to receive a value of type number even though the APG pattern tells us otherwise. By implementing a role spinbutton in a text input, we will be going against the nature of the role, which leads us to do extra work to make it work correctly.
The pattern is good but we can make it better, with some browser limitations (the party can never be enjoyed in peace), with the native HTML solution as we will see below.
Pattern B: Native HTML solution
Unfortunately, nothing is perfect; the native HTML solution for spinbutton is tempting because it works well in most cases BUT has a few limitations that aren't HTML-related but browser-related, buuuh.
Browsers have different engines to create what we colloquially know as Accessibility Tree. This tree is just an object with nodes. Every node includes information about the HTML elements and attributes. Finally, this object is being exposed to the Accessibility APIs of every operating system so they can translate the information and make it readable to assistive technologies. Here is where the things get complicated because every browser engine sometimes interprets things differently. Below, you can find a list of the browser engines for the different browsers and the Accessibility APIs they support.
- Blink: this is the browser engine for Chrome, Edge, Opera and Brave (all browsers based on Chromium) which means all the browsers are going to have the same Accessibility Tree as the one we can see on the following image:

Supported on the following Accessibility APIs:
- Windows: IAccessible, IAccessible2 and UIAutomation
- Mac: NSAccessibility (funny thing here is that Blink creates a more accurate and complete node than WebKit and VoiceOver announces the spinbutton better on Chrome, as we are going to read later)
- Linux: ATK
-
Android: AccessibilityNodeInfo and AccessibilityNodeProvider. I opened a ticket for this operating system because it is not exposing the
spinbuttoncorrectly preventing the user from having an accessible experience. The ticket to Android can be seen here- Gecko: this is the browser engine for Firefox. The Accessibility Tree it creates, in my opinion, is the most complete and it is compatible with the following Accessibility APIs:
- Windows: IAccessible, IAccessible2 and UIAutomation
- Mac: mozAccessible
-
Linux: ATK
-
WebKit: this is the browser engine for Safari in macOS and iOS. Here is where our problems start. The engine does not recognize the element as a
spinbuttonbut as arole="textbox"if we don't explicitly pass therole="spinbutton"to the<input type="text" />. This is an issue because the VoiceOver screen reader in both devices, desktop and mobile, will prevent the user from having an accessible experience, the same issue that I faced on Android.
Again, I ended up opening a ticket to Apple about this behavior. The ticket to Apple can be seen here
-
WebKit: this is the browser engine for Safari in macOS and iOS. Here is where our problems start. The engine does not recognize the element as a
Functional Example – HTML
The native functional example can be found here
HTML native code
<input
id="add-to-bag-native"
type="number"
role="spinbutton"
autoComplete="off"
min={MIN}
max={MAX}
step={STEP}
value={value}
/>
-
id: same thing as in the APG pattern -
type="number": this is what natively converts an input into aspinbuttonand restricts it to numeric values. It natively supports supports increasing and decreasing the value by: pressing the native controls that come with the inputtype="number"(see/read/perceive the image below), pressing Up Arrow or Down Arrow, or swiping (on mobile with the screen readers on).

The appearance of these arrows inside the input comes by default and we can override it if we provide another mechanism to substitute the buttons themselves; we already did that with the custom buttons to increase and decrease the value, so we are good to go.
input[type="number"] {
appearance: textfield;
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
-
appearance: textfield;, we need it to strip the native spinbutton styling in Firefox -
::-webkit-outer-spin-buttonand::-webkit-inner-spin-buttonare pseudo-selectors that only Blink and WebKit recognize and the-webkit-appearance: noneproperty strips the native spinbutton styling in Chrome and Safari.-
role="spinbutton": well, having in mind all the compatibility issues we have in different browsers, I found out that if you only pass the WAI-ARIArole="spinbutton"magically they all disappear. So, there is no harm in adding it to prevent headaches in the future. This post was built for this moment, you can thank me. -
autoComplete="off": just the same as in the APG example -
min={NUMBER}: the minimum value to accept for this input and should be less than or equal to the maximum value. If a value that isn't a valid number is specified, then the input has no minimum. -
max={NUMBER}: the maximum value to accept for this input and should be greater than or equal to the minimum value. -
step={NUMBER}: defines the allowed interval between valid values. The default is 1 and only accepts integers. -
value={NUMBER}: a number representing the value of the number entered into the input
-
Conclusion for the HTML native solution
If you want to have a functional, accessible and widely supported across browsers spinbutton component, I recommend using my solution and to always go to the native solutions and, if you do not feel comfortable enough, talk to your trustworthy accessibility expert. This native example with the workaround of adding a WAI-ARIA role="spinbutton" to force its semantic value on some browser engines was tested on:
- Chrome (version 148) + macOS (Tahoe version 26.4.1) + VoiceOver
- Chrome (version 148) + iOS (version 26.4.1)/ iPhone pro 15 + VoiceOver
- Chrome (version 148) + Windows 11 + NVDA
- Chrome (version 148) + Android/Pixel 10 (version 16) + TalkBack
- Firefox (version 150) + Windows 11 + NVDA
- Firefox (version 150) + macOS (Tahoe version 26.4.1)
- Safari (version 26.4) + macOS (Tahoe version 26.4.1) + VoiceOver
- Safari (version 26.4) + iOS (version 26.4.1) + VoiceOver
What I did not cover but it's important to mention: the Home and End keyboard shortcuts mentioned on the Keyboard section are not provided natively so you have to cover them programmatically. Also, some keyboards, such as Apple's keyboards (always Apple) do not have keys for Home and End and they are substituted by the combination Command or or Fn + Right/Left Arrows so you have to cover this combination as well. It's just a function of a 9-line function, nothing too exceptional, you should contemplate it.
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
if (ev.key === 'End' || (ev.metaKey && ev.key === 'ArrowRight')) {
ev.preventDefault()
setValue(MAX)
} else if (ev.key === 'Home' || (ev.metaKey && ev.key === 'ArrowLeft')) {
ev.preventDefault()
setValue(MIN)
}
}
Final Thoughts
Having a semantic, functional and accessible spinbutton on your e-commerce will not only contribute to improving the overall internet experience to everyone but it will leverage your product and increase your sales. A win-win for all parties involved; you get the money, the user, no matter how he/she/they are navigating, gets the product.
According to WHO more than 2.5 billion people need one or more assistive products to to meet their daily needs, and one of those needs is the right to navigate freely through the web. You do not really know who is navigating through your website behind the screen, could be a human with different needs or now an AI AGENT (!!!!). Omg, a new party entered the game and should be contemplated too. Nowadays, some users are relying on autonomous purchase bots, voice shopping assistants or AI browsing agents. These AI assistants base their navigation on the web in the same way as any other assistive technology: they consume the data provided by the Accessibility Tree. Amazing.
Well, we learned that: a new player entered the game (AI assistants), the native HTML solution is always the best option (might be buggy but it's the browser's fault) and you should always be critical and test everything you are building.
You can find me on:
- email: micaela.avigliano@gmail.com
- linkedin: https://www.linkedin.com/in/micaelaavigliano/
Top comments (1)
Great deep dive. The WebAIM 2026 stat about accessibility errors increasing by 10.1% is alarming but not surprising.
I've seen so many component libraries ship spinbuttons with just role "spinbutton" on a text input and call it a day.
Your point about WebKit not recognizing type "number" as a spinbutton without the explicit role is something I ran into last month and spent way too long debugging. The native HTML + role="spinbutton" fallback approach is the pragmatic call. Also +1 for testing across that many browser/screen reader combos, most people stop at Chrome + NVDA and call it done.
👍 👍 👍