tl;dr Using .visually-hidden on table headings breaks built-in association between column headings and cell content when using VoiceOver with Firefox or Safari, while .visually-hidden crashes Opera if used on <thead>! The workaround is to wrap the content you wish to hide in a <span class="visually-hidden">.

A few weeks ago I was working on an opening hours table for a restaurant. The visual design didn't include table headings so I decided to add visually hidden headings to make the table accessible. I used slightly expanded .visually-hidden class from Heydon Pickering's Inclusive Components to achieve this. You can find the .visually-hidden class I use at the end of the article.

Initially I put the .visually-hidden class on <thead> but VoiceOver on Firefox didn't register column headings, while traversing the table on Opera caused it to crash! More on Opera crash later. So then I tried moving .visually-hidden class downstream to <tr> but that broke the built-in association between column heading and column cells in Firefox while VoiceOver on Safari wouldn't even announce column headings. I continued moving .visually-hidden downstream to <th> but that also broke association between column headings and cell content in both Firefox and Safari.

Upon investigating .visually-hidden class I found the culprit: position: absolute;. I tried replacing it with float: left; but it seems that any technique that takes table elements out of normal flow breaks their accessibility. The cross-browser solution I came up with is to wrap content you wish to hide with a <span class="visually-hidden">.

Note: using .visually-hidden on Chrome and Edgium with VoiceOver produced no issue on any table-related element.

Opera crashing was surprising, even more so considering all it took was some CSS. The exact four CSS properties that cause the crash are:

width: 1px;
height: 1px;
overflow: hidden;
position: absolute; /* or float: left; */

I set up an example page that crashes Opera using this approach, which I submitted alongside a bug report to Opera. Unfortunately they don't have a public bug tracking system 🤷‍♂️ but at least they shared the bug report ID with me - DNAWIZ-89553 - which we can use to take a look in the changelogs to see if it was fixed already.

Update: the bug seems fixed 🎉 in the very next build even though the changelog doesn't mention the bug.

.visually-hidden:not(:focus):not(:active) {
    width: 1px !important;
    height: 1px !important;
    padding: 0 !important;
    border: 0 !important;
    overflow: hidden !important;
    position: absolute !important;
    white-space: nowrap !important;
    clip: rect(1px 1px 1px 1px) !important; /* IE6, IE7 */
    clip: rect(1px, 1px, 1px, 1px) !important;
    clip-path: inset(50%) !important;