VoiceOver on my iPhone read the ten service links at the top of my own Services page as ten loose links, with no list around them and no count. Chrome’s accessibility tree, the same tree my audit tool reads, said it was a perfectly healthy list. Both of them were telling the truth.
Twenty swipes and no count
On August 25 I was going through bekee.com on my iPhone with VoiceOver on, and I got to the “What I take on” index at the top of the Services page. It’s ten in-page links, one for each service, set in two columns with a number in front of each name. This is what VoiceOver said:
What I take on, heading level 2. Zero one. Site Triage, in-page link. Zero two. Accessibility audit, in-page link.
That readout has two problems in it. Each number was its own stop, so ten entries cost twenty swipes. And nothing ever said “one of ten,” so a person listening had no idea how long the list was or how far into it they’d gotten. The mobile menu on the same site says “two of six” as you move through it, so VoiceOver can do it.
The number was the easier one. It sat next to the link instead of inside it, so I moved it into the link and hid it with aria-hidden="true". On staging, VoiceOver gave me one stop per entry after that:
What I take on, heading level 2. Site Triage, in-page link. Accessibility audit, in-page link. Technical SEO audit, in-page link.
Still no count.
The theory that held up in Chrome
Each list item was a flex container, which is how the number and the name lined up on a shared baseline. The first theory was that display: flex on the li strips its list-item role. It was a reasonable guess, since CSS display values have removed table semantics in browsers before.
I tested it before changing anything. A script loaded six versions of a list in Chrome and read the accessibility tree for each one: a plain list, display: flex on the items, display: block on the items, list-style: none, display: flex on the list itself, and role="list" with flex items. Chrome kept the list role and every list item role in all six.
So the theory was wrong in Chrome. The catch is that Chrome wasn’t where the list went missing. Safari was, and Chrome’s accessibility tree can’t tell you what WebKit does with the same markup.
Fifteen lists on a test page
The only way to find out what WebKit does was to listen to it. I put a test page on staging with lists that differed from each other by one property at a time, and I swiped through every list with VoiceOver and read back what I heard. It took three rounds.
Round one had six lists of five links each: an ol in two columns with list-style: none (which is what the live page had), the same in one column, a ul in two columns, the first one plus role="list", an ol with its numbers visible, and a one-column ul shaped like the mobile menu.
None of the six announced a position. Four of them weren’t announced as lists at all. The one with role="list" said “Site Triage, link, list start” on the first entry and “list end” after the last. The one with visible numbers announced the list too, and read each number aloud as its own piece of text. And the list shaped like the mobile menu was silent, which meant the menu’s “two of six” was coming from something other than the list.
Round two went after that something. It had the menu-shaped list again as a control, then that list with a border on each item, then inside a nav element, then with both, and last, the real mobile menu’s markup and CSS copied over exactly. The border changed nothing. Both lists inside a nav came back as lists (“list start,” “navigation landmark,” and “list end”), with no count. The copied menu counted all the way through, “one of six” to “six of six.”
Round three put every list inside a nav, so the landmark was held constant, and changed only the links: plain links, the real Services index markup, links with display: flex on the anchor, and links built the way the menu builds them, with aria-labelledby, a nested label and subtitle, and a hidden number and arrow. Only the last one counted: “three of five,” “four of five,” “five of five.”
| What the list had | What VoiceOver said |
|---|---|
list-style: none, ol or ul, one column or two | no list, no count |
| the same, plus a border on each item | no list, no count |
the same, plus role="list" | list start and list end, no count |
| visible list numbers | list start, and each number read aloud as text |
the same unstyled list inside a nav | list start and list end, no count |
the mobile menu’s full link markup, inside a nav | the list, and “one of six” through “six of six” |
I put twelve of those lists on a test page, each with what I heard under it, so you can swipe through them on your own phone and see whether your iPhone agrees with mine.
WebKit does this on purpose
I didn’t know about this behavior going in, and it isn’t new. In March 2017 Izabella Cornwell filed WebKit bug 170179, “VoiceOver does not announce a list for groups of links when list-style: none,” and James Craig at Apple answered it:
This was a purposeful change due to rampant “list”-itis by web developers. In the same way WebKit differentiates layout tables from data tables, it now differentiates layout lists from data lists.
Plenty of sites build every row of links out of a ul, and a screen reader announcing each of those as a list is noise. WebKit decided that a list with its markers removed is probably layout, and stopped announcing it. Scott O’Hara wrote it up in “Fixing” Lists in 2019, including the exception for lists inside a nav, which is exactly what round two turned up.
So nothing on that test page was a discovery. What it gave me was confirmation on a current iPhone, one property at a time, and a fix I’d watched work instead of one I’d read about.
role=”list”, and not a nav
Both role="list" and a nav wrapper brought the list back, and I shipped role="list". It restores the list without adding a landmark to the page, so it changes less.
An HTML validator will tell you that role="list" on an ol is redundant, and in most browsers it is. In Safari it’s the whole fix. The page is built by a script, so I left a comment in that script saying why the role is there, because the next time I tidy that markup I’ll want to delete it.
I stopped before the count
Only the menu’s full link markup produced a count, and I don’t know which part of that markup does it. Getting “one of ten” on the Services index would mean putting a label, a subtitle, and a hidden arrow on ten links that don’t need any of them, to get one screen reader to say a number.
No WCAG success criterion requires a position count. It’s a VoiceOver convenience, and I decided not to chase it. What the index needed was one stop per entry, the right name on each one, and a real list around them, and it has all three now.
Where to listen
In the skip link post the tool was wrong: it read the stylesheet and passed a link that painted nothing. This time nothing was wrong. Chrome reports a list because in Chrome there is one. My audit tool runs axe-core in Chromium, so it gets Chromium’s answer too, and on this list Chromium’s answer isn’t what a person holding an iPhone hears.
If you take the markers off a list that people need to hear as a list, put role="list" on it, and then pick up a phone and listen to it (VoiceOver comes with every iPhone, under Settings, then Accessibility).