Big Hyperlinks the Right Way

Something I’ve often had to do while building webpages is to turn a large block of content into a link. The obvious solution is to just slap an <a> tag (or perhaps a <button>) around it and call it a day, but I’d argue that’s usually the wrong approach.

For starters, from an accessibility/semantics standpoint, the link’s text content should serve as a descriptive label for its destination. If you wrap a whole block of content in an <a> tag, then you’re probably including too much in that description. Plus, you might be creating invalid HTML. The specification explicitly forbids nesting “interactive content” within a elements, so links within links are a no-go. There are also many elements that don’t allow an a element as a direct child, so you can’t wrap a <li> in an <a> tag, for example.

The next-most-obvious solution would be to reach for a little JavaScript: just attach a click event handler to the container and handle the navigation yourself. That’s a perfectly valid approach, if you don’t mind a little unnecessary JavaScript. Oh, and you remembered to reimplement all of the browser’s built-in link-handling behavior, right?

In this post I’d like to share my pure-CSS approach to the problem.


Let’s start with this little post component:

<article class="item">
	<img class="item-image" src="" alt="">
	<div class="item-info">
		<h1 class="item-title">
			<a href="">Lorem Ipsum Dolor Sit Amet</a>
		</h1>
		<p class="item-byline">
			Submitted by <a href="">Lorem Ipsum</a>
		</p>
	</div>
</article>
.item {
	display: flex;
	align-items: center;
	column-gap: 0.625rem;
	padding: 0.5rem;
	border-radius: 8px;
}

.item-image {
	border-radius: 6px;
	width: 3.5rem;
	height: 3.5rem;
}

.item-title {
	font-size: 1.25rem;
	font-weight: 600;
}

.item-byline {
	font-size: 0.875rem;
	color: hsl(0, 0%, 60%);
	margin-top: 0.375rem;
}

Our ultimate goal here is to make the entire component into a link to the post. Because there’s a second link for the post’s author, we can’t just wrap the whole component with an <a> tag, since then we would be nesting interactive elements. Besides, we don’t want to mess with the current semantics of our markup.

So instead of trying to change our markup, let’s approach this as a presentation problem. Using CSS, we can expand the link to visually (or in this case, invisibly) cover the entire component without changing the markup. To do this, we’ll add a ::before pseudo-element to the first <a> element, then use position: absolute to stretch it across the entire container, which we’ll also add position: relative to. Don’t forget to match the border-radius!

 .item {
 	display: flex;
 	align-items: center;
 	column-gap: 0.625rem;
 	padding: 0.5rem;
 	border-radius: 8px;
+	position: relative;
 }
 
 .item-image {
 	border-radius: 6px;
 	width: 3.5rem;
 	height: 3.5rem;
 }
 
 .item-title {
 	font-size: 1.25rem;
 	font-weight: 600;
 }
+
+.item-title :any-link::before {
+	position: absolute;
+	inset: 0;
+	content: '';
+	border-radius: 8px;
+}
 
 .item-byline {
 	font-size: 0.875rem;
 	color: hsl(0, 0%, 60%);
 	margin-top: 0.375rem;
 }

However, we’ve just introduced a pretty serious problem with this component— the second link isn’t clickable anymore. Because our pseudo-element is sitting on top of everything else in the component, none of the other elements can receive mouse events.

Check out this version with the bounding boxes visualized:

To get around this, we need to make sure any other interactive elements are stacked above the pseudo-element. We could just directly target the .item-byline element, but to avoid tightly coupling the CSS to the specific structure of the HTML I’ll write the code to handle links that appear anywhere in the component.

One thing we need to make careful not to do is apply position: relative to the title link (or any of its ancestors), since otherwise the pseudo-element would end up being positioned relative to that rather than the outer container. The selector for this used to be really tricky to write cleanly, but thanks to the improvements to :not() in Selectors Level 4 we can easily express a “not a descendant of” constraint.

 .item {
 	display: flex;
 	align-items: center;
 	column-gap: 0.625rem;
 	padding: 0.5rem;
 	border-radius: 8px;
 	position: relative;
 }
 
 .item-image {
 	border-radius: 6px;
 	width: 3.5rem;
 	height: 3.5rem;
 }
 
 .item-title {
 	font-size: 1.25rem;
 	font-weight: 600;
 }
 
 .item-title :any-link::before {
 	position: absolute;
 	inset: 0;
 	content: '';
 	border-radius: 8px;
 }
 
 .item-byline {
 	font-size: 0.875rem;
 	color: hsl(0, 0%, 60%);
 	margin-top: 0.375rem;
 }
+
+.item :any-link:not(.item-title *) {
+	position: relative;
+	z-index: 1;
+}

Here’s the final version:

There is one major downside with this approach, which is that it prevents any elements behind the link from receiving hover events, and therefore renders the :hover selector useless. Unfortunately CSS does not have any way to separately control handling of “click” and “hover” events at the moment.