Build a fancy hover animation

Categories

Learn how to use the power of CSS to take any collection of images and make them blend well together with a fancy interactive state.


If you’ve got a collection of images, each with different colour profiles, it’s hard to make them look cohesive—especially when they’re in a tight grid.

A grid of very different colour-treated profile shots of people

We could edit and grade them all in Photoshop, but what happens when a new image is added? With CSS, we can use filters, pseudo-elements and transitions to create not only a cohesive look and feel, but also a fancy interactive element—all while maintaining semantic HTML and accessibility.

What we’re building permalink

We’re building a team page that features 6 team members, in a responsive grid. By default, all you can see is their name and a black and white picture. When you hover or focus, their picture goes full-colour, their job title slides in and we introduce some nice filter effects.

Pretty sweet right? Let’s dig in.

Getting started permalink

For this tutorial you only need two files. A HTML file and a CSS file:

  • index.html
  • global.css

HTML permalink

The first thing we’ll do is add the shell of our HTML document. Open up index.html and add the following to it:

Code language
HTML
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Fancy hover card</title>
    <link rel="stylesheet" href="https://unpkg.com/modern-css-reset/dist/reset.min.css" />
    <link rel="stylesheet" href="css/global.css" />
    <link rel="preconnect" href="https://fonts.gstatic.com" />
    <link
      href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;900&display=swap"
      rel="stylesheet"
    />
  </head>
  <body>

  </body>
</html>

All we have here are the basics of a HTML document. We’re pulling in a CSS reset and some fonts from Google Fonts. We’re also linking up to our global.css file.

Let’s add the main <article>. Inside the <body> element, add the following HTML:

Code language
HTML
<article class="flow">
	<h1>Our Team</h1>
	<p>
		Hover or focus over each card to see the person’s job title slide in and the
		colour treatment change.
	</p>
	<div class="team">
	
	</div>
</article>

This is pretty straightforward, too. The <article> element creates a semantic grouping of the team section for us and in that group, we’re using a heading and paragraph to describe the content.

Next, let’s add the list of team members. Inside the <div class="team"> element, add the following:

Code language
HTML
<ul class="auto-grid" role="list">
	<li>
		<a href="https://swop.link/cool" class="profile">
			<h2 class="profile__name">Anita Simmons</h2>
			<p>Founder</p>
			<img alt="Anita Simmons" src="https://source.unsplash.com/BhcutpohYwg/800x800" />
		</a>
	</li>
	<li>
		<a href="https://swop.link/cool" class="profile">
			<h2 class="profile__name">Celina Harris</h2>
			<p>Creative Director</p>
			<img alt="Profile shot for Celina Harris" src="https://source.unsplash.com/j5KAuRrYX7g/800x800" />
		</a>
	</li>
	<li>
		<a href="https://swop.link/cool" class="profile">
			<h2 class="profile__name">Ruby Morris</h2>
			<p>Technical Lead</p>
			<img alt="Profile shot for Ruby Morris" src="https://source.unsplash.com/pQyIutdScOY/800x800" />
		</a>
	</li>
	<li>
		<a href="https://swop.link/cool" class="profile">
			<h2 class="profile__name">Nicholas Castro</h2>
			<p>Designer</p>
			<img alt="Profile shot for Nicholas Castro" src="https://source.unsplash.com/55JRsxcAiWE/800x800" />
		</a>
	</li>
	<li>
		<a href="https://swop.link/cool" class="profile">
			<h2 class="profile__name">Marc Dixon</h2>
			<p>Developer</p>
			<img alt="Profile shot for Marc Dixon" src="https://source.unsplash.com/5wn6DeAEcmE/800x800" />
		</a>
	</li>
	<li>
		<a href="https://swop.link/cool" class="profile">
			<h2 class="profile__name">Chad Chadson</h2>
			<p>Intern</p>
			<img alt="Profile shot for Chad" src="https://source.unsplash.com/7jCYw6a2Wao/800x800" />
		</a>
	</li>
</ul>

There’s a couple of things to cover here. The first thing is that you might be confused why we’ve got role="list" on a list. The reason for this is VoiceOver on iOS and Mac can remove list semantics when list styles are removed. It still annoys me now, but here we are.

Inside this list is each profile. These are <a> elements with, wait, headings as child elements?! Yep, you can add all sorts of flow content inside an <a> element and it makes life much easier with this sort of context. Just be wary that selecting text can get a bit trickier for users when you use this sort of markup.

That’s the HTML sorted, so let’s make it look good!

CSS permalink

We’re going to partially lean into the CUBE CSS methodology for this project. If you haven’t already read up on it, no worries: we’re not getting hugely into it, but knowing what it’s all about will probably benefit you.

The first thing we’re doing is setting some global styles. I try to style as much as I can as high up as I can and let the cascade do as much as possible. This results in some very light CSS, even in extremely large projects.

In this particular context, there’s not a huge amount of global CSS to write, but there’s still some! Open up global.css and add the following to it:

Code language
CSS
/* Globals */
body {
  font-family: 'Inter', sans-serif;
  max-width: 55rem;
  padding: 2rem 1.5rem;
  margin: 0 auto;
  color: #241623;
  background: #eef2f4;
}

h1 {
  font-weight: 900;
  font-size: 2.7rem;
  max-width: 20ch;
}

p {
  max-width: 60ch;
}

a {
  color: currentColor;
}

The only thing to highlight here is that we’re limiting the line-lengths of the <h1> and <p> elements to improve readability. You can read about that here.

Let’s add some utilities now. There’s two to add: a grid layout and a flow utility. I’ve written explainers for them both so check those out if you want to learn more, but for now, we’re just going to pop them into our CSS file.

Open up global.css and add the following:

Code language
CSS
.auto-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(var(--auto-grid-min-size, 14rem), 1fr));
  grid-gap: var(--auto-grid-gap, 0);
  padding: 0;
}

.flow > * + * {
  margin-top: var(--flow-space, 1em);
}

With those in, let’s add a tiny composition style too. The flow utility adds space to adjacent sibling elements and uses a custom property which allows us to override it. I’m happy with the default 1em space for the heading and paragraph, but the grid of team members needs a touch more space. Add the following to your CSS:

Code language
CSS
.team {
  --flow-space: 2em;
}

Because the .team element is a <div>, it inherits the base font-size, which because we’ve not changed it, is around 16px. This means 1em will be equal to that amount, which is a touch too tight, so we instead, set it to 2em. The handy thing about using em units is that if the font-size changes, that spacing will be relative, too.

Creating the profile block

Time to get stuck into the main course of this tutorial. All the shell is built and really, it doesn’t look too bad.

Those profile cards need building, so first of all, let’s add the base block CSS. Open up global.css and add the following to it:

Code language
CSS
.profile {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  aspect-ratio: 1/1;
  min-height: 150px;
  position: relative;
  padding: 1.5rem;
  backface-visibility: hidden;
  text-decoration: none;
  color: #ffffff;
  overflow: hidden;
}

We’re using flexbox in a column flow, which in this current writing mode, means that the main axis runs top-to-bottom. To push all the content down, we justify the content to flex-end. We’re also using aspect-ratio to make it a square. If that’s not supported, the padding and min-height will take over for us as a suitable fallback; good ol’ progressive enhancement.

Lastly, we add a bit of visual treatment and backface-visibility: hidden; to help smooth out the transitions later on.

We need to add two pseudo-elements now that provide two gradient overlays. One gradient overlay is for making the text readable, regardless of what image is on display, and the other is for creating a nice orange overlay on hover. Add the following CSS:

Code language
CSS
.profile::before,
.profile::after {
  content: '';
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
}

We use absolute positioning for this decorative treatment because we want it to sit on top of everything else. We also don’t want it to affect the flow of content inside a profile card.

We need to add the gradients next, so first, add the following CSS:

Code language
CSS
.profile::before {
  background: linear-gradient(
    to top,
    hsl(0 0% 0% / 0.79) 0%,
    hsl(0 0% 0% / 0.787) 7.8%,
    hsl(0 0% 0% / 0.779) 14.4%,
    hsl(0 0% 0% / 0.765) 20.2%,
    hsl(0 0% 0% / 0.744) 25.3%,
    hsl(0 0% 0% / 0.717) 29.9%,
    hsl(0 0% 0% / 0.683) 34.3%,
    hsl(0 0% 0% / 0.641) 38.7%,
    hsl(0 0% 0% / 0.592) 43.3%,
    hsl(0 0% 0% / 0.534) 48.4%,
    hsl(0 0% 0% / 0.468) 54.1%,
    hsl(0 0% 0% / 0.393) 60.6%,
    hsl(0 0% 0% / 0.31) 68.3%,
    hsl(0 0% 0% / 0.216) 77.3%,
    hsl(0 0% 0% / 0.113) 87.7%,
    hsl(0 0% 0% / 0) 100%
  );
  transition: 300ms opacity linear;
}

.profile::after {
  background: linear-gradient(45deg, hsl(5 97% 63% / 0.7) 0, hsl(5 97% 63% / 0) 100%);
  opacity: 0;
  transition: 300ms opacity linear;
}

You’re probably thinking, “what the hell is that gradient all about?!?” And frankly, I get it. The first thing to explain this is that we need to provide contrast for the white text, so a very dark gradient does this nicely. The problem with gradients at the moment is that they are not as smooth as they could be if we did a simple black to transparent black. The reason we add so many colour stops to the ::before gradient is to create a really smooth finish. I recommend you read this article and this article. I used this editor to create it too.

The second gradient is much simpler. It’s a 45deg angled gradient of a lush orange tone to provide that nice hover treatment.

Gradients are done, so now, let’s make sure that content sits on top of them. Add the following CSS:

Code language
CSS
.profile > * {
  z-index: 1;
}

This selects all direct child elements of the .profile element and sets their z-index to 1. We don’t need to add position: relative to do this because we are in a flex context. The same would be the case if we were in a grid context too.

Let’s deal with the image next. Add the following CSS:

Code language
CSS
.profile img {
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
  margin: 0;
  z-index: -1;
  object-fit: cover;
  filter: grayscale(1);
  transition: filter 200ms ease, transform 250ms linear;
}

There’s loads going on here, so let’s break it down:

  1. We make it absolutely positioned so it effectively acts as a background image, not affecting the content within the profile block
  2. Using object-fit: cover means that if we use an image that isn’t square, it won’t look squished when it’s set to fill it’s parent with width: 100%; and height: 100%;
  3. We make it grayscale by using the grayscale filter
  4. Finally, we set z-index: -1 to send it to the back of it’s stacking context, which is the .profile element it lives in

Lots covered there, but we’ve still got more things to add. Let’s concentrate on the text content. Add the following CSS:

Code language
CSS
.profile h2 {
  font-size: 1.7rem;
  line-height: 1.2;
  font-weight: 900;
  letter-spacing: 0.03ch;
  transition: 300ms transform ease;
}

.profile p {
  font-size: 1.2rem;
  font-weight: 500;
}

.profile p {
  opacity: 0;
  transition: 300ms opacity linear, 300ms transform ease-in-out;
}

.profile h2,
.profile p {
  transform: translateY(2ex);
}

We start with some typesetting and then the attention quickly turns to how this content will interact. By default, we set the <p> to have 0 opacity. This combined with both the <h2> and <p> being pushed down with transform makes the p “disappear” because it has the job title in it.

In that the above demo, I’ve made the <p> visible so you can see how it works with the <h2>. Go ahead and interact with it too!

One thing to touch on before we move on to interactions is that we are using ex units to push the text down. An ex unit is the x-height—the height of the “x” character in the chosen font and size. It’s a good idea to use type units to deal with type because they scale with the type itself, giving you a solid and flexible front-end.

Right, let’s add the last bit, the interactive state. Add this CSS to the end of your global.css file:

Code language
CSS
.profile:focus {
  outline: 0.5rem solid white;
  outline-offset: -0.5rem;
}

.profile:hover :is(h2, p),
.profile:focus :is(h2, p) {
  transform: none;
}

.profile:hover p,
.profile:focus p {
  opacity: 1;
  transition-delay: 200ms;
}

.profile:hover::after,
.profile:focus::after,
.profile:hover::before,
.profile:focus::before {
  opacity: 0.7;
}

.profile:hover img,
.profile:focus img {
  filter: grayscale(0);
  transform: scale(1.05) rotate(1deg);
}

For this section, when I say “shared interactive state”, I mean where both :hover and :focus share the same styles. Let’s break this down into pieces:

  1. We create a distinct :focus state on .profile so when a keyboard user focuses with their tab key, it’s very clear which one is in focus
  2. The first thing we change with the shared interactive state is the transform of the <h2> and <p>, bringing them back to their natural flow
  3. The <p> gets shown next in the shared interactive state by setting opacity to 1. We also use a little transition-delay to allow other animations to happen prior to it being shown.
  4. Lastly, we show the second gradient and set it to have the same opacity as the existing gradient. This happens along with the image getting the grayscale effect removed and a very subtle zoom and skew

With that, we are done!

Wrapping up permalink

There’s a fair bit going on in this tutorial and I hope it demonstrates that you don’t have to choose between accessibility and design flair; you can do both!

You can see a full screen version of this tutorial or grab the finished source files here.

Until next time, take it easy 👋


Comments

If you liked this post, you might like these ones, too

  1. Container Queries are actually coming

    After years of asking and memes, we’re finally getting container queries and they will transform UI design, just like media queries did.

    Continue
  2. Improve the readability of the content on your website

    Learning how to make long-form content, like blog posts, read well is a valuable and transferable design skill to learn. In this tutorial, we’re looking at how CSS gives us all the tools we need to achieve this, as a compliment to semantic HTML.

    Continue
  3. Easy horizontal and vertical centering in CSS

    🔥 A handy quick tip.

    Continue

Become a supporter by joining the Piccalilli Membership

For $5 per month, you get access to a private, friendly Discord community, a regular newsletter, huge discounts on courses and free access to all premium tutorials.

Most importantly, by becoming a supporter, you help make as much content, free-to-everyone as possible on this site, which benefits everyone. As a member, you also get an ad-free experience around the site.

Support Piccalilli by becoming a member

Sign up for updates

Stay up to date with updates from Piccalilli. You’ll get alerted as soon as any new content gets published. You’ll also get updates on upcoming courses and membership features! You can unsubscribe at any time, too.