{"slug": "css-has-selector-the-layout-trick-i-wish-i-knew-5-years-ago", "title": "CSS :has() Selector: The Layout Trick I Wish I Knew 5 Years Ago", "summary": "The CSS `:has()` selector, now supported in all major browsers, allows developers to style a parent element based on whether it contains a specific child element, effectively turning CSS into a conditional parent selector. This capability has enabled frontend developers to remove entire JavaScript files and eliminate fragile modifier classes by letting layout and styling respond directly to content structure, such as adapting a card's grid layout based on the presence of an image or applying hero styling to a section containing a primary call-to-action button.", "body_md": "## CSS :has() is not just a fancy :parent\n\nWhen `:has()`\n\nstarted popping up in specs and tweets, I mentally filed it under “cool, but not for shipping work.” I was wrong.\n\nNow it is in Chrome, Safari, Edge, and Firefox. I use it in real projects. It has removed entire JavaScript files and a pile of `.is-active`\n\nclasses that I was embarrassed to maintain.\n\nIf you are a working frontend dev, the shorthand is this: `:has()`\n\nturns CSS from “style what is there” into “style this thing **if it contains** that thing”. That one capability changes layout, state, and validation flows.\n\nI will walk through three places where it made a real difference for me:\n\n- Parent styling without JS\n- Sibling state UIs without wiring events\n- Form validation UI that reacts to the DOM, not a framework\n\nAll of this shipped with zero additional JavaScript.\n\n## Quick mental model of :has()\n\nThe syntax looks like a pseudo class on a selector:\n\n```\n.card:has(img.hero) {\n  /* styles here */\n}\n```\n\nRead it as: “select `.card`\n\nelements that **have** a descendant `img.hero`\n\nsomewhere inside.” It is a conditional filter on the left side of the selector.\n\nYou can also scope it more tightly:\n\n```\n.tabs:has(> .tab.is-active) {\n  /* direct children only */\n}\n```\n\nOr use it with relational selectors like siblings:\n\n```\n.field:has(+ .field--error) {\n  /* this .field is followed by an error field */\n}\n```\n\nOnce that clicks, you start seeing places to remove JS.\n\n## 1. Parent styling in a content-heavy project\n\nFirst real use: a content-heavy marketing site for a biohacking brand I work with. Editors can drop components in any order with a CMS. Sometimes a card has an image, sometimes it is text-only. The layout should adapt.\n\nPreviously I solved this with modifier classes from the CMS, or a hydration script that scans the DOM and adds classes like `.card--with-media`\n\n. Boring, fragile, and slightly gross.\n\nWith `:has()`\n\nI deleted that script.\n\n### Image-aware cards\n\nThe card markup is boring on purpose:\n\n```\n<article class=\"card\">\n  <img class=\"card__media\" src=\"hero.jpg\" alt=\"\">\n  <div class=\"card__body\">\n    <h2>Title</h2>\n    <p>Some text...</p>\n  </div>\n</article>\n\n<article class=\"card\">\n  <div class=\"card__body\">\n    <h2>Another Card</h2>\n    <p>Text-only card.</p>\n  </div>\n</article>\n```\n\nNow the CSS decides layout based on presence of media.\n\n```\n.card {\n  display: grid;\n  gap: 1rem;\n}\n\n.card:has(.card__media) {\n  grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);\n  align-items: center;\n}\n\n.card:not(:has(.card__media)) {\n  padding: 2rem;\n  background: #111;\n  color: #eee;\n}\n```\n\nResult: if marketing drops in an image, the card becomes a two-column layout. If not, it becomes a full-width text block. No new class. No CMS configuration. No JS.\n\nI like this because the markup stays semantic and dumb. The layout is a true function of the content, which is what CSS was always supposed to do but rarely could at the parent level.\n\n### Auto-promoting “hero” sections\n\nSame project. Editors could add a `.section`\n\nstack: some had a prominent CTA, some were just copy. If a section had a primary CTA, design wanted extra padding and a gradient background.\n\n```\n<section class=\"section\">\n  <h2>Get early access</h2>\n  <p>Short description.</p>\n  <a class=\"btn btn--primary\" href=\"#\">Join the beta</a>\n</section>\n\n<section class=\"section\">\n  <h2>What you get</h2>\n  <p>More text...</p>\n</section>\n```\n\nWith `:has()`\n\nI treat any section with a primary button as a pseudo hero.\n\n```\n.section {\n  padding: 2rem 1.5rem;\n  background: #050505;\n}\n\n.section:has(.btn--primary) {\n  padding: 4rem 1.5rem;\n  background: radial-gradient(circle at top, #2f80ed, #050505);\n  color: #fff;\n}\n\n.section:has(.btn--primary) h2 {\n  font-size: 2.25rem;\n}\n```\n\nThat tiny selector replaced a custom “hero” block type in the CMS that content editors kept misusing. I stopped explaining “use the hero component for this” and just let the CSS infer intent from presence of a primary CTA.\n\nYou can do similar things with `:has(video)`\n\n, `:has(.badge--new)`\n\n, etc. It is a good fit for messy CMS content where you want layout to respond to what your editors actually do, not what the schema designer hoped they would do.\n\n## 2. Sibling state UIs without event listeners\n\nSecond use case: stateful UIs that I used to wire up with click handlers. Tabs, disclosure panels, navigation highlights, that stuff.\n\nYes, you can still do it in JS. But if the state is already visible in the DOM, `:has()`\n\nlets CSS own more of the behavior. That means less code, fewer states to sync, and fewer bugs.\n\n### Tabs powered by :target and :has()\n\nOn a little side project for baseball drills, I built a tabbed interface where each tab is actually a link to an anchor. I wanted a sticky tab bar that changes style when any tab content is active.\n\n```\n<div class=\"tabs\">\n  <nav class=\"tabs__nav\">\n    <a href=\"#hitting\">Hitting</a>\n    <a href=\"#pitching\">Pitching</a>\n    <a href=\"#fielding\">Fielding</a>\n  </nav>\n\n  <section id=\"hitting\" class=\"tabs__panel\">...</section>\n  <section id=\"pitching\" class=\"tabs__panel\">...</section>\n  <section id=\"fielding\" class=\"tabs__panel\">...</section>\n</div>\n```\n\nThe panels show / hide with a regular `:target`\n\ntrick.\n\n```\n.tabs__panel {\n  display: none;\n}\n\n.tabs__panel:target {\n  display: block;\n}\n```\n\nOld me would now add JS to toggle classes on the nav. Instead I lean on `:has()`\n\n.\n\n```\n.tabs {\n  border-bottom: 1px solid #333;\n}\n\n.tabs__nav a {\n  padding: .5rem 1rem;\n  text-decoration: none;\n  color: #888;\n}\n\n.tabs__nav a:is(:hover, :focus-visible) {\n  color: #fff;\n}\n\n/* highlight the active tab label */\n.tabs__nav a[href^=\"#\"] {\n  position: relative;\n}\n\n.tabs:has(#hitting:target) .tabs__nav a[href=\"#hitting\"],\n.tabs:has(#pitching:target) .tabs__nav a[href=\"#pitching\"],\n.tabs:has(#fielding:target) .tabs__nav a[href=\"#fielding\"] {\n  color: #fff;\n  font-weight: 600;\n}\n\n/* make the whole tabs block look active if any panel is targeted */\n.tabs:has(.tabs__panel:target) {\n  border-color: #2f80ed;\n}\n```\n\nI am not pretending this scales to 50 tabs. For most content UIs, 3 to 5 tabs is realistic. Writing those few selectors is still cheaper than adding a tab manager, handling history state, and worrying about hydration.\n\nThe key pattern is: some child panel already has state via `:target`\n\nor `[aria-selected=\"true\"]`\n\n. Let `:has()`\n\nbubble that state up to parents and siblings.\n\n### Accordion with native <details> and :has()\n\nI use `<details>`\n\na lot. It is surprisingly powerful with `:has()`\n\n. On a settings panel I wanted the container to visually compress when no section was open, then expand once any accordion entry was open.\n\n```\n<section class=\"settings\">\n  <details class=\"settings__item\">\n    <summary>Profile</summary>\n    <div>...</div>\n  </details>\n  <details class=\"settings__item\">\n    <summary>Privacy</summary>\n    <div>...</div>\n  </details>\n</section>\n```\n\nCSS:\n\n```\n.settings {\n  padding: 1rem;\n  border-radius: .75rem;\n  border: 1px solid #333;\n  max-height: 60vh;\n  overflow: auto;\n  transition: box-shadow .2s ease, border-color .2s ease;\n}\n\n.settings:has(.settings__item[open]) {\n  border-color: #2f80ed;\n  box-shadow: 0 16px 40px rgba(0, 0, 0, .55);\n}\n\n.settings__item + .settings__item {\n  border-top: 1px solid #222;\n}\n\n.settings__item summary {\n  cursor: pointer;\n}\n```\n\nOnce any `<details>`\n\nis open, the whole settings block feels “in focus”. No JS to listen for the toggle event, no syncing of `.is-active`\n\nclasses. The HTML already has `[open]`\n\n. CSS reacts.\n\n## 3. Form validation UI with zero JavaScript\n\nThe biggest win for me: form UI that uses `:has()`\n\nwith built-in browser validation. No client-side validation library. No “touched” state juggling.\n\nOn my own site I revamped a contact form and a simple experiment log form. I wanted:\n\n- Parent field wrappers that highlight error or success\n- Inline messages that only show when actually invalid\n- Submit button that changes state based on form validity\n\nBrowser validation already tracks validity. The DOM knows. `:has()`\n\nlets CSS hook into that.\n\n### Field states from input validity\n\nMarkup:\n\n```\n<form class=\"form\" novalidate>\n  <div class=\"field\">\n    <label>\n      Email\n      <input type=\"email\" name=\"email\" required>\n    </label>\n    <p class=\"field__error\">Please enter a valid email.</p>\n  </div>\n\n  <div class=\"field\">\n    <label>\n      Message\n      <textarea name=\"message\" minlength=\"10\" required></textarea>\n    </label>\n    <p class=\"field__error\">Write at least 10 characters.</p>\n  </div>\n\n  <button type=\"submit\">Send</button>\n</form>\n```\n\nYou can bind field styling to the input inside, purely with CSS.\n\n```\n.field {\n  margin-bottom: 1.5rem;\n}\n\n.field input,\n.field textarea {\n  width: 100%;\n  padding: .6rem .75rem;\n  border-radius: .4rem;\n  border: 1px solid #444;\n  background: #050505;\n  color: #eee;\n}\n\n.field__error {\n  display: none;\n  margin-top: .35rem;\n  font-size: .8rem;\n  color: #ff6b6b;\n}\n\n/* highlight when invalid and touched (using :user-invalid where supported) */\n.field:has(input:user-invalid),\n.field:has(textarea:user-invalid) {\n  color: #ff6b6b;\n}\n\n.field:has(input:user-invalid) input,\n.field:has(textarea:user-invalid) textarea {\n  border-color: #ff6b6b;\n  box-shadow: 0 0 0 1px rgba(255, 107, 107, .6);\n}\n\n.field:has(input:user-invalid) .field__error,\n.field:has(textarea:user-invalid) .field__error {\n  display: block;\n}\n\n/* success state */\n.field:has(input:user-valid),\n.field:has(textarea:user-valid) {\n  color: #4caf50;\n}\n\n.field:has(input:user-valid) input,\n.field:has(textarea:user-valid) textarea {\n  border-color: #4caf50;\n}\n```\n\nNo custom event handlers. The browser decides when the input is valid or invalid. CSS uses `:has()`\n\nto move that state to the wrapper and the message.\n\nIf you want broader support than `:user-invalid`\n\n, you can fall back to `:invalid`\n\nand accept that some browsers show the state earlier.\n\n### Form-level feedback and submit button state\n\nNow zoom out one level. The entire `<form>`\n\nelement also exposes validity via `:valid`\n\nand `:invalid`\n\n. Combine that with `:has()`\n\nand your submit button can react.\n\n```\n.form button[type=\"submit\"] {\n  padding: .7rem 1.25rem;\n  border-radius: .4rem;\n  border: none;\n  background: #333;\n  color: #aaa;\n  cursor: not-allowed;\n  transition: background .15s ease, color .15s ease, transform .05s;\n}\n\n/* any invalid field keeps button in \"disabled\" style */\n.form:has(:invalid) button[type=\"submit\"] {\n  background: #333;\n  color: #777;\n}\n\n/* all fields valid, button goes live */\n.form:has(:valid) button[type=\"submit\"] {\n  background: #2f80ed;\n  color: #fff;\n  cursor: pointer;\n}\n\n.form:has(:valid) button[type=\"submit\"]:active {\n  transform: translateY(1px);\n}\n```\n\nIf you want to actually disable the button, you still need a tiny bit of JS to toggle the `disabled`\n\nattribute. I usually do not bother for simple forms; the button just looks inactive until the browser considers the form valid.\n\nThe nice part is that the logic lives where it belongs. The browser enforces constraints. CSS reads that state. JS, if present at all, sends the request and displays a toast.\n\n## 4. Layout tweaks based on children, not breakpoints\n\nOne more pattern that has crept into my “default toolkit”: adjusting layout based on how many items a container has.\n\nOn my baseball drills page, each drill has one or more tags. I wanted single-tag drills to show the tag inline next to the title, and multi-tag drills to move them into a separate row. Doing that in JS felt silly.\n\n```\n<article class=\"drill\">\n  <header class=\"drill__header\">\n    <h3 class=\"drill__title\">Front toss</h3>\n    <div class=\"drill__tags\">\n      <span class=\"tag\">Hitting</span>\n    </div>\n  </header>\n</article>\n\n<article class=\"drill\">\n  <header class=\"drill__header\">\n    <h3 class=\"drill__title\">Relay race</h3>\n    <div class=\"drill__tags\">\n      <span class=\"tag\">Fielding</span>\n      <span class=\"tag\">Conditioning</span>\n    </div>\n  </header>\n</article>\n```\n\nWith `:has()`\n\nand the `:nth-child()`\n\nselector you can treat the two cases differently.\n\n```\n.drill__header {\n  display: flex;\n  gap: .5rem;\n  align-items: baseline;\n  flex-wrap: wrap;\n}\n\n/* one tag only: keep inline */\n.drill__tags:has(.tag:nth-child(1):last-child) {\n  order: 0;\n}\n\n/* more than one tag: push tags to next line */\n.drill__tags:has(.tag:nth-child(2)) {\n  flex-basis: 100%;\n  order: 1;\n}\n```\n\nNo JavaScript counting nodes. No data attributes. Just “if there is at least a second tag, change layout”. If product decides to add a third or fourth tag, the CSS keeps working.\n\n## Reality check: performance and support\n\nI am not going to pretend `:has()`\n\nis free. The browser has to do more work, because selectors now depend on what is inside elements and how that changes.\n\nMy take after profiling a few real pages: do not go wild with global `*:has(...)`\n\nselectors. Scope them. Prefer direct children or close relationships.\n\n```\n/* Bad idea */\n*:has(.error) { ... }\n\n/* Reasonable */\n.form:has(.field__error) { ... }\n\n/* Even better */\n.form:has(.field > .field__error) { ... }\n```\n\nSupport is good now. Chrome, Edge, Safari, Firefox all ship `:has()`\n\n. Old Safari versions are the main risk. If you work on something critical for a weird enterprise fleet, check caniuse and add progressive enhancement.\n\nMost of my patterns above fail gracefully. You lose a highlight or a layout tweak, not core functionality. That is a good bar to aim for.\n\n## How I think about :has() now\n\nI used to reach for JavaScript whenever a parent needed to know about a child, or a sibling needed to react to state. That felt normal. It also created a lot of glue code that did not age well.\n\nNow my filter is simple:\n\n- Is the state already visible in the DOM? (attribute, pseudo class, anchor, etc.)\n- Can that state reasonably drive styling only?\n\nIf the answer is yes, I try `:has()`\n\nfirst. JS comes later, if at all.\n\nFive years ago I was writing tab managers and form validators by hand. I would not go back. `:has()`\n\nis the layout trick that finally lets CSS act on the structure we already have, instead of the utility classes we wish we had planned better.\n\nIf you have a component that keeps growing event listeners and state flags, look at the HTML for five minutes. There is a decent chance `:has()`\n\ncan take some of that weight off.", "url": "https://wpnews.pro/news/css-has-selector-the-layout-trick-i-wish-i-knew-5-years-ago", "canonical_source": "https://dev.to/richardlemon/css-has-selector-the-layout-trick-i-wish-i-knew-5-years-ago-4mbh", "published_at": "2026-05-23 05:00:00+00:00", "updated_at": "2026-05-23 05:35:52.612118+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Chrome", "Safari", "Edge", "Firefox"], "alternates": {"html": "https://wpnews.pro/news/css-has-selector-the-layout-trick-i-wish-i-knew-5-years-ago", "markdown": "https://wpnews.pro/news/css-has-selector-the-layout-trick-i-wish-i-knew-5-years-ago.md", "text": "https://wpnews.pro/news/css-has-selector-the-layout-trick-i-wish-i-knew-5-years-ago.txt", "jsonld": "https://wpnews.pro/news/css-has-selector-the-layout-trick-i-wish-i-knew-5-years-ago.jsonld"}}