How to Make an Interactive Element Invisible but Accessible
How to Make an Interactive Element Invisible but Accessible êŽë š
Letâs say we want to hide some interactive elements until interaction occurs. For example, we want some cards to have share buttons, but we donât want them to clutter the UI. Other use-cases include skip links, which we can reveal at key moments in the tab order, anchor links (i.e., links to a specific part of a page), which we can reveal when weâre within said part, copy-to-clipboard buttons, which we can reveal when weâre somehow engaged with the content that we want to copy, scroll-to-top buttons, actionbar items of lesser importance â you know, things that might be okay to hide initially because theyâre highly contextual or because users will know that theyâre there.
This means we need to hide the element while still making it accessible. Well, thatâs what the hidden="until-found" attribute-value does, but we also need to reserve the space, since we canât hover what isnât there, right? We also donât want to cause any layout shift.
And thatâs exactly where contain-intrinsic-size: auto none comes into it, which remembers the size of a rendered element.
Hereâs an example:
The red border outlines the button, which is hidden but accessible (hoverable, focusable, find-in-pageable, and fragment navigable). The hidden=until-found attribute-value makes it find-in-pageable and fragment navigable, while a bit of CSS makes it hoverable and focusable. If youâre ready to see how, letâs begin.
The HTML (hidden=until-found)
In the demo below, Iâve added hidden=until-found to a <button>. This makes the buttonâs content find-in-pageable (we can reveal the text by searching for âHidden until foundâ), fragment navigable (itâll reveal itself if it matches a fragment identifier/URL hash), and itâs also focusable right off the bat (wellâŠthe buttonâs focusable, not the content).
<button hidden="until-found">Hidden until found</button>
hidden=until-found declares content-visibility: hidden, overwriting the initial value of visible, so it shouldnât come as a surprise that it toggles the contentâs visibility, not the element itself. This is why we see an empty button to begin with, and why the button remains focusable.
Weâll worry about hiding the overall button in a minute. For now, letâs worry about the fact that interacting with it causes layout shift, and that itâs not fully hoverable, whichâll be a bigger issue once we actually hide it. Essentially, we need the button to be in its full-size state with the content hidden.
Other approaches (visibility: hidden, display: none, opacity: 0, width: 0 + height: 0, etc.) arenât accessible visually, or to screen readers, or to find-in-page. Old-school opacity: 0 is actually the most effective of those because it reserves layout space and makes the content accessible to screen readers, but when it comes to find-in-page, the content is found but not shown. hidden=until-found, which leverages content-visibility, works because it finds the content and toggles it.
The CSS (mostly contain-intrinsic-size: auto none)
But again, how do we reserve the space to make it fully hoverable and avoid layout shift? How do we hover something that isnât there, exactly? Well, contain-intrinsic-size: auto none saves the last rendered size of an element, so all we need to do is render it, if only for a millisecond. We can do this using a @keyframes animation.
Obviously, we donât need to do this if we know size of the element already and have declared it, but if we donât, then we can show the element for a millisecond using a @keyframes animation and use contain-intrinsic-size: auto none to say, âHey, remember that itâs this size.â
Anyway, we can see our @keyframes animation below, where content-visibility: visible is declared at the start of the animation. We donât include a to keyframe selector, which means that itâll animate to the content-visibility: hidden fallback, courtesy of hidden=until-found.
@keyframes render {
/* When the animation starts */
from {
/* Make the content visible */
content-visibility: visible;
}
/* This is already implied
to {
content-visibility: hidden;
}
*/
}
Now, letâs apply this animation to the button and ensure that its dimensions are saved during the very brief moment that itâs rendered.
animation: 1ms render applies content-visibility: visible during the one-millisecond animation as contain-intrinsic-size: auto none saves the size. However, youâll almost certainly see the content flicker regardless. From my understanding, this is because of how the Critical Rendering Path (CRP) works, but we can hide the element altogether with opacity: 0, so no worries there.
button {
/* This is almost instantaneous */
animation: 1ms render;
/* Save the rendered size while visible */
contain-intrinsic-size: auto none;
/* Prevent flickering during the animation */
opacity: 0;
}
On the topic of rendering and depending on web performance, 1ms might not be enough time to capture the size, especially in Safari for some reason. So if the technique doesnât work, try increasing the animation duration. Even 2ms or 3ms can make a big difference.
As the demo below shows, the button is fully laid out (as indicated by the containerâs red border). Itâs invisible, and the content is hidden, but itâs also hoverable, focusable, find-in-pageable, and fragment navigable.
All we need to do now is make the button react to those interactions and behaviors, which is what Iâve done below with the &:is(:hover, :focus, :not([hidden])) nested selector:
button {
/* This is almost instantaneous */
animation: 1ms render;
/* Save the rendered size while visible */
contain-intrinsic-size: auto none;
/* Prevent flickering during the animation */
opacity: 0;
/* Reveal it at will */
&:is(:hover, :focus, :not([hidden])) {
opacity: 1;
content-visibility: visible;
}
}
Here, opacity (which was set to 0 to hide the flickering) is set to 1, while content-visibility (which was set to hidden by hidden=until-found) is set to visible. This happens on hover, on focus, and when the hidden attribute is toggled off by find-in-page or a fragment identifier. If you feel like youâre playing too fast and loose with the whole âusers will know itâs thereâ thing, you can move that :hover to a parent (i.e., have a link-to-this-comment button reveal itself when the overall comment is hovered).
The button already reverts back to content-visibility: visible when find-in-page or a fragment identifier removes the hidden attribute (i.e., :not([hidden])), but for brevity, we can keep it as-is.
By the way, if the browser has to wait for CSS (e.g., because itâs external), the layout will shift regardless, so I recommend serving the CSS with the HTML, just as Iâve done in the demos.
Final Thoughts
I donât know if making interactive elements (or anything, really) invisible is a neat idea, but if youâre being forced to or you intend to do it anyway, this approach at least responds to all relevant states of interaction, whereas other techniques donât.
Having said that, I imagine youâd use this technique for decluttering (i.e., to deprioritize unimportant UI components). With that in mind, I strongly suggest placing those components exactly where users expect them, perhaps with some extra padding to make it easier to trigger the hover state.
Finally, note that there are some quirks that look like bugs, but arenât:
- Content found by find-in-page doesnât disappear once found
- In Safari, buttons are only tabbable with
tabandoption(by default, anyway) - The technique wonât work in background tabs unless theyâre prerendered