CSS :has() Playground
Pick a real-world HTML scene, type a :has() selector, and see exactly which elements match, live in your browser. No CodePen, no setup.
How the playground works
The playground is not a screenshot or a canned animation. Your selector is run against real DOM nodes in the preview pane, so what you see is what a browser would actually match.
- 1
Pick a scene
Six preloaded scenes cover the situations where :has() earns its keep: form validation, an active nav item, content-aware cards, todo checkboxes, sibling dimming on hover, and a conditional clear button. Each scene loads real HTML with its own scoped styles into the preview pane.
- 2
Type a selector
The editor starts with a working selector for the scene, and the hint underneath tells you what is in the DOM (how many cards, which input is invalid, which link is active). You can edit it freely or write something completely different.
- 3
The selector runs against the preview
About 150 milliseconds after you stop typing, the tool calls document.querySelectorAll on the preview container with your selector. The debounce is what keeps typing smooth: without it, every keystroke would re-query the DOM on a half-written selector.
- 4
Matched elements are outlined
Every returned element gets a .pg-match class, which applies a pulsing outline. The match count above the preview tells you exactly how many nodes came back, which is the fastest way to tell an over-broad selector from a precise one.
- 5
Show Answer applies the real CSS
Highlighting proves which elements match. Show Answer goes further and injects the scene's actual CSS rule into the preview, so you see the styling effect rather than just the outline. This is the only way to demonstrate hover-based selectors, which cannot be captured by a one-shot query.
What the results mean
There are four outcomes after a query runs. Three of them tell you something useful about your selector, and one of them is a limitation of the tool rather than a mistake in your CSS.
One or more elements are outlined and the match count is above zero. Read the count carefully: matching six cards when you meant to match two usually means your :has() argument is broader than you think. The argument is matched against descendants, not just direct children, unless you add a combinator.
.card:has(img) -> 2 matches (two cards contain an img)
.card -> 3 matches (every card, :has() removed)
.card:not(:has(img)) -> 1 match (the text-only card)The selector parsed fine but nothing in the current scene satisfies it. Usually the scene simply does not contain what you asked for, so switch scenes or relax the condition. Drop the :has() part first: if the bare selector matches and the :has() version does not, the argument is the problem.
.form-group:has(select) -> 0 matches (this scene has no <select>)
.form-group -> 3 matches (the containers do exist)
.form-group:has(input) -> 3 matches (the argument was the issue)querySelectorAll evaluates the selector at the instant it is called. Your pointer is over the editor at that moment, not over a card, so :hover is false for every element and the query returns nothing. The CSS itself is perfectly valid. Use Show Answer to apply the rule, then hover the preview to see it work.
.grid:has(.card:hover) .card:not(:hover)
# 0 matches from querySelectorAll, works fine as real CSS.
# The same applies to :focus, :focus-within, and :active.The selector could not be parsed at all, so no query ran. Nearly always an unbalanced bracket, a stray comma, or a pseudo-element inside :has(). Pseudo-elements are not real elements, so they can never be the argument of :has().
.card:has(img # unclosed parenthesis
.card:has() # empty argument
li:has(a.active,) # trailing comma
.card:has(::before) # pseudo-elements are not allowed:has() syntax reference
:has() takes a list of relative selectors and matches the element it is attached to, not the element inside the parentheses. That inversion is the whole point: it is the first selector in CSS that lets a parent respond to its children.
/* Reads: any .card that contains an img, anywhere inside it. */
.card:has(img) { border-color: blue; }
/* The element that gets styled is .card, NOT the img.
Everything before :has() is the subject of the selector. */
/* Descendant is the default: this matches a deeply nested img too. */
.card:has(figure > picture > img) { }/* Direct child only: an img that is an immediate child of .card. */
.card:has(> img) { }
/* Next sibling: an h2 immediately followed by a p. */
h2:has(+ p) { }
/* Any later sibling: an h2 with a .warning somewhere after it. */
h2:has(~ .warning) { }
/* Without a leading combinator, the browser assumes a descendant. */
.card:has(img) is the same as .card:has(:scope img)/* Comma inside :has() means OR: contains an img OR a video. */
.card:has(img, video) { }
/* Chaining means AND: contains an img AND contains an h3. */
.card:has(img):has(h3) { }
/* Negation, outside: cards that do NOT contain an img. */
.card:not(:has(img)) { }
/* Negation, inside: cards that contain something other than an img. */
.card:has(:not(img)) { }
/* Those last two are different. The first is about absence,
the second is about the presence of a non-img descendant. *//* Form row turns red when its input fails validation. */
.form-group:has(input:invalid) { border-left: 3px solid #e53e3e; }
/* Label strikes through when its checkbox is checked. */
label:has(input:checked) { text-decoration: line-through; }
/* Clear button appears only when the input has a value.
:placeholder-shown is true while the field is empty. */
.input-wrapper:has(input:not(:placeholder-shown)) .clear-btn { display: flex; }
/* Sibling dimming: every non-hovered card fades. */
.grid:has(.card:hover) .card:not(:hover) { opacity: 0.4; }
/* Lock page scroll while a dialog is open, with no JavaScript. */
body:has(dialog[open]) { overflow: hidden; }/* :has() cannot be nested inside another :has(). */
.a:has(.b:has(.c)) { } /* invalid */
/* Pseudo-elements cannot be the argument. */
.card:has(::after) { } /* invalid */
/* :has() cannot follow a pseudo-element. */
.card::before:has(img) { } /* invalid */
/* Specificity: :has() itself adds nothing, but its most
specific argument counts. */
.card:has(img) -> (0, 1, 1) class + element
.card:has(#hero) -> (1, 1, 0) the id inside still counts
/* An invalid argument does NOT kill the whole rule, because
:has() takes a forgiving selector list. */
.card:has(img, ::nonsense) { } /* still matches on img */One practical consequence of that last rule: a typo inside :has() fails silently instead of throwing. If a rule is not applying and the syntax looks right, check the argument in the playground above to confirm it actually matches something.
Try these patterns
Paste any selector from this table directly into the tool above.
| Selector |
|---|
| .form-group:has(input:invalid) |
| li:has(a.active) |
| .card:has(img) |
| .grid:has(.card:hover) .card:not(:hover) |
| label:has(input:checked) |
| .grid:has(:nth-child(3)) |
What works vs. what doesn't in this playground
- ✓Persistent states:
:has(img),:has(.active),:has(input:invalid),:has(input:checked) - ✗Transient states:
:has(:hover),:has(:focus)— evaluated at call time, not during hover. Click “Show Answer” to see the CSS effect directly.
Frequently Asked Questions
How does the element highlighting work?
- When you type a selector, the tool runs
document.querySelectorAll(selector)inside the preview pane. - It adds a
.pg-matchCSS class to every matched element, which applies a pulsing yellow outline. - The query runs 150ms after each keystroke so the preview stays responsive while you type.
Why doesn't the hover-based selector highlight any elements?
querySelectorAll evaluates the selector at the exact moment it's called. Since nothing is hovered when the query runs, .grid:has(.card:hover) matches zero elements.
Why does :has(input:checked) work but :has(input:hover) doesn't?
| :has(input:checked) ✓ | :has(input:hover) ✗ | |
|---|---|---|
| State type | Persistent (DOM tracks it) | Transient (only exists during hover) |
| querySelectorAll | Evaluates real DOM state | Hover is gone by call time |
| Works in playground? | Yes | No, use Show Answer instead |
Is CSS :has() safe to use in production?
Yes. :has() is Baseline Widely Available since 2024. Chrome, Firefox, Safari, and Edge all support it fully. Global coverage exceeds 95% in 2026. Use it without fallbacks for any modern project.
How is this different from CodePen or JSFiddle?
| This playground | CodePen / JSFiddle | |
|---|---|---|
| Starting point | 6 preloaded real-world scenes | Blank canvas (build HTML yourself) |
| Purpose | Test :has() specifically | General CSS/JS sandbox |
| Account needed? | No | Optional but needed to save |