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.
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 buildingpermalink
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.
See the Pen Build a fancy hover animation - complete by piccalilli (@piccalilli) on CodePen.
Pretty sweet right? Let’s dig in.
Getting startedpermalink
For this tutorial you only need two files. A HTML file and a CSS file:
index.html
global.css
HTMLpermalink
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!
CSSpermalink
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.
See the Pen Build a fancy hover animation - pre-profile by piccalilli (@piccalilli) on CodePen.
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:
- We make it absolutely positioned so it effectively acts as a background image, not affecting the content within the profile block
- 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 withwidth: 100%;
andheight: 100%;
- We make it grayscale by using the grayscale filter
- 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.
See the Pen Build a fancy hover animation - visible p element by piccalilli (@piccalilli) on CodePen.
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:
- 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 - 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 - The
<p>
gets shown next in the shared interactive state by settingopacity
to1
. We also use a littletransition-delay
to allow other animations to happen prior to it being shown. - 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!
See the Pen Build a fancy hover animation - complete by piccalilli (@piccalilli) on CodePen.
Wrapping uppermalink
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 👋