Front-end education for the real world. Since 2018.





While you’re fixing the fun stuff, fix the important stuff too

Andy Bell

Topic: CSS

Let’s pretend we’ve been assigned a ticket to fix the “janky” hover state of some product cards. The cards shift up a few pixels with a nice transition but glitch if the pointer is at the bottom of the card.

A problem that can show up when you create a nice transform and transition is because most of that time-based effect on :hover is glitchy and jolty for users if their pointer escapes out of the bounds of your element.

See it for yourself.

See the Pen Card hover area - jolty hover by piccalilli (@piccalilli) on CodePen.

This is the CSS that creates that effect for us.

Code language
css

.card:hover {
  transform: translateY(calc(var(--transform-size) * -1));
  transition: transform 150ms linear;
}

We have a --transform-size custom property and by multiplying that by -1 — using calc() — it becomes a negative value, shifting our card element upwards. A linear transition timing function makes that effect nice and smooth.

The selector .card:hover targets the specific .card element when it is being actively hovered, so as the pointer leaves .card, it starts to snap back to its default state, causing a glitchy loop.

A quick way around a fixpermalink

Let’s first take a look at our CSS for the card element.

Code language
css

:root {
  --gutter: var(--space-m) var(--space-s);
  --card-padding: var(--space-s);
  --radius: var(--space-xs);
  --transform-size: var(--space-xs);
}

.card {
  display: block;
  padding: var(--card-padding);
  font-size: var(--size-step-00);
  text-decoration: none;
  color: var(--color-dark);
  background: var(--color-light-shade);
  border-radius: calc(var(--radius) + var(--card-padding));
}

We’ve set some tokens with custom properties that because they’re at the :root, they will be available to all of our CSS. Then we use those to set some consistent padding, spacing and radius. Colour custom properties come from our demo base styles.

One approach would be to apply padding to the .card’s parent which in our case is a <li> element.

Code language
css

:has(> .card) {
  padding: var(--card-padding);
}

:has(> .card):hover {
  transform: translateY(calc(var(--transform-size) * -1));
  transition: transform 150ms linear;
}

See the Pen Card hover area - padded parent fix by piccalilli (@piccalilli) on CodePen.

The problem, as you can see, is we have created ourselves an overall horizontal alignment issue. Sure, we try to fix this, using negative margin by multiplying --card-padding by -1:

Code language
css

.grid:has(.card) {
  margin-inline-start: calc(var(--card-padding) * -1);
}

This fixes the alignment visually, but — as I hope you can see with the background I gave the grid container — there’s a hidden sticking plaster, as it were. We’re potentially getting into hacky territory here now as I see it.

A better iteration on that fixpermalink

Our hover state is on the .card element. This element isn’t empty though. For example, if I hover the .card’s <img />, our .card:hover state is still triggered because it’s a child element of .card.

With that in mind, a sensible way around this problem is to bleed out of the card — creating a buffer zone, as it were.

Let’s do that with a pseudo-element.

Code language
css

.card {
  position: relative;
}

.card::after {
  content: "";
  display: block;
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  width: 100%;
  height: calc(100% + var(--transform-size));

  /* For demo purposes */
  background: red;
  opacity: 0.4;
}

By making our ::after pseudo-element an absolutely positioned block element, we can fix it to the top left then add --transform-size to the set 100% height value, making the pseudo-element fill its parent with a little bit of extra.

Because this pseudo-element is absolutely positioned, it won’t affect the rest of its siblings even when it’s larger than the card. Handy.

See the Pen Card hover area - smoother hover by piccalilli (@piccalilli) on CodePen.

We’re definitely barking up the right tree here, but there’s still a glitch. By only applying the rather small --transform-size to our 100% height, there’s not much wiggle room available to us in terms of the buffer zone. It’s not ideal if someone has a shaky hand, for example.

Let’s create a specific custom property to control this buffer spacing instead.

Code language
css

:root {
  /* The rest of our custom properties */
  --transform-space: var(--space-m);
}

This new --transform-space custom property is a much more generous size, using our existing space scale. This could be any value you choose though.

Now all we have to do is switch out those custom properties.

Code language
css

.card::after {
  /* The rest of our CSS */
  height: calc(100% + var(--transform-space));
}

See the Pen Card hover area - smooth hover by piccalilli (@piccalilli) on CodePen.

Let’s make the card better semantically while we’re here toopermalink

I noticed when I picked up the ticket that the semantics are not ideal here. Let’s just take a look at the HTML markup together:

Code language
html

<a href="#" class="card flow">
  <img alt="A dark grey Nike trainer shoe with a lightweight mesh upper, a black speckled shoelace and a thick, textured sole" src="https://assets.codepen.io/174183/card-product-1.jpg?width=1500&format=auto&quality=80" />
  <p class="card__heading">Nike trainers</p>
  <p class="card__price">£59.99 - £79.99</p>
</a>

Our card is an <a> element which has an <img /> and two <p> elements.

It’s not horrendously bad, but as Heydon Pickering so eloquently says in Inclusive Components, in reference to a card that is an <a>, wrapping other content:

…So when a screen reader encounters it, the announcement might be something like “Card design woes, ten common pitfalls to avoid when designing card components, by Heydon Pickering, link”.

It’s not disastrous in terms of comprehension, but verbose — especially if the card evolves to contain more content — especially when it’s interactive content. It’s also quite unexpected to find a block element like an <h2> inside an inline element like an <a>, even though it’s technically permissible in HTML5.

If I were to start adding interactivity, like linking the author name, things start to get even more confusing. Some screen readers only read out the first element of a ‘block link’, reducing verbosity but making it easy to miss the additional functionality. You just wouldn’t expect there to be another link inside the first link and a blind user might Tab away none the wiser.

We’ve got ourselves a card that works really well as a link in our right now context, but we’re inadvertently causing both a longer term technical debt problem — who knows how this element will evolve — and potentially problems for people using assistive technology, such as a screen reader.

It’s not so much an access problem for screen reader users, but we’re not delivering and ideal experience, so by proxy, this is a user experience problem, as I see it.

Let’s refine this component with a method both Heydon and I advocate for: a “break out” pseudo-element as a child of a single link within the card. Heydon did it with a link inside the heading and I favoured a button-like link.

Let’s run with Heydon’s approach today because we don’t have a button affordance within the card.

We need to adjust the markup first.

Code language
html

<li class="card flow">
  <img alt="A dark grey Nike trainer shoe with a lightweight mesh upper, a black speckled shoelace and a thick, textured sole" src="https://assets.codepen.io/174183/card-product-1.jpg?width=1500&format=auto&quality=80" />
  <h3 class="card__heading">
    <a href="#">Nike trainers</a>
  </h3>
  <p class="card__price">£59.99 - £79.99</p>
</li>

Now, instead of creating a pseudo-element that’s a child of the card, we can shift that to being a pseudo-element of the new heading link, instead.

Let’s “reset” the heading font treatment by inheriting.

Code language
css

.card__heading {
  font-size: inherit;
  font-weight: inherit;
}

Let’s get rid of the underline on our now, child <a> too.

Code language
css

.card__heading a {
  text-decoration: none;
}

Now we need to replace our earlier .card::after CSS with this CSS.

Code language
css

.card__heading a::after {
  content: "";
  display: block;
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  width: 100%;
  height: calc(100% + var(--transform-space));
  background: var(--psuedo-element-bg, none);
  opacity: var(--psuedo-element-opacity, 0);
  cursor: pointer;
}

Notice how we’re didn’t make the heading a relative parent because we want that pseudo-element to carry on breaking out of the card container, as I explain in this post.

I use a similar pattern in Complete CSS to apply a hover visual effect to the parent card, when the child button element is hovered. Check out the component here.

We don’t make the entire card clickable in the course because the whole point of the work we do during the lessons is to generate a better design output. The chosen pattern there, far-outweighs this pattern as I see it because we’re catering for everyone’s needs.

In that pattern, we applied two links — one around the <img /> to increase tap space and a link button. We could have also used that technique here today, but the vast majority of cards I see in the wild, opt for a full-coverage link behaviour like what we’re trying to tackle today.

I know how it goes in teams, the stakeholder wants the whole thing to be clickable! The route we’ve gone with today will at least improve that experience. Small wins and everything.

Our job here today, was to fix a hover state and now we’re in much better shape overall. Not only did we improve the hover state of our card, but we refactored a much better card in general. I’d say that’s still a job well done. Let’s make a tea to celebrate.

Wrapping uppermalink

Things might work differently in your team. You could, for example, work in a pretty strict agile methodology, so your tickets are pre-defined and pre-“sized” as it were. In that context, I would create a ticket, outlining the problems, instead of immediately making changes outside the scope of work.

The point I’m making is that we should be always looking at ways to improve stuff, as we work on it. A lot of codebases are large these days, so the chances are that no one will open these cards for months after we’ve worked on them.

Our job is not just to write code, but to use our critical thinking and analysis skills to make decisions outside of the scope of writing characters in a text editor. It’s what sets us apart from AI tools because this type of thinking is an inherently human behaviour.

I can’t see those sort of technologies getting even close to that, but heck, people are now having to find ways to elevate themselves above this technology — from a perspective of optics — so a good way of doing that is practising the sort of work we’ve been doing today. Being proactive and critical of existing code creates a lot of long term efficiency. It’s a useful thing to be associated with.

This way of working certainly beats the mostly append-only methodology of AI tools as I see it.

Enjoyed this article? You can support us by leaving a tip via Open Collective


Newsletter