Exploring the visually-hidden css

You might also know it by it's name of .sr-only or .show-for-sr. For many years i've used the .visually-hidden mixin without thinking twice.

So here we are. Exploring the .visually-hidden helper class. For example, the Bootstrap 5 variant looks like this:

@mixin visually-hidden() {
  width: 1px !important;
  height: 1px !important;
  padding: 0 !important;
  margin: -1px !important; // Fix for https://github.com/twbs/bootstrap/issues/25686
  overflow: hidden !important;
  clip: rect(0, 0, 0, 0) !important;
  white-space: nowrap !important;
  border: 0 !important;

  // Fix for positioned table caption that could become anonymous cells
  &:not(caption) {
    position: absolute !important;
  }
}

Summary

This article exploded a little, so here's my tl;dr.

For use in production, stick to following snippet. See The anatomy of visually-hidden by James Edwards and the linked articles there for all of the reasons. The main ones are years of accessibility community test coverage on countless websites.

.visually-hidden:not(:focus):not(:active) {
    clip-path: inset(50%);
    height: 1px;
    overflow: hidden;
    position: absolute;
    white-space: nowrap;
    width: 1px;
}

My new favourite snippet, however, is this. It's only tested on a small set of elements in a lab environment, so to speak. Half the article is covering the lab session.

.vh--mvs:not(:focus):not(:active) {
    position: absolute;
    clip-path: inset(50%);
}

What's the goal here?

My initial question was centered around what each of the properties do and how they potentially interact with each other. The answer is given most excellently by James Edwards in "The anatomy of visually-hidden".

Further questions were:

  • Why the !important? Foundation e.g. has it optional though on by default
  • Differences in turning it off for on-focus (think skiplinks)? Bootstrap 5 has :not(focus), Foundation is resetting the properties
  • What's the margin -1px issue in Bootstrap?
  • What is still necessary in 2024? The always-referred-to-article by Jonathan Snook is from 2011 https://snook.ca/archives/html_and_css/hiding-content-for-accessibility.

I quickly fell down the rabbit hole of gathering and comparing the different rule sets. (See the list at the end of the article.) I also set up a test page where i applied all those styles and created my own test set.

What does the helper class do?

Both class names .sr-only and .visually-hidden give away the goal. Visually hide elements, but keep them available for screen reader users. In the case of applying the helper class to interactive elements, which stay in the tab order for keyboards, they need to become visible while having focus.

What are the typical use cases?

  • Skiplink (or jump link), to be shown on focus
  • Buttons that only display an icon and has the button label as a child element (my preferred method over aria-label in case the css doesn't load.)
  • Tables with visually empty header cells
  • H1, every page needs it, but often enough designers don't want them, e.g. when there shall be a fancy stage/hero element with only an image.
  • Read more links => not tested since i prefer not to use it. It's duplicating content in HTML. If required, we use the aria-label method at my company. If possible, avoid those links or design for unique link labels, see “Learn More” Links: You Can Do Better by Katei Sherwin.
  • Customize form controls (checkables: radio, checkbox): Works a bit different than the typical .vh class. First and foremost, we don't want to display the native control on focus. At my comany, we basically hide with position: absolute; and opacity: 0;, apply size same as the visual checkable, add z-index: 1; for ensuring clickability.
  • For more complex form control scenarios, where the fieldset legend is basically the visual label, such as start/end dates, credit card month/year combination, etc.

As you can see, the use cases list rather small elements. A label here, a table head there, at most an element the length of an H1.

Put more bluntly: Visually hiding stuff should not be used on larger elements anyways! If you feel like you want to do this, read this article for what you might actually need Inclusively Hidden.

The test session

Finding the minimum viable style (MVS)

There's basically two things we want to achieve:

  • Make the element visually disappear => e.g. using clip-path: inset(50%) or clip-path: rect(0 0 0 0).
  • Move the element out of the document flow so there's no unwanted white space left => using position: absolute;

Testing typical use cases

From the use cases i've mentioned above, i chose the following:

  • a paragrah <p>, because it has default browser styles (margin).
  • a non-semantic <div>
  • a heading <h1>
  • as an interactive element a <button> (make sure they appear on focus)
  • a button with an icon and a text label, where the text is visually hidden
  • a link, a typical skip link (make sure they appear on focus)
  • a table with a visually empty header cell

The first button with .visually-hidden applied also overlapped with the icon button. That way i could also test that always visible interactive elements are not restricted in functionality.

My test setup:

  • NVDA 2023.3 with Edge 127 and Firefox 128 on Windows 11
  • VoiceOver with Safari 17.4, Chrome 127 and Firefox 128 on macOS 14

The test results

This little snippet works quite well with all modern browsers - for my use cases, i might add.

It doesn't seem to matter if clip-path: rect() or clip-path: inset() are used. transform: scale(0); does make a difference for the screen reader highlight (see below).

.vh--mvs:not(:focus):not(:active) {
    position: absolute;
    /* transform: scale(0); */
    /* clip-path: rect(0 0 0 0); */
    clip-path: inset(50%);
}

There are differences on how large the screen reader-browser-combinations display the screen reader focus highlight, also called read cursor.

For the traditional helper classes

  • On macOS, Chrome shows the read cursor as a small rectangle. Safari or Firefox show roughly the size of the text length.
  • On Windows, Firefox and Edge show the read cursor as a small rectangle.

For the MVS helper class, using clip-path

  • On macOS, Firefox and Chrome show the read cursor as roughly the size of the text length. Safari on block-level elements highlights the viewport.
  • On Windows, Firefox and Edge show the read cursor as roughly the size of the text length.

For the MVS helper class, using scale()

  • On macOS, Firefox, Chrome and Safari show the read cursor for block-level elements the size of the viewport.
  • On Windows, Edge shows the read cursor for block-level elements as viewport size but shifted, Firefox shows a small rectangle.

I am missing the experience and moreover actual user feedback, however, i prefer the screen reader read cursor to stay roughly in the document flow. There are screen reader users who can see. I suppose, a jumping read cursor is a bit like a shifting layout. See also the text-wrapping section in the tpgi article.

I'm still not sure, if i'm overlooking something on the "size and overflow" side of things. Something, my lab session does not cover.

My result

This snippet is my favourite.

.vh--mvs:not(:focus):not(:active) {
    position: absolute;
    /* clip-path: rect(0 0 0 0); */
    clip-path: inset(50%);
}

Again, if used on interactive elements, it shall appear on focus.

Further considerations

Is this viable for libraries/css frameworks?

No. Requirements for library/framework maintainer are certainly less tough than for W3C specification bodies, however they do have to consider a lot more browsers, backwards compatibility, etc. Which brings me to my next question.

How well does this work with older browsers and/or browser-AT combinations?

No idea. Let's approach this with browser support:

  • clip-path: Edge was the last in adding support in 2020 when switching to blink.
  • :not: even IE9 supported simple not-selectors? Uff, that's an unexpected today-i-learned…

But older versions and maybe even single buggy versions of screen readers? I am totally at loss here.

Why the !important statements?

For example, Foundation has it optional, though on by default. I suppose, this is rather for library reason, you know, to reeeally make sure it works. To set the bar reeeally high for overrides.

If you use your own helper class in your own controlled project, the !important statement is not important.

Differences in turning it off for on-focus (think skiplinks)?

Bootstrap 5 already has the not-selector :not(focus) while Foundation is resetting the properties.

The always-referred-to-article by Jonathan Snook is from 2011 https://snook.ca/archives/html_and_css/hiding-content-for-accessibility. Edge didn't even exist back then. 2017, 2018 saw a little density of articles and now 2022, 2023. I guess, if something never causes a problem, why care.

What's the margin -1pxthing?

Bootstrap mentions the use of margin: -1px !important; as a "Fix for https://github.com/twbs/bootstrap/issues/25686", the issue being from 2018.

I haven't understood the problem. Maybe it doesn't exist anymore.

A few Gotchas and TILs

The clip property is deprecated. Use clip-path instead.

Either clip-path: inset(50%); or clip-path: rect(0 0 0 0); will not interfere with visible interactive elements. This means that visible interactive elements are interactive even though they might be in the same layout position as the hidden element. Other combinations do interfere, e.g. the combo .vh {position:absolute; opacity: 0;}.

More a today-i-learned (TIL). Absolute positioning without any position properties (top, left, inset, …) positions the hidden element a little below the succeding element, in case of table cells a little below-to-the-right. Not using the position properties was already the case with the traditional helper class. Default value is inset: auto;

Result

I'd be rather cautious to use my small version in production. 2020 was four years ago. Which is not that long ago if you're conservative with your browser support. At the time of writing, Safari 15.6-15.8 has a share of 0.83% globally (think old phones not receiving updates) or Chrome 109 with 1.5% (from browserlist data).

Ressources

These two articles are in my opinion the best entry points.

For the historians among us:

List of util classes and mixins for my test session:

NOte: This is the end of the article, no further text coming up afterwards.

All helper classes / mixins gathered

/* distilled properties from all the style blocks */
.visuallyhidden {
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    position: absolute;
    padding: 0; // mscr, BS, FD
    margin: -1px; // BS, mscr, BS has issue linked
    white-space: nowrap; // FD, BS, OH
    border: 0; // FD, BS
    outline: 0; // mscr
    clip-path: inset(50%); // in OH
}

/* our mixin */
@mixin visuallyhidden {
    position: absolute !important;
    width: 1px !important;
    height: 1px !important;
    overflow: hidden;
    clip: rect(0 0 0 0) !important;
    margin: -1px !important; // not foundation
    padding: 0;
    outline: 0; // not foundation, has border instead
}

/* O'Hara css, 2017 */
.visually-hidden:not(:focus):not(:active) {
  clip: rect(0 0 0 0); 
  clip-path: inset(50%);
  height: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap; 
  width: 1px;
}

/* By Joe Watkin & Zell Liew, from 2019, adddressing the VO bug */
.visuallyhidden {
  border: 0;
  clip: rect(0 0 0 0);
  height: auto; /* new - was 1px */
  margin: 0; /* new - was -1px */
  overflow: hidden;
  padding: 0;
  position: absolute;
  width: 1px;
  white-space: nowrap; /* 1 */
}

/* bootstrap mixin */
@mixin visually-hidden() {
  width: 1px !important;
  height: 1px !important;
  padding: 0 !important;
  margin: -1px !important; // Fix for https://github.com/twbs/bootstrap/issues/25686, from 2018
  overflow: hidden !important;
  clip: rect(0, 0, 0, 0) !important;
  white-space: nowrap !important;
  border: 0 !important;

  // Fix for positioned table caption that could become anonymous cells
  &:not(caption) {
    position: absolute !important;
  }
}

/* Foundation mixin */
@mixin element-invisible($enforce: true) {
  $important: if($enforce, '!important', null);

  position: absolute #{$important};
  width: 1px #{$important};
  height: 1px #{$important};
  padding: 0 #{$important};
  overflow: hidden #{$important};
  clip: rect(0, 0, 0, 0) #{$important};
  white-space: nowrap #{$important};
  border: 0 #{$important};
}