{"slug": "the-undeniable-utility-of-css-has", "title": "The Undeniable Utility Of CSS :has", "summary": "The article discusses the CSS `:has` pseudo-class, a modern feature that allows styling a parent element based on its children, reversing traditional top-down CSS selectors. It highlights real-world use cases, such as improving focus outlines on interactive cards, and notes that while browser support is at ~92%, fallback styles can be provided using the `@supports` rule. The author, initially skeptical, found `:has` to be an incredibly handy utility even in CSS-in-JS contexts like React.", "body_md": "[Introduction]\n\nI don’t know if you’ve noticed, but the CSS world has been on fire recently. 🔥\n\nBehind the scenes, all major browser vendors and the CSS specification authors have been working together to deliver *tons* of highly-requested CSS features. Things like container queries, native CSS nesting, relative color syntax, balanced text, and so much more.\n\nOne of these new features is the `:has`\n\npseudo-class. **And, honestly, I wasn’t sure how useful it would be for me.** I mostly build webapps using React, which means I tend not to use complex selectors. Would the `:has`\n\npseudo-class really offer much benefit in this context?\n\nWell, I’ve spent the past few months rebuilding this blog, using all of the modern CSS bells and whistles. **And my goodness, I was wrong about :has.** It’s an\n\n*incredibly*handy utility, even in a CSS-in-JS context!\n\nIn this blog post, I'll introduce you to `:has`\n\nand share some of the most interesting real-world use cases I’ve found so far, along with some truly mindblowing experiments.\n\n[Link to this heading](#the-basics-1)The basics\n\nHistorically, CSS selectors have worked in a “top down” fashion.\n\nFor example, by separating multiple selectors with a space, we can selectively style a *child* based on its *parent*:\n\nCode Playground\n\nResult\n\nThe `:has`\n\npseudo-selector works in a “bottom up” fashion; it allows us to style a *parent* based on its *children:*\n\nCode Playground\n\nResult\n\nThis might not seem like a big deal, but it opens *so many interesting new doors.* Over the past few months, I’ve had one epiphany after another, moments where I went *“Woah, that means I can do this??”*\n\n[Link to this heading](#browser-support-2)Browser support\n\nBefore we get to all the cool demos, we should briefly talk about browser support. `:has`\n\nis supported in all 4 major browsers, starting from:\n\n- Safari 15.4, introduced in March 2022\n- Chrome/Edge 105, introduced in August 2022\n- Firefox 121, introduced in December 2023\n\nAs I write this in September 2024, `:has`\n\nis at ~92% browser support. Here's a live embed with up-to-date values:\n\nHonestly, 92% isn’t *great* when it comes to browser support… That means roughly 1 in 12 people are using an unsupported browser!\n\nFortunately, most of the use cases I’ve found for `:has`\n\nare optional “nice-to-have” bonuses, so it’s not really a big deal if they don’t show up for everyone. And in other cases, we can use feature detection to provide fallback CSS.\n\n[Link to this heading](#feature-detection-3)Feature detection\n\nThe `@supports`\n\nat-rule allows us to apply CSS conditionally, based on whether or not it’s supported by the user’s browser. Here’s what it looks like:\n\n```\np {\n  /* Fallback styles here */\n}\n\n@supports selector(p:has(a)) {\n  p:has(a) {\n    /* Fancy modern styles here */\n  }\n}\n```\n\nIf the selector passed to the `selector()`\n\nfunction isn’t understood by the current browser, everything within is ignored. And if the user’s browser is even older, and doesn’t recognize the `@supports`\n\nat-rule, then the whole block is ignored. Either way, it works out.\n\nNow, the thing is, there is no way to “mimic” `:has`\n\nusing older CSS. Our fallback styles won’t really be able to reproduce the same effect. Instead, we should think of it as having two sets of styles that accomplish the same goal in different ways. I'll include an example in the next section.\n\n[Link to this heading](#styling-based-on-states-4)Styling based on states\n\nOn this blog’s new [“About Josh” page](/about-josh/), I use a “bento box” layout containing a bunch of little cards. Some of these cards have clickable children:\n\nFor folks who navigate with a keyboard, however, the experience was a bit more funky. Some of the children dynamically change size, leading to curious focus outlines like this:\n\nTo solve this problem, I moved the focus outline to the parent container. Here’s what it looks like now:\n\nThis solves our problem, and I think it also looks pretty nice!\n\nLet’s dig into how this works. Here’s roughly what the HTML looks like:\n\n```\n<div class=\"bento-card\">\n  <p>\n    I'm\n    <button>188cm</button>\n    tall.\n  </p>\n</div>\n```\n\nIn the past, I might’ve solved this by making the whole `.bento-card`\n\ncontainer a `<button>`\n\n, but this isn’t a good idea. Cramming so much stuff into a button would introduce several usability and accessibility issues; for example, users can't click-and-drag to select text inside buttons!\n\nFortunately, we can keep our nice semantic markup and accomplish our goals with `:has`\n\n:\n\n```\n.bento-card:has(button:focus-visible) {\n  outline: 2px solid var(--color-primary);\n}\n\n/* Remove the default button focus outline */\n.bento-card button {\n  outline: none;\n}\n```\n\nWhen `.bento-card`\n\ncontains a focused button, we add an outline to it. The outline is applied to the parent `.bento-card`\n\n, rather than to the button itself.\n\nIf you’re not familiar with the `:focus-visible`\n\npseudo-class, it works exactly like `:focus`\n\n, but it only applies when the browser detects that the user is using the keyboard (or other non-pointer device) to navigate. When a mouse-wielding user focuses the button by clicking it, `:focus-visible`\n\nwon’t be triggered, and no focus outline will be shown.\n\nI'm also removing the default focus outline from the button, to prevent double focus indicators. **This is something we should be very cautious about.** In fact, our solution isn’t yet complete, since we also need to provide a fallback experience for folks using older browsers.\n\nHere’s what that looks like:\n\n```\n@supports selector(:has(*)) {\n  .bento-card:has(button:focus-visible) {\n    outline: 2px solid var(--color-primary);\n  }\n\n  .bento-card button {\n    outline: none;\n  }\n}\n```\n\nIn this updated version, the outline modifications will only be applied for folks who visit using modern browsers. If someone is using a legacy browser, none of this stuff will apply, and they’ll see the standard focus outlines. Even though it’s a little funky, I think it’s a reasonable fallback experience.\n\nI'm also taking a little shortcut here: rather than test for the specific selector I'm using (`.bento-card:has(button:focus-visible)`\n\n), I'm instead using the smallest valid `:has`\n\nselector, `:has(*)`\n\n. The browser won't actually try and *resolve* the selector we supply, so it doesn’t matter which elements are selected. `@supports`\n\nworks by looking at the syntax and establishing whether it's valid or not.\n\n[Link to this heading](#another-state-based-example-5)Another state-based example\n\nCSS has dozens and dozens of pseudo-classes beyond `:focus-visible`\n\n, and we can use any of them to apply CSS conditionally with `:has`\n\n!\n\nLet’s look at another example from this blog. Here’s a custom form control I use in a couple of places. I call it an “X/Y Pad”:\n\n- X:\n- 0.00\n- Y:\n- 0.00\n\n(*This is an interactive element!* You can click and drag the handle to change the X/Y values. For keyboard users, you can focus the handle and use the arrow keys.)\n\nNotice that while you drag/adjust the handle, *the container* changes color! The code looks something like this:\n\n```\n<style>\n  .xy-pad {\n    --dot-color: gray;\n  }\n  .xy-pad:has(.handle:active),\n  .xy-pad:has(.handle:focus-visible), {\n    --dot-color: var(--color-primary);\n  }\n</style>\n\n<div class=\"xy-pad\">\n  <svg>\n    <!-- Dotted background here -->\n  </svg>\n\n  <button class=\"handle\"></button>\n</div>\n```\n\nIf you’re not familiar, the `:active`\n\npseudo-class is applied when a button is being clicked and held. While the user is dragging the handle, our `:has`\n\nselector matches, and we change the value of a CSS variable, `--dot-color`\n\n.\n\nAdditionally, I've added a secondary selector with `:focus-visible`\n\n, so that keyboard users get the same treatment.\n\nThe `--dot-color`\n\nCSS variable is used in several places, for the borders and lines and dots. The dots themselves are dynamically generated as a bunch of SVG circles:\n\n```\n<circle fill=\"var(--dot-color)\">\n```\n\n[Link to this heading](#global-detection-6)Global detection\n\n**This is maybe the coolest use-case I've found so far.** We can use `:has`\n\nas a sort of global event listener.\n\nFor example, suppose we’re building a modal/dialog component. When the modal is open, we want to disable scrolling on the page. We can do this by applying some CSS to the `<html>`\n\ntag:\n\n```\n/* Scrolling disabled while this is set: */\nhtml {\n  overflow: hidden;\n}\n```\n\nHere’s how I would have solved this in the past, using a JS framework like React:\n\n```\n// Register a side-effect that runs whenever `isOpen` changes:\nReact.useEffect(() => {\n  if (isOpen) {\n    // Save the current value for `overflow`,\n    // so that we can restore it later:\n    const { overflow } =\n      document.documentElement.getComputedStyle();\n\n    // Apply the new value to disable scrolling:\n    document.documentElement.style.overflow = \"hidden\";\n\n    // Register a cleanup function that undoes this work,\n    // when `isOpen` flips back to `false`:\n    return () => {\n      document.documentElement.style.overflow = overflow;\n    };\n  }\n}, [isOpen]);\n```\n\nDon’t worry if you’re not familiar with React. The point here is that this is a really clunky way to solve this problem!\n\nWe can solve this in a much nicer way with `:has`\n\n:\n\n```\nhtml:has([data-disable-document-scroll=\"true\"]) {\n  overflow: hidden;\n}\n```\n\nIf the HTML contains an element that sets this data attribute, *no matter where it is in the DOM*, we’ll apply `overflow: hidden`\n\n.\n\nInside our `Modal`\n\ncomponent, we’ll trigger it by conditionally setting the data attribute:\n\n```\nfunction Modal({ isOpen, children }) {\n  return (\n    <div\n      data-disable-document-scroll={isOpen}\n    >\n      {/* Modal stuff here */}\n    </div>\n  );\n}\n```\n\n*How friggin’ cool is that??* The instant our modal opens, this data attribute gets flipped to `\"true\"`\n\n, which means our `:has`\n\nselector becomes fulfilled, and scrolling becomes disabled. If this data attribute flips back to `\"false\"`\n\n, or if the element itself is removed from the DOM, scrolling will automatically be restored. ✨\n\nThis example uses React, but we can leverage the same trick in a vanilla JavaScript context. Here’s a quick sketch:\n\n``` js\nfunction toggleModal(isOpen) {\n  const element = document.querySelector('...');\n  element.dataset.disableDocumentScroll = isOpen;\n}\n```\n\n[Link to this heading](#javascript-free-dark-mode-7)JavaScript-free Dark Mode\n\nJen Simmons discovered that we can use this trick to create a JavaScript-free “Dark Mode” toggle. Here’s an example:\n\n```\n<style>\n  /* Default (light mode) colors: */\n  body {\n    --color-text: black;\n    --color-background: white;\n  }\n\n  /* Dark mode colors: */\n  body:has(#dark-mode-toggle:checked) {\n    --color-text: white;\n    --color-background: black;\n  }\n</style>\n\n<!-- Somewhere in the DOM: -->\n<input id=\"dark-mode-toggle\" type=\"checkbox\">\n<label for=\"dark-mode-toggle\">\n  Enable Dark Mode\n</label>\n```\n\nWhen the user clicks the checkbox, the `:checked`\n\npseudo-class is applied to it, which causes our `:has`\n\nselector to match. We overwrite the baseline CSS variables with new dark-mode ones, and the theme is effectively swapped!\n\nTo be clear, Dark Mode is a [surprisingly complicated thing](/react/dark-mode/), and this approach isn’t really a complete implementation (for example, it doesn’t save/restore the user’s preferred option, or inherit the default theme from the operating system). Plus, I wouldn’t want a core piece of functionality to depend on a CSS feature with only ~92% support. But still, it’s friggin’ cool that we can add a “Dark Mode” toggle with only a single CSS rule and no JS!\n\nYou can read more about this approach, and see lots of other cool examples, in [Jen’s wonderful blog post(opens in new tab)](https://webkit.org/blog/13096/css-has-pseudo-class/).\n\n[Link to this heading](#the-missing-selector-8)The missing selector\n\nSo far, all of the examples we’ve looked at involve styling the parent based on one of its descendants. This is very cool, but it’s only the tip of the iceberg.\n\nCheck this out:\n\nCode Playground\n\nResult\n\nIn this scenario, I'm selecting all paragraphs that come *right before* a `<figure>`\n\ntag. The big difference here is that there’s no parent/child relationship; the paragraphs and figures are siblings!\n\nNow, to be clear, we’ve been able to do *similar* things in CSS for quite a while, using the “next-sibling combinator”, `+`\n\n. This little fella allows us to select an element that comes *after* a given selector:\n\nCode Playground\n\nResult\n\nOn its own, the `+`\n\ncombinator can only be used to select elements that come *after* a given selector in the DOM. It only works in one direction. With `:has`\n\n, we can flip the order, which means that together, we can select elements in either direction!\n\nCode Playground\n\nResult\n\n**We’re not limited to direct siblings, either.** With `:has`\n\n, we can style one element based on another element in a totally different container!\n\nHere’s a wild example, adapted from [Ahmad’s comprehensive blog post on :has(opens in new tab)](https://ishadeed.com/article/css-has-guide/). Try hovering over the category buttons and/or the books:\n\nCode Playground\n\nResult\n\nHovering over one of the category buttons will add a hover state to the buttons themselves, *as well as any books that match the selected category!* Likewise, hovering over one of the books highlights the matching category.\n\nIt’s hard to parse the CSS in the constrained space within the playground, so here’s the core CSS logic in a more spacious box:\n\n```\nhtml:has([data-category=\"sci-fi\"]:hover) [data-category=\"sci-fi\"] {\n  background: var(--highlight-color);\n}\nhtml:has(\n  [data-category=\"sci-fi\"]:hover\n) [data-category=\"sci-fi\"] {\n  background: var(--highlight-color);\n}\n```\n\nThe first part of this selector uses the same “global detection” logic we saw earlier. We’re checking to see if the DOM contains a node that:\n\n- Sets the\n`category`\n\ndata attribute to`\"sci-fi\"`\n\n, and - Is currently being hovered.\n\nInstead of applying styles directly to the `<html>`\n\ntag, though, we’re instead looking for any *descendants* that have the `category`\n\ndata attribute set to `\"sci-fi\"`\n\n.\n\nTo paraphrase the logic here, I'm essentially saying: *“If the HTML document contains at least 1 hovered element with category set to \"sci-fi\", apply the following CSS to all elements with that category”*. In this particular case, the CSS I'm applying is a lilac background color, but it could be anything!\n\nThe wild thing about this example is that the actual DOM structure doesn’t matter. The category buttons are in a totally different part of the DOM from the book elements. There’s no parent/child relationship, or even a sibling relationship! The only thing they have in common is that they’re both descendants of the root `<html>`\n\ntag, same as any other node in the document.\n\n**It kinda feels like :has is the “missing selector” in CSS.** Historically, there have been a bunch of relationships we just couldn’t express in CSS. With\n\n`:has`\n\n, we can select any element based on the properties/status of any *other*element. No limits!\n\n[Link to this heading](#the-best-tool-for-the-job-9)The best tool for the job\n\nAs we’ve seen, the `:has`\n\nselector is *incredibly* powerful. Things that used to require JavaScript can now be accomplished exclusively using CSS!\n\nBut just because we *can* solve problems like this, does that mean we *should?*\n\nI'm a big fan of using whichever tool can solve the problem with the least amount of complexity. And when a problem can be solved either with CSS or JavaScript, the CSS solution tends to be much simpler.\n\nWith `:has`\n\n, however, things can get pretty complicated. Here’s a “final” version of the snippet we just saw, including alternative controls for mobile/keyboard:\n\n```\nhtml:where(\n  :has([data-category=\"sci-fi\"]:hover),\n  :has([data-category=\"sci-fi\"]:focus-visible),\n  :has([data-category=\"sci-fi\"]:active),\n) [data-category=\"sci-fi\"],\nhtml:where(\n  :has([data-category=\"fantasy\"]:hover),\n  :has([data-category=\"fantasy\"]:focus-visible),\n  :has([data-category=\"fantasy\"]:active),\n) [data-category=\"fantasy\"],\nhtml:where(\n  :has([data-category=\"romance\"]:hover),\n  :has([data-category=\"romance\"]:focus-visible),\n  :has([data-category=\"romance\"]:active),\n) [data-category=\"romance\"] {\n  background: var(--highlight-color);\n}\n```\n\n(The `:where`\n\npseudo-class allows us to “group” related selectors. It’s equivalent to writing each clause out as a separate selector.)\n\nIf I was building this UI using a framework like React, I think it would actually be simpler to create a state variable that tracks which category is currently active. It would also be more flexible; we could have dynamic categories, rather than hardcoded ones. And books could belong to multiple categories. And it would work in Internet Explorer.\n\nI included this example because it really is an incredible demonstration of what `:has`\n\ncan do, but if I was building this particluar UI for real, I would implement this logic in JavaScript.\n\nIn practice, I find myself using `:has`\n\nin less grandiose ways, like the focus outlines on the [“About” page](/about-josh/), or for disabling scroll on mobile. It’s a *super* handy selector in these circumstances, and works very well in the context of a React application!\n\n**As I mentioned earlier, I recently rebuilt this blog, using a bunch of modern CSS.** This is the first of several blog posts I plan to write 😄. If you’d like to be notified when I publish new content, you can join my newsletter:\n\nAnd if you'd like to learn more about `:has`\n\n, there are tons of amazing resources out there. Here are some of my favourites:\n\n[Selecting Previous Siblings With CSS :has()(opens in new tab)](https://tobiasahlin.com/blog/previous-sibling-css-has/), by Tobias Ahlin[CSS :has() Interactive Guide(opens in new tab)](https://ishadeed.com/article/css-has-guide/)by Ahmad Shadeed[Level Up Your CSS Skills With The :has() Selector(opens in new tab)](https://www.smashingmagazine.com/2023/01/level-up-css-skills-has-selector/), by Stephanie Eckles\n\n### Last updated on\n\nSeptember 9th, 2024", "url": "https://wpnews.pro/news/the-undeniable-utility-of-css-has", "canonical_source": "https://www.joshwcomeau.com/css/has/", "published_at": "2024-09-09 13:15:00+00:00", "updated_at": "2026-05-22 14:58:19.011606+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["React", "CSS"], "alternates": {"html": "https://wpnews.pro/news/the-undeniable-utility-of-css-has", "markdown": "https://wpnews.pro/news/the-undeniable-utility-of-css-has.md", "text": "https://wpnews.pro/news/the-undeniable-utility-of-css-has.txt", "jsonld": "https://wpnews.pro/news/the-undeniable-utility-of-css-has.jsonld"}}