{"id":10331,"date":"2026-07-08T08:41:32","date_gmt":"2026-07-08T13:41:32","guid":{"rendered":"https:\/\/master.dev\/blog\/?p=10331"},"modified":"2026-07-08T08:41:32","modified_gmt":"2026-07-08T13:41:32","slug":"how-to-make-an-interactive-element-invisible-but-accessible","status":"publish","type":"post","link":"https:\/\/master.dev\/blog\/how-to-make-an-interactive-element-invisible-but-accessible\/","title":{"rendered":"How to Make an Interactive Element Invisible but Accessible"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Let\u2019s say we want to hide some interactive elements until interaction occurs. For example, we want some cards to have share buttons, but we don\u2019t 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\u2019re within said part, copy-to-clipboard buttons, which we can reveal when we\u2019re somehow engaged with the content that we want to copy, scroll-to-top buttons, actionbar items of lesser importance \u2014 you know, things that might be okay to hide initially because they\u2019re highly contextual or because users will know that they\u2019re there.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This means we need to hide the element while still making it accessible. Well, that\u2019s what the\u00a0<code>hidden=\"until-found\"<\/code>\u00a0attribute-value does, but we also need to reserve the space, since we can\u2019t hover what isn\u2019t there, right? We also don\u2019t want to cause any layout shift.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And that\u2019s exactly where&nbsp;<code>contain-intrinsic-size: auto none<\/code>&nbsp;comes into it, which remembers the size of a rendered element.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here\u2019s an example:<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_RNKRzYK\" src=\"\/\/codepen.io\/anon\/embed\/RNKRzYK?height=450&amp;theme-id=1&amp;slug-hash=RNKRzYK&amp;default-tab=html,result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed RNKRzYK\" title=\"CodePen Embed RNKRzYK\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">The red border outlines the button, which is hidden but accessible (hoverable, focusable, find-in-pageable, and fragment navigable). The&nbsp;<code>hidden=until-found<\/code>&nbsp;attribute-value makes it find-in-pageable and fragment navigable, while a bit of CSS makes it hoverable and focusable. If you\u2019re ready to see how, let\u2019s begin.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The HTML (<code>hidden=until-found<\/code>)<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In the demo below, I\u2019ve added&nbsp;<code>hidden=until-found<\/code>&nbsp;to a&nbsp;<code>&lt;button&gt;<\/code>. This makes the button\u2019s content find-in-pageable (we can reveal the text by searching for \u201cHidden until found\u201d), fragment navigable (it\u2019ll reveal itself if it matches a fragment identifier\/URL hash), and it\u2019s also focusable right off the bat (<em>well<\/em>\u2026the&nbsp;<em>button\u2019s<\/em>&nbsp;focusable, not the content).<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">button<\/span> <span class=\"hljs-attr\">hidden<\/span>=<span class=\"hljs-string\">\"until-found\"<\/span>&gt;<\/span>Hidden until found<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">button<\/span>&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_QwGRmMV\" src=\"\/\/codepen.io\/anon\/embed\/QwGRmMV?height=450&amp;theme-id=1&amp;slug-hash=QwGRmMV&amp;default-tab=html,result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed QwGRmMV\" title=\"CodePen Embed QwGRmMV\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p class=\"wp-block-paragraph\"><code>hidden=until-found<\/code>&nbsp;declares&nbsp;<code>content-visibility: hidden<\/code>, overwriting the initial value of&nbsp;<code>visible<\/code>, so it shouldn\u2019t come as a surprise that it toggles the content&#8217;s <em>visibility<\/em>, not the element itself. This is why we see an empty button to begin with, and why the button remains focusable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">We\u2019ll worry about hiding the overall button in a minute. For now, let\u2019s worry about the fact that interacting with it causes layout shift, and that it\u2019s not fully hoverable, which\u2019ll 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Other approaches (<code>visibility: hidden<\/code>,&nbsp;<code>display: none<\/code>,&nbsp;<code>opacity: 0<\/code>,&nbsp;<code>width: 0<\/code>&nbsp;+&nbsp;<code>height: 0<\/code>, etc.) aren\u2019t accessible visually, or to screen readers, or to find-in-page. Old-school&nbsp;<code>opacity: 0<\/code>&nbsp;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.&nbsp;<code>hidden=until-found<\/code>, which leverages&nbsp;<code>content-visibility<\/code>, works because it finds the content and toggles it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The CSS (mostly&nbsp;<code>contain-intrinsic-size: auto none<\/code>)<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">But again, how do we reserve the space to make it fully hoverable and avoid layout shift? How do we hover something that isn\u2019t there, exactly? Well,&nbsp;<code>contain-intrinsic-size: auto none<\/code>&nbsp;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&nbsp;<code>@keyframes<\/code>&nbsp;animation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Obviously, we don\u2019t need to do this if we know size of the element already and have declared it, but if we don\u2019t, then we can show the element for a millisecond using a&nbsp;<code>@keyframes<\/code>&nbsp;animation and use&nbsp;<code>contain-intrinsic-size: auto none<\/code>&nbsp;to say, \u201cHey, remember that it\u2019s&nbsp;<em>this<\/em>&nbsp;size.\u201d<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Anyway, we can see our&nbsp;<code>@keyframes<\/code>&nbsp;animation below, where&nbsp;<code>content-visibility: visible<\/code>&nbsp;is declared at the start of the animation. We don\u2019t include a&nbsp;<code>to<\/code>&nbsp;keyframe selector, which means that it\u2019ll animate to the&nbsp;<code>content-visibility: hidden<\/code>&nbsp;fallback, courtesy of&nbsp;<code>hidden=until-found<\/code>.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-keyword\">@keyframes<\/span> render {\n  <span class=\"hljs-comment\">\/* When the animation starts *\/<\/span>\n  <span class=\"hljs-selector-tag\">from<\/span> {\n    <span class=\"hljs-comment\">\/* Make the content visible *\/<\/span>\n    <span class=\"hljs-attribute\">content-visibility<\/span>: visible;\n  }\n\n  <span class=\"hljs-comment\">\/* This is already implied\n  to {\n    content-visibility: hidden;\n  }\n  *\/<\/span>\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">Now, let\u2019s apply this animation to the button and ensure that its dimensions are saved during the very brief moment that it\u2019s rendered.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>animation: 1ms render<\/code>&nbsp;applies&nbsp;<code>content-visibility: visible<\/code>&nbsp;during the one-millisecond animation as&nbsp;<code>contain-intrinsic-size: auto none<\/code>&nbsp;saves the size. However, you\u2019ll 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&nbsp;<code>opacity: 0<\/code>, so no worries there.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">button<\/span> {\n  <span class=\"hljs-comment\">\/* This is almost instantaneous *\/<\/span>\n  <span class=\"hljs-attribute\">animation<\/span>: <span class=\"hljs-number\">1ms<\/span> render;\n\n  <span class=\"hljs-comment\">\/* Save the rendered size while visible *\/<\/span>\n  <span class=\"hljs-attribute\">contain-intrinsic-size<\/span>: auto none;\n\n  <span class=\"hljs-comment\">\/* Prevent flickering during the animation *\/<\/span>\n  <span class=\"hljs-attribute\">opacity<\/span>: <span class=\"hljs-number\">0<\/span>;\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">On the topic of rendering and depending on web performance,&nbsp;<code>1ms<\/code>&nbsp;might not be enough time to capture the size, especially in Safari for some reason. So if the technique doesn\u2019t work, try increasing the animation duration. Even&nbsp;<code>2ms<\/code>&nbsp;or&nbsp;<code>3ms<\/code>&nbsp;can make a big difference.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">As the demo below shows, the button is fully laid out (as indicated by the container\u2019s red border). It\u2019s invisible, and the content is hidden, but it\u2019s also hoverable, focusable, find-in-pageable, and fragment navigable.<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_KwazeWq\" src=\"\/\/codepen.io\/anon\/embed\/KwazeWq?height=450&amp;theme-id=1&amp;slug-hash=KwazeWq&amp;default-tab=html,result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed KwazeWq\" title=\"CodePen Embed KwazeWq\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">All we need to do now is make the button react to those interactions and behaviors, which is what I\u2019ve done below with the&nbsp;<code>&amp;:is(:hover, :focus, :not([hidden]))<\/code>&nbsp;nested selector:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">button<\/span> {\n  <span class=\"hljs-comment\">\/* This is almost instantaneous *\/<\/span>\n  <span class=\"hljs-attribute\">animation<\/span>: <span class=\"hljs-number\">1ms<\/span> render;\n\n  <span class=\"hljs-comment\">\/* Save the rendered size while visible *\/<\/span>\n  <span class=\"hljs-attribute\">contain-intrinsic-size<\/span>: auto none;\n\n  <span class=\"hljs-comment\">\/* Prevent flickering during the animation *\/<\/span>\n  <span class=\"hljs-attribute\">opacity<\/span>: <span class=\"hljs-number\">0<\/span>;\n\n  <span class=\"hljs-comment\">\/* Reveal it at will *\/<\/span>\n  &amp;:is(:hover, :focus, :not(&#91;hidden])) {\n    <span class=\"hljs-attribute\">opacity<\/span>: <span class=\"hljs-number\">1<\/span>;\n    <span class=\"hljs-attribute\">content-visibility<\/span>: visible;\n  }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">Here, <code>opacity<\/code>&nbsp;(which was set to&nbsp;<code>0<\/code>&nbsp;to hide the flickering) is set to&nbsp;<code>1<\/code>, while&nbsp;<code>content-visibility<\/code>&nbsp;(which was set to&nbsp;<code>hidden<\/code>&nbsp;by&nbsp;<code>hidden=until-found<\/code>) is set to&nbsp;<code>visible<\/code>. This happens on hover, on focus, and when the&nbsp;<code>hidden<\/code>&nbsp;attribute is toggled off by find-in-page or a fragment identifier. If you feel like you\u2019re playing too fast and loose with the whole \u201cusers will know it\u2019s there\u201d thing, you can move that&nbsp;<code>:hover<\/code>&nbsp;to a parent (i.e., have a link-to-this-comment button reveal itself when the overall comment is hovered).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The button already reverts back to&nbsp;<code>content-visibility: visible<\/code>&nbsp;when find-in-page or a fragment identifier removes the&nbsp;<code>hidden<\/code>&nbsp;attribute (i.e.,&nbsp;<code>:not([hidden])<\/code>), but for brevity, we can keep it as-is.<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_RNKRzYK\" src=\"\/\/codepen.io\/anon\/embed\/RNKRzYK?height=450&amp;theme-id=1&amp;slug-hash=RNKRzYK&amp;default-tab=html,result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed RNKRzYK\" title=\"CodePen Embed RNKRzYK\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">By the way, if the browser has to wait for CSS (e.g., because it\u2019s external), the layout will shift regardless, so I recommend serving the CSS&nbsp;<em>with<\/em>&nbsp;the HTML, just as I\u2019ve done in the demos.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Final Thoughts<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">I don\u2019t know if making interactive elements (or anything, really) invisible is a neat idea, but if you\u2019re 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\u2019t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Having said that, I imagine you\u2019d 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Finally, note that there are some quirks that look like bugs, but aren\u2019t:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Content found by find-in-page doesn\u2019t disappear once found<\/li>\n\n\n\n<li>In Safari, buttons are only tabbable with&nbsp;<code>tab<\/code>&nbsp;<em>and<\/em>&nbsp;<code>option<\/code>&nbsp;(by default, anyway)<\/li>\n\n\n\n<li>The technique won\u2019t work in background tabs unless they\u2019re&nbsp;<a href=\"https:\/\/developer.chrome.com\/docs\/web-platform\/prerender-pages\">prerendered<\/a><\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>The attribute `hidden=&#8221;until-found&#8221; can be quite useful in HTML, but we&#8217;ll need another unexpected bit of CSS to ensure space is reserved.<\/p>\n","protected":false},"author":37,"featured_media":10340,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"sig_custom_text":"","sig_image_type":"featured-image","sig_custom_image":0,"sig_is_disabled":false,"inline_featured_image":false,"_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_feature_clip_id":0,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_post_was_ever_published":false},"categories":[1],"tags":[49,122,7,509,510],"class_list":["post-10331","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog-post","tag-accessibility","tag-contain","tag-css","tag-hidden","tag-skip-link"],"acf":[],"jetpack_featured_media_url":"https:\/\/i0.wp.com\/master.dev\/blog\/wp-content\/uploads\/2026\/07\/hidden-until-found.jpg?fit=2000%2C1200&ssl=1","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/posts\/10331","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/users\/37"}],"replies":[{"embeddable":true,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/comments?post=10331"}],"version-history":[{"count":3,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/posts\/10331\/revisions"}],"predecessor-version":[{"id":10365,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/posts\/10331\/revisions\/10365"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/media\/10340"}],"wp:attachment":[{"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/media?parent=10331"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/categories?post=10331"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/tags?post=10331"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}