The CSS :has()
pseudo class is rolling out in many browsers with Chrome and Safari already fully supporting it. It’s often referred to it as “the parent selector” — as in, we can select style a parent element from a child selector — but there is so much more that :has()
can help us solve. One of those things is re-inventing the clickable card pattern many of us love to use from time to time.
We’ll take a look at how :has()
can help us handle linked cards, but first…
:has()
pseudo class?
What is this There is already a bunch of great posts floating around that do an excellent job explaining what :has()
is and what it’s used for, but it’s still new enough that we ought to say a few words about it here as well.
:has()
is a relational pseudo class that’s part of the W3C Selectors Level 4 working draft. That’s what the parentheses are all about: matching elements that are related to — or, more accurately, contain — certain child elements.
/* 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) { }
So, you can see why we might want to call it a “parent” selector. But we can also combine it with other functional pseudo classes to get more specific. Say we want to style articles that do not contain any images. We can combine the relational powers of :has()
with the negation powers of :not()
to do that:
/* Matches an article without images */
article:not(:has(img)) { }
But that’s just the start of how we can combine powers to do more with :has()
. Before we turn specifically to solving the clickable card conundrum, let’s look at a few ways we currently approach them without using :has()
.
How we currently handle clickable cards
There are three main approaches on how people create a fully clickable card these days and to fully understand the power of this pseudo class, it’s nice to have a bit of a round-up.
The “Link as a Wrapper” approach
This approach is something used quite frequently. I never use this approach but I created a quick demo to demonstrate it:
There are a lot of concerns here, especially when it comes to accessibility. When users navigate your website using the rotor function, they will hear the full text inside of that <a>
element — the heading, the text, and the link. Someone might not want to sit through all that. We can do better. Since HTML5, we can nest block elements inside of an <a>
element. But it never feels right to me, especially for this reason.
Pros:
- Quick to implement
- Semantically correct
Cons:
- Accessibility concerns
- Text not selectable
- A lot of hassle to overwrite styles that you used on your default links
The JavaScript method
Using JavaScript, we can attach a link to our card instead of writing it in the markup. I found this great CodePen demo by costdev who also made the card text selectable in the process:
This approach has a lot of benefits. Our links are accessible on focus and we can even select text. But there are some drawbacks when it comes to styling. If we want to animate those cards, for example, we would have to add :hover
styles on our main .card
wrapper instead of the link itself. We also would not benefit from the animations when the links are in focus from keyboard tabbing.
Pros:
- Can be made perfectly accessible
- Ability to select text
Cons:
- Requires JavaScript
- Right clicking not possible (although could be fixed with some extra scripting)
- Will require a lot of styling on the card itself which would not work when focussing the link
::after
selector approach
The This method requires us to set the card with relative positioning, then set absolute positioning on the link’s ::after
pseudo selector of a link. This doesn’t require any JavaScript and is pretty easy to implement:
There are a few drawbacks here, especially when it comes to selecting text. Unless you provide a higher z-index on your card-body, you won’t be able to select text but if you do, be warned that clicking the text will not activate your link. Whether or not you want selectable text is up to you. I think it can be a UX issue, but it depends on the use-case. The text is still accessible to screen readers but my main problem with the method is the lack of animation possibilities.
Pros:
- Easy to implement
- Accessible link without bloated text
- Works on hover and focus
Cons:
- Text is not selectable
- You can only animate the link as this is the element you’re hovering.
::after
with :has()
A new approach: Using Now that we’ve established the existing approaches for clickable cards, I want to show how introducing :has()
to the mix solves most of those shortcomings.
In fact, let’s base this approach on the last one we looked at using ::after
on the link element. We can actually use :has()
there to overcome that approach’s animation constraints.
Let’s start with the 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>
I will be keeping things as simple as possible by targeting elements in the CSS instead of classes.
For this demo, we’re going to add an image zoom and shadow to the card on hover, and animate the link with an arrow popping up and while changing the link’s text color. To make this easy, we’re going to add some custom properties scoped on our card. Here’s the basic styling:
/* 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;
}
Great! We added an initial scale for the image (--img-scale: 1.001
), the initial color of the card heading (--title-color: black
) and some extra properties we will use to make our arrow pop out of the link. We’ve also set an empty state of the box-shadow
declaration in order to animate it later . This sets up what we need for the clickable card right now, so let’s add some resets and styling to it by adding those custom properties to the elements we want to animate:
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;
}
Let’s be kind to people and also add a screen reader class hidden behind the link:
.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;
}
Our card is starting to look pretty sweet. It’s time to add a bit of magic to it. With the :has()
pseudo class, we can now check if our link is hovered or focused, then update our custom properties and add a box-shadow
. With this little chunk of CSS our card really comes to life:
/* 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;
}
See what’s up there? Now we get the updated styles if any child element in the card is hovered or focused. And even though the link element is the only thing that can contain a hover or focus state in the ::after
clickable card approach, we can use that to match the parent element and apply the transitions.
And there you have it. Just another powerful use case for the :has()
selector. Not only can we match a parent element by declaring other elements as arguments, but we can match also use pseudos to match and style parents as well.
Pros:
- Accessible
- Animatable
- No JavaScript needed
- Uses
:hover
on the correct element
Cons:
- Text is not easily selectable.
- Browser support is limited to Chrome and Safari (it’s supported in Firefox behind a flag).
Here is a demo using this technique. You might notice an extra wrapper around the card, but that’s just me playing around with container queries, which is just one of those other fantastic things rolling out in all major browsers.
Got some other examples you wish to share? Other solutions or ideas are more than welcome in the comment section.
For the final example, doesn’t this work and let you do the same animation style?
Hovering on a child element will in this case also be a hover on the parent, and focus-within checks for any child element having focus.
I agree the new way is cleaner, but it’s not only possible with
:has
True that this would do pretty much the same in this case. You would need some extra styles for your cursor for example, but what if one of your cards wouldn’t have a link in it? You’d still get the hover effect even though no link is present. With the has pseudo we can check this right away.
Thanks – it’s always interesting to see uses for
:has
.A couple of suggestions:
The con on the “::after selector approach” of “You can only animate the link as this is the element you’re hovering” can be gotten around by using
.article-wrapper:hover img
. Nothing says you need to actually target the<a>
to do your hovering styles.I also wouldn’t call the “Link as a Wrapper” particularly semantic, given we’re wrapping a stack of content inside a link. The “::after selector approach” and the “:has” approach strike me as the more semantic options.
Hey! Thanks for this article! Your initial markup code block for the :has() solution doesn’t contain the actual
<a>
element while you do refer to it in the text. The later snippet does.Yes indeed :) I notified the editor
Fixed!
Thank you, i wasn’t see anything like this before, definitely will try for next project.
Thank you for the feedback.
I do find that if you use the
.article-wrapper:hover img
technique, you always assume it has a link in it, the hover effect will always play even if a link is missing. So that’s where the :has() pseudo can really shine (especially if you are more specific with:has(a:hover, a:focus
)The link as a wrapper is semantically correct as it allows block elements inside of it (which wasn’t the case in previous versions) This is according to the the HTML5 spec :) although not a good practice in this case as you suggested as well.
Thank you so much for supporting accessibility! This is a very well documented and thought out article.
There is just one gotcha that I can see. That is you should probably add
aria-hidden="true"
to the SVG element inside the link tag. Some browsers will announce there is an image.Good point indeed :)