The :has() selector and the clickable card problem

CSS :has() has shipped in Chrome and Safari, with Firefox supporting it behind a flag. While it is often called the "parent selector" for its ability to style a parent based on its children, its relational powers go much further. One practical use case is solving the long-standing problem of how to build fully clickable cards that remain accessible, animatable, and free of JavaScript.

How :has() works

:has() is a relational pseudo class defined in the W3C Selectors Level 4 working draft. Its arguments let you match elements that contain specific child elements or other related conditions.

/* Matches an article element that contains an image element */
article:has(img) { }

/* Matches an article element with an image contained immediately within it */
article:has(> img) { }

You can also combine it with other functional pseudo classes for more precision. For instance, pairing it with :not() lets you select articles that contain no images at all:

/* Matches an article without images  */
article:not(:has(img)) { }

Before applying this to clickable cards, it helps to recall the existing approaches and their trade-offs.

Current card patterns and their limits

Wrapping a full card in an <a> tag is quick and semantically simple. But it creates accessibility problems: screen reader users navigating with the rotor hear the entire contents of the card as one long link, and the resulting text extraction is unwieldy. Overriding default link styles is also tedious.

Pros:

  • Fast to implement
  • Semantically valid since HTML5

Cons:

  • Verbose audio output for screen reader users
  • No text selection
  • Default link styles need resetting

The JavaScript pattern

A JavaScript-based approach attaches the link behavior to a card element rather than placing it in markup. This pattern can be fully accessible and allows text selection, but it has trade-offs. Card animations must be written on the card wrapper only, and focus states from keyboard tabbing will not trigger the same transitions.

Pros:

  • Can be made fully accessible
  • Text is selectable

Cons:

  • Depends on JavaScript
  • Right-clicking is not possible without extra scripting
  • Hover styling on the card does not automatically apply to the focused link

The ::after method

This approach uses a relatively positioned card with an absolutely positioned ::after pseudo element on the link inside it. That stretches the link across the whole card without any JavaScript. The link is accessible and responds to both hover and focus, but the text inside the card is not selectable unless you layer the body above the link—in which case clicks on the text no longer activate the link. Most importantly, the animation story is limited because the card itself cannot react to the link's hover state.

Pros:

  • Easy to implement
  • Link text stays concise and accessible
  • Hover and focus effects work on the link

Cons:

  • No text selection
  • Animations limited to the link element only

Combining ::after with :has()

The ::after method has solid accessibility but fails on animation. Bringing :has() into the picture fixes exactly that, unifying card-level and link-level visuals.

Start with this markup:

<article>
  <figure>
    <img src="cat.webp" alt="Fluffy gray and white tabby kitten snuggled up in a ball." />
  </figure>
  <div clas="article-body">
    <h2>Some Heading</h2>
    <p>Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas.</p>
    <a href="#">
      Read more
       <svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 20 20" fill="currentColor">
         <path fill-rule="evenodd" d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd" />
       </svg>
    </a>
  </div>
</article>

For clarity, the demo styles plain element selectors instead of adding classes. The idea is to add an image zoom and a box-shadow transition to the card when its link is hovered or focused, while also making an arrow pop out of the link and changing its color. Custom properties scoped to the card keep the animation states tidy:

/* The card element */
article {
  --img-scale: 1.001;
  --title-color: black;
  --link-icon-translate: -20px;
  --link-icon-opacity: 0;

  position: relative;
  border-radius: 16px;
  box-shadow: none;
  background: #fff;
  transform-origin: center;
  transition: all 0.4s ease-in-out;
  overflow: hidden;
}
/* The link's ::after pseudo */
article a::after {
  content: "";
  position: absolute;
  inset-block: 0;
  inset-inline: 0;
  cursor: pointer;
}

The defaults tucked inside those custom properties give the image an initial scale, set the heading color, and keep the box-shadow at an empty state so it can be animated later. Now those properties are attached to the elements that actually need them:

article h2 {
  margin: 0 0 18px 0;
  font-family: "Bebas Neue", cursive;
  font-size: 1.9rem;
  letter-spacing: 0.06em;
  color: var(--title-color);
  transition: color 0.3s ease-out;
}
article figure {
  margin: 0;
  padding: 0;
  aspect-ratio: 16 / 9;
  overflow: hidden;
}
article img {
  max-width: 100%;
  transform-origin: center;
  transform: scale(var(--img-scale));
  transition: transform 0.4s ease-in-out;
}
article a {
  display: inline-flex;
  align-items: center;
  text-decoration: none;
  color: #28666e;
}
article a:focus {
  outline: 1px dotted #28666e;
}
article a .icon {
  min-width: 24px;
  width: 24px;
  height: 24px;
  margin-left: 5px;
  transform: translateX(var(--link-icon-translate));
  opacity: var(--link-icon-opacity);
  transition: all 0.3s;
}

.article-body {
  padding: 24px;
}

An inclusive hidden element for screen readers sits after the link so assistive technology gets a clear link description:

.sr-only:not(:focus):not(:active) {
  clip: rect(0 0 0 0); 
  clip-path: inset(50%);
  height: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap; 
  width: 1px;
}

Now the core trick. With :has() applied to the card, checking for a hovered or focused link inside it updates the custom properties and adds a box-shadow to the card itself:

/* Matches an article element that contains a hover or focus state */
article:has(:hover, :focus) {
  --img-scale: 1.1;
  --title-color: #28666e;
  --link-icon-translate: 0;
  --link-icon-opacity: 1;

  box-shadow: rgba(0, 0, 0, 0.16) 0px 10px 36px 0px, rgba(0, 0, 0, 0.06) 0px 0px 0px 1px;
}

Note what is happening here: since any child of the card that can be hovered or focused now retroactively updates the parent's state, the ::after-based card and the link animation can finally be driven together. Transitions on the card fire when the link is interacted with, whether by mouse or keyboard.

That is the meaning of this pattern past the parent-selector trick: the relational cursor from :has() can match pseudo-element-driven states and apply them further up the DOM tree.

Pros:

  • Accessible and keyboard friendly
  • Animations applied to both the card and its link
  • No JavaScript overhead
  • Uses :hover only where it matters, the link itself

Cons:

  • Card text is not easily selectable
  • Browser support is limited to Chrome and Safari today, plus Firefox behind a flag