DEV Community

Vesa Piittinen
Vesa Piittinen

Posted on • Updated on • Originally published at vesa.piittinen.name

Can we at least modernize visually hidden?

Recently Ben Myers wrote about the need for native visually hidden. On this I agree: we would really love to have display: visually-hidden; because visually hidden is a very useful utility.

While the standardization has not really happened, and personally I lack the influence over people to be the change, I can be my usual self and question the status quo of copy pasting existing solutions and instead see if the utility can be improved.

How it works?

The shortest way to describe the core of visually hidden utility is that it hacks around the rules of screen readers. So what the styles really do is to convince a screen reader that this text is visible on the screen, please read it!

So we lie. We really desire the text not to be visible on the screen! This is useful because we want to provide information to screen readers about what is visually there. And this mechanism is better and more reliable than aria-label which has issues like not being translated by machine translators. And it should only be used on interactive elements, things that you can click. We rather would like to have a solution that works for anything.

What we have now?

Here is the variation of visually hidden as posted by Ben Myers:

.visually-hidden {
    border: 0;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    height: 1px;
    margin: -1px;
    overflow: hidden;
    padding: 0;
    position: absolute;
    white-space: nowrap;
    width: 1px;
}
Enter fullscreen mode Exit fullscreen mode

This is just one of many variations you can find in the wild. But why does it have these particular rules it has now? Well, I guess I could answer that question. Let's go through all the rules!

position: absolute;

This is the easiest one to explain: at any cost we do not want to have any change to the visual layout. And this is exactly what we can achieve with position: absolute;.

height: 1px; and width: 1px;

Screen reader heuristics require that the element must be visible. So by having the element at the minimum size of one pixel we pass the heuristics test for "yeah, the element can be seen".

So very clever workaround by us.

white-space: nowrap;

This is required to guarantee that a screen reader reads all the text and depicts it as we desire. If this rule didn't exist the text would be wrapped based on the 1px container size and this can lead to very interesting results on how a screen reader would read the text.

Also other rules like word-break: break-all; can mess up things even further causing screen reader to listing each character one by one. That's not really... desirable!

border, margin and padding

An issue with these properties is that they will enlarge the element. And we really want to avoid any side-effects!

If these rules were to be removed you could end up with unintended scrolling here and there. It would depend on whether the element had these values.

However margin has been set negative. And there is a reason this has been done! It is hard to figure out what is the origin of each of these, but it seems that not having negative margin has caused overflow in browsers of the past.

However looking at the samples with modern browsers I was unable to see the overflow issue. So it might now be entirely possible that setting negative margin is not required anymore. And it happens to actually be harmful: negative margin can change the reading order in VoiceOver!

So here is the first thing that we could actually improve upon: don't use negative margin, make it zero instead.

clip-path: inset(50%);

The purpose of this rule is to clip away all the visuals. Screen readers ignore this rule because it is quite hard to figure out how much will be visible based on the values of this property.

While this is fairly short could we improve upon this? Well yes, yes we could! clip-path: path(''); is shorter by two characters and gives the same end result!

Oh and since path repeats it will also compress better when delivered over the network. Much awesome.

clip: rect(0 0 0 0); and overflow: hidden;

Why do we still support Internet Explorer? clip is a CSS property that wasn't a standard but became a de-facto standard since all browsers implemented this IE only feature.

Today we really don't need these rules: clip-path does both of these.


Improving upon it

So while going through each of the styles we did find a few changes to be done. This is where we are at the moment:

.visually-hidden {
    border: 0;
    clip-path: path('');
    contain: content;
    height: 1px;
    margin: 0;
    padding: 0;
    position: absolute;
    white-space: nowrap;
    width: 1px;
}
Enter fullscreen mode Exit fullscreen mode
  1. Removed clip
  2. Shorter clip-path
  3. Margin to 0
  4. Removed overflow to favor contain

However now that we removed clip is there a possibility that there would be a browser that does not yet support clip-path?

As unfortunate as it is, yes, there is a browser that does not support clip-path: Samsung Internet 20 still requires -webkit prefix! This means that we have to repeat the rule.

Another annoying thing is the need to have border: 0; margin: 0; padding: 0;. These are basically default values on CSS. And this reminded me of initial values in CSS: they happen to be zero. Which then reminded me that hey, we actually have a property now to make everything be in their initial values!

.visually-hidden {
    all: initial;
    clip-path: path('');
    contain: content;
    height: 1px;
    position: absolute;
    white-space: nowrap;
    width: 1px;
}
Enter fullscreen mode Exit fullscreen mode

Wonderful! We are down to only 7 style properties! Also as a benefit all: initial; is likely to fix some edge cases and side-effects caused by the cascade.

However! We are still not protected against styles that are defined later on. For example what if there was another style rule targetting the same element which said padding: 100rem;? That would mess us up as it would cause unexpectedly large element and breaking layout.

So we need overrides and there are some ways that we can go about it:

  1. Use !important
  2. Increase specificity

Personally I really dislike !important regardless of any context. So specificity it is.


The final modernization?

The shortest way I know of to add more specificity is to use :root:

:root .visually-hidden {
    all: initial;
    clip-path: path('');
    contain: content;
    height: 1px;
    position: absolute;
    white-space: nowrap;
    width: 1px;
}
Enter fullscreen mode Exit fullscreen mode

This is enough to make all: initial win over any properties set by regular classes, attribute selectors, or element selectors. You could still add !important if you are afraid that there is still somebody who uses it. I'm such an optimist that I believe we've finally grown over that phase and nobody uses it, ever.

Skip links

The sample Ben Myers posted actually had more into the selector: it was .visually-hidden:not(:focus):not(:active). This is clever in that if the element is focusable then you can navigate to the element using keyboard and boom! it becomes visible. However I think this might be an undesired feature for a generic utility class.

So my own opinion on this is to steal an idea from sanitize.css and use attributes instead:

:where([aria-hidden='false' i][hidden]) {
    display: initial;
}

:is(
    [aria-hidden='false' i][hidden]:not(:focus):not(:active),
    .visually-hidden
) {
    all: initial;
    clip-path: path('');
    contain: content;
    height: 1px;
    position: absolute;
    white-space: nowrap;
    width: 1px;
}
Enter fullscreen mode Exit fullscreen mode

And now a skip link can be done like this:

<a class="skip-link" hidden aria-hidden="false" href="#main">
    Skip to content
</a>
Enter fullscreen mode Exit fullscreen mode

Here you can put the desired visual styles to .skip-link.

Oh, and you maybe also noticed that I used :is() instead of having :root. Why? Well, those nice attribute selectors happen to give a specificity of 0, 4, 0 which is bigger than 0, 2, 0 with :root .visually-hidden so we use the :is() to increase specificity of .visually-hidden to the same level as the attributes. This is a nice additional benefit that we get "for free" with no awkward looking code by introducing hidden aria-hidden="false" utility.

Is that all?

No. While I have done some testing to guarantee my own confidence that this new set of rules do work as intended there is something that remains: this has not been battle tested by the larger community! And as a single person there is no way I would have the resources to test everything comprehensively like companies can.

There is also the fact that what I have here is an opinioned improvement. For example not everyone likes the idea of hidden aria-hidden="false". Some people really prefer that the issue should be handled by CSS only. Which gets us back into the main issue: display: visually-hidden; has not been standardized and it really should be.

I hope you like this autistic deep dive!


Updates!

[2023-03-11] After writing this it came to my attention that Safari apparently should not support clip-path: path('');. And it is possible that while testing on my iPhone I simply didn't notice that it doesn't actually work. This still needs to be checked again.

[2023-03-12] Bootstrap had issues with clip-path and dropped it in favor of clip. The problem was related to severe performance issues on Chrome regardless of device: Android, MacOS and Windows were all confirmed. However this was in 2018 and I did a re-run of the tests on Windows and Android (Sony Xperia 10 / 2019, latest updates) and could not see the issue anymore. So there should be no reason to use the deprecated clip!

[2023-03-12] Similar article by James Edwards: The anatomy of visually-hidden.

[2023-03-13] Opposite opinion on requiring native visually hidden by WebAxe: We Don’t Need .visually-hidden

[2023-03-13] Samsung Internet does support clip-path without prefix! I had been looking at the wrong support table, here is the correct clip-path: path one. However the support table is incomplete and claims Opera does not support, but it does support. So it might be possible to have all of it working nicely on all current browsers without the prefix!

[2023-04-17] contain: content is required (or overflow: hidden) as scroll containers calculate layout based on the text within.

Top comments (3)

Collapse
 
alohci profile image
Nicholas Stimpson • Edited

Nice article. One point though, clip() was standardized. See clip() in CSS 2.2 Nor is it new there. It was present in the original 2.0 specification too, and has been ever since.

Collapse
 
merri profile image
Vesa Piittinen • Edited

Ah, great you point it out! I remembered it as IE property, and it took me a while to remember why: the syntax difference.

/* IE */
clip: rect(0 0 0 0);
/* Standard */
clip: rect(0, 0, 0, 0);
Enter fullscreen mode Exit fullscreen mode

I find it confusing that many of the visually hidden / sr-only utilities have clip-path in them. Or at least to me it would logically make more sense to either use clip + overflow or clip-path, not both at the same time.

In the other hand clip is deprecated so we should use clip-path, but if it is true that it still has perf issues on Android and clip does not then I'd prefer using clip anyway.

Update! Tested, clip-path seems to work fine with no perf hickup on Chrome Android nor Edge Windows.

Collapse
 
merri profile image
Vesa Piittinen • Edited

Adding some further thoughts.

If there is a case where the 1px of size does cause an unintended side-effect to layout it might be possible to negate this with margin: 0 -1px -1px 0;. However until such case is found I see no reason to add that rule.

Additionally the use of hidden aria-hidden="false" is troublesome in the sense that false value has not been specced in ARIA, and currently does not do anything. From a programmer perspective this is odd of course as you would expect a boolean to have both values to be working, but only true is valid and part of spec. And there is the whole "don't use ARIA unless you really need to" perspective.

Skip link might be the only valid use case for :not(:focus):not(:active) so in that sense it might be "overkill" to go the attribute route in the first place. So one alternate viewpoint would be to do this instead:

:is(
    a[href].visually-hidden:not(:focus):not(:active),
    .visually-hidden
) {
    all: initial;
    -webkit-clip-path: path('');
    clip-path: path('');
    height: 1px;
    position: absolute;
    white-space: nowrap;
    width: 1px;
}
Enter fullscreen mode Exit fullscreen mode

And then just have two classes:

<a class="skip-link visually-hidden" href="#main">
    Skip to content
</a>
Enter fullscreen mode Exit fullscreen mode

However I have tested the hidden aria-hidden="false" and it does work with the CSS provided on NVDA, Talkback and VoiceOver. So things ultimately boil down to what is the preferred syntax.