Solution: Duotone card

Front-End Challenges Club - Solution #003

This is the solution post for Challenge #003 and it’s a fun one!

This solution has the following code files:

  • index.html, referred to as “your markup” for the rest of this tutorial
  • css/global.css, referred to as “your CSS” for the rest of this tutorial

You can see a live demo of the final solution here.

HTML permalink

Let’s first add the base HTML structure to your empty index.html file:

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>Front-End Challenges Club - 003</title>
<link rel="stylesheet" href="https://unpkg.com/modern-css-reset/dist/reset.min.css" />
<link rel="stylesheet" href="/css/global.css" />
</head>
<body>
<main class="wrapper"></main>
</body>
</html>

This is our core structure and as you can see, it’s pretty simple. We’re pulling in our CSS, along with a CDN version of my modern reset that I use.

With this now in place: inside the <main> element, add the following markup:

Code language
HTML
<h1>Meet the team</h1>
<div class="switcher"></div>

This is just our outer layout structure. Now we need to fill it with three <article> elements that are all exactly the same, apart from the content, so for this solution we’ll cover just one. All you will need to do at the end is duplicate that twice and add different content and images.

Add this content inside the <div class="switcher"> element:

Code language
HTML
<article class="duotone-card">
<div class="duotone-card__inner">
<div class="duotone-card__content">
<h2 class="duotone-card__heading">María Godoy</h2>
<p class="duotone-card__summary">Founder</p>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
width="1em"
height="1em"
viewBox="0 0 24 24"
class="duotone-card__icon"
>

<path
d="M9.707 18.707l6-6c0.391-0.391 0.391-1.024 0-1.414l-6-6c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414l5.293 5.293-5.293 5.293c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0z"
>
</path>
</svg>
</div>
<a href="#" class="cover-button">
<span class="visually-hidden">View María’s profile</span>
</a>
<div class="duotone-card__media">
<img
src="/images/image-1.jpg"
width="300"
alt=""
aria-hidden="true"
loading="lazy"
/>

</div>
</div>
</article>

This setup is pretty straightforward. We use an <article> because semantically, the context of this element works well in that setup. Using a <h2> allows screen readers to navigate by headings, which is super handy. You could of course use a list, too if you wanted. In the grand scheme of things, the choice of a collection of <article> elements and <li> elements isn’t that important.

One component to draw attention to is the .cover-button component. This works very much like a breakout button which allows the whole parent element—which in this case is the <article>—to be clicked/tapped. It allows us to create a nice semantic structure and provide a nice user experience. A win-win in my books.

That’s it for the HTML. Including the other two card elements, your HTML should now look like this:

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>Front-End Challenges Club - 003</title>
<link rel="stylesheet" href="https://unpkg.com/modern-css-reset/dist/reset.min.css" />
<link rel="stylesheet" href="/css/global.css" />
</head>
<body>
<main class="wrapper">
<h1>Meet the team</h1>
<div class="switcher">
<article class="duotone-card">
<div class="duotone-card__inner">
<div class="duotone-card__content">
<h2 class="duotone-card__heading">María Godoy</h2>
<p class="duotone-card__summary">Founder</p>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
width="1em"
height="1em"
viewBox="0 0 24 24"
class="duotone-card__icon"
>

<path
d="M9.707 18.707l6-6c0.391-0.391 0.391-1.024 0-1.414l-6-6c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414l5.293 5.293-5.293 5.293c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0z"
>
</path>
</svg>
</div>
<a href="#" class="cover-button">
<span class="visually-hidden">View María’s profile</span>
</a>
<div class="duotone-card__media">
<img
src="/images/image-1.jpg"
width="300"
alt=""
aria-hidden="true"
loading="lazy"
/>

</div>
</div>
</article>
<article class="duotone-card">
<div class="duotone-card__inner">
<div class="duotone-card__content">
<h2 class="duotone-card__heading">Tiago Monteiro</h2>
<p class="duotone-card__summary">
<abbr title="Chief Creative Officer">CCO</abbr>
</p>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
width="1em"
height="1em"
viewBox="0 0 24 24"
class="duotone-card__icon"
>

<path
d="M9.707 18.707l6-6c0.391-0.391 0.391-1.024 0-1.414l-6-6c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414l5.293 5.293-5.293 5.293c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0z"
>
</path>
</svg>
</div>
<a href="#" class="cover-button">
<span class="visually-hidden">View Tiago’s profile</span>
</a>
<div class="duotone-card__media">
<img
src="/images/image-3.jpg"
width="300"
alt=""
aria-hidden="true"
loading="lazy"
/>

</div>
</div>
</article>
<article class="duotone-card">
<div class="duotone-card__inner">
<div class="duotone-card__content">
<h2 class="duotone-card__heading">Amelia Harris</h2>
<p class="duotone-card__summary">
<abbr title="Chief Technical Officer">CTO</abbr>
</p>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
width="1em"
height="1em"
viewBox="0 0 24 24"
class="duotone-card__icon"
>

<path
d="M9.707 18.707l6-6c0.391-0.391 0.391-1.024 0-1.414l-6-6c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414l5.293 5.293-5.293 5.293c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0z"
>
</path>
</svg>
</div>
<a href="#" class="cover-button">
<span class="visually-hidden">View Amelia’s profile</span>
</a>
<div class="duotone-card__media">
<img
src="/images/image-2.jpg"
width="300"
alt=""
aria-hidden="true"
loading="lazy"
/>

</div>
</div>
</article>
</div>
</main>
</body>
</html>

CSS permalink

Now let’s get stuck into the real fun stuff. Let’s start by adding some globals. Add the following to your CSS:

Code language
CSS
/**
* VARIABLES
*/

:root {
--color-pink: #faa7ed;
--color-navy: #0c1f72;
--color-navy-opaque: rgba(6, 0, 79, 0.77);
--color-light: #ffffff;
--color-dark: #252525;
--font-serif: Georgia, serif;
--font-sans: Helvetica, sans-serif;
--metric-wrapper: 40rem;
--metric-gutter: 1.25rem;
--transition-snappy: 200ms linear;
--transition-silky: 300ms cubic-bezier(1, 0, 0.55, 0.85);
}

/**
* GLOBAL STYLES
*/

body {
padding: 5rem 1.5rem;
font-family: var(--font-serif);
background: var(--color-light);
color: var(--color-dark);
min-width: 18rem;
}

h1,
h2
{
font-weight: 400;
line-height: 1.1;
}

h1 {
font-size: 2rem;
margin-bottom: 1rem;
}

h2 {
font-size: 1rem;
}

abbr {
text-decoration: none;
}

We’ve set some :root Custom Properties to control our colours and some metrics. We also control transition duration and timing function with Custom Properties.

For the global styles, we’re keeping it simple. It’s mainly <body> styles and some heading work. The last bit is removing the weird underline on <abbr> elements.

Let’s add some utilities. First up, we’re going to add our visually-hidden utility that hides text visually, but allows assertive technology such as screen readers to access it:

Code language
CSS
/**
* VISUALLY HIDDEN UTILITY
*/

.visually-hidden {
border: 0;
clip: rect(0 0 0 0);
height: auto;
margin: 0;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
white-space: nowrap;
}

Next, we’ll add a wrapper utility that creates a nice max-width container that sits horizontally in the center. You may have noticed that’s attached to our <main> element.

Add this to your CSS:

Code language
CSS
/**
* WRAPPER UTILITY
*/

.wrapper {
max-width: var(--metric-wrapper);
margin-left: auto;
margin-right: auto;
}

Now that we’ve added our utilities, let’s get stuck into a component. Add this to your CSS:

Code language
CSS
/**
* SWITCHER COMPONENT
*/

.switcher {
display: flex;
flex-wrap: wrap;
margin: calc(var(--metric-gutter) * -1) 0 0 calc(var(--metric-gutter) * -1);
}

.switcher > * {
flex-basis: calc((var(--metric-wrapper) - 100%) * 999);
flex-grow: 1;
margin: var(--metric-gutter) 0 0 var(--metric-gutter);
}

This one is a killer and it comes straight from Every Layout, which I co-authored. You can read about it in detail here.

In summary though, the .switcher allows us to lay out a grid of items with no media queries. We set a “breakpoint” which essentially acts as a switch, because when flex-basis is under 0, it disables itself which in turn allows the element to stretch to fill space. It’s a super powerful CSS algorithm.

Next up, let’s get stuck into the main course and build our .duotone-card component. Add this to your CSS:

Code language
CSS
/**
* DUOTONE CARD COMPONENT
*/

.duotone-card {
--duotone-card-media-opacity: 1;
--duotone-card-media-brightness: 1.1;
--duotone-card-media-grayscale: 1;
--duotone-card-icon-x: -0.5rem;
--duotone-card-icon-opacity: 0;
--duotone-card-content-background: var(--color-navy-opaque);

max-width: 15rem;
}

.duotone-card__inner {
position: relative;
padding-bottom: 115%;
}

We start off by creating a whole bunch of Custom Properties that are scoped to this element. I still add the component name as a prefix in this situation because I believe it helps with readability.

The role of these Custom Properties in twofold. Firstly, they are used to style inner elements consistently, but most importantly, they are used to make the hover and focus states much cleaner. This will be apparent when we get to that section.

We set a max-width so that when the .switcher is stacked, the elements don’t get massive.

Lastly, on the .duotone-card__inner element, we set a padding-bottom value of 115%. This is because we want our height to be more than our width, consistently, without setting a height. This also creates a solid shape for us to have absolute child elements, which this component has plenty of!Handy trick, right? You can read more about how this works here.

Add the following to your CSS:

Code language
CSS
.duotone-card__content {
width: 100%;
position: absolute;
bottom: 0;
left: 0;
z-index: 2;
background: var(--duotone-card-content-background);
color: var(--color-light);
padding: 0.6rem 3rem 0.6rem 0.6rem;
line-height: 1.1;
}

.duotone-card__summary {
font-family: var(--font-sans);
font-size: 0.88rem;
margin-top: 0.3rem;
}

.duotone-card__icon {
fill: currentcolor;
position: absolute;
right: 0.5rem;
top: 50%;
transform: translate(var(--duotone-card-icon-x), -50%);
opacity: var(--duotone-card-icon-opacity);
font-size: 1.5em;
transition: opacity var(--transition-snappy), transform var(--transition-silky);
}

Here, we style that area where our team member’s name and title appear. Notice how we’re using the scoped Custom Property because this changes on hover. You could push this to the bottom with flexbox but I’ve found using absolute positioning in this context to be the simplest, most pragmatic approach.

For the icon, I use font-size to style it because it set its width and height as 1em inline. It’s a great way of scaling icons in their context. You can read more about that here. By default the icon is visually hidden thanks to the scoped Custom Property that reveals it on hover.

The rest of this is pretty self-explanatory, but I’ll cover the z-index part. I set that to 2 so it sits on top of everything in this component. It’s already in a new stacking context, so this is a pretty straightforward z-index override.

Next up add this to your CSS:

Code language
CSS
.duotone-card__media {
position: absolute;
width: 100%;
height: 100%;
}

.duotone-card__media img {
width: 100%;
height: 100%;
object-fit: cover;
filter: grayscale(var(--duotone-card-media-grayscale)) brightness(
var(--duotone-card-media-brightness)
);
transition: filter var(--transition-snappy);
}

Because we set our aspect ratio earlier, it’s made working with our image much easier. We can just set it to fill the container and then use object-fit: cover to fix any image skewing that comes from setting both width and height.

We use the scoped Custom Properties to set the filter which controls the grayscale and brightness. For the default state, I want the image to be black and white with some extra brightness. This really enhances the duotone effect because if you leave colour in, the lowlights struggle to break through.

Speaking of duotone: let’s add it! Add this to your CSS:

Code language
CSS
@supports (mix-blend-mode: multiply) {
.duotone-card__media::before,
.duotone-card__media::after
{
content: '';
display: block;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 1;
opacity: var(--duotone-card-media-opacity);
transition: opacity var(--transition-snappy);
}

.duotone-card__media::before {
background: var(--color-pink);
mix-blend-mode: multiply;
}

.duotone-card__media::after {
background: var(--color-navy);
mix-blend-mode: lighten;
}
}

We set our ::before and ::after pseudo-elements of .duotone-card__media to be absolutely positioned and fill the container that they live in. We also set the z-index of them to sit between the image and the content. We also use the scoped Custom Properties to control opacity.

We use mix-blend-mode to create our duotone effect:

  • For the bottom layer, ::before, we use multiply because we want our pink colour to replace the highlights of our image.
  • For the top layer, ::after, we use lighten because we want our navy colour to be replaced with colours that our lighter than it. This means the navy only shows up for lowlights

We fence all of this in an @supports block which tests support for us and prevents two solid coloured blocks where there is no support for mix-blend-mode. This also means browsers that don’t support mix-blend-mode will get black and white images. Good ol’ progressive enhancement.

Now let’s add our hover and focus styles:

Code language
CSS
.duotone-card:hover,
.duotone-card:focus-within
{
--duotone-card-media-opacity: 0;
--duotone-card-media-brightness: 1;
--duotone-card-media-grayscale: 0;
--duotone-card-icon-opacity: 1;
--duotone-card-icon-x: 0;
--duotone-card-content-background: var(--color-navy);
}

“That’s it?” They scream.

Yep! That is it indeed. Because we used scoped Custom Properties which are affected by the cascade and specificity, we can just switch them on hover to do all of the heavy work for us. This prevents us writing complex selectors that are a nightmare to read. Consider this Andy’s tip of the day 😉

The last bit to cover here is :focus-within. This is a handy rule that allows us to say “if there’s some focus inside the component, use this CSS”. It’s not fully supported yet in all browsers, but it’s very much a progressive enhancement because we’re adding a proper focus state next.

Right, we have one last bit of CSS to add: our .cover-button. Add this to your CSS:

Code language
CSS
/**
* COVER BUTTON COMPONENT
*/

.cover-button {
display: block;
width: 100%;
height: 100%;
position: absolute;
z-index: 3;
}
.cover-button:focus {
outline: 0.25rem solid var(--color-navy);
}

This is mostly self explanatory. The key point is that z-index is set to 3 so it sits at the very top. Remember, we hide the text of this element with visually-hidden. The reason we don’t visually hide this with opacity is that we want our focus style to be visible.

For focus, I’ve gone with a thick dark border. This creates plenty of contrast against the background of these elements, so it’s a really effective focus state.

And with that, we are done!!

Your finished CSS should look like this:

Code language
CSS
/**
* VARIABLES
*/

:root {
--color-pink: #faa7ed;
--color-navy: #0c1f72;
--color-navy-opaque: rgba(6, 0, 79, 0.77);
--color-light: #ffffff;
--color-dark: #252525;
--font-serif: Georgia, serif;
--font-sans: Helvetica, sans-serif;
--metric-wrapper: 40rem;
--metric-gutter: 1.25rem;
--transition-snappy: 200ms linear;
--transition-silky: 300ms cubic-bezier(1, 0, 0.55, 0.85);
}

/**
* GLOBAL STYLES
*/

body {
padding: 5rem 1.5rem;
font-family: var(--font-serif);
background: var(--color-light);
color: var(--color-dark);
min-width: 18rem;
}

h1,
h2
{
font-weight: 400;
line-height: 1.1;
}

h1 {
font-size: 2rem;
margin-bottom: 1rem;
}

h2 {
font-size: 1rem;
}

abbr {
text-decoration: none;
}

/**
* VISUALLY HIDDEN UTILITY
*/

.visually-hidden {
border: 0;
clip: rect(0 0 0 0);
height: auto;
margin: 0;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
white-space: nowrap;
}

/**
* WRAPPER UTILITY
*/

.wrapper {
max-width: var(--metric-wrapper);
margin-left: auto;
margin-right: auto;
}

/**
* SWITCHER COMPONENT
*/

.switcher {
display: flex;
flex-wrap: wrap;
margin: calc(var(--metric-gutter) * -1) 0 0 calc(var(--metric-gutter) * -1);
}

.switcher > * {
flex-basis: calc((var(--metric-wrapper) - 100%) * 999);
flex-grow: 1;
margin: var(--metric-gutter) 0 0 var(--metric-gutter);
}

/**
* DUOTONE CARD COMPONENT
*/

.duotone-card {
--duotone-card-media-opacity: 1;
--duotone-card-media-brightness: 1.1;
--duotone-card-media-grayscale: 1;
--duotone-card-icon-x: -0.5rem;
--duotone-card-icon-opacity: 0;
--duotone-card-content-background: var(--color-navy-opaque);

max-width: 15rem;
}

.duotone-card__inner {
position: relative;
padding-bottom: 115%;
}

.duotone-card__content {
width: 100%;
position: absolute;
bottom: 0;
left: 0;
z-index: 2;
background: var(--duotone-card-content-background);
color: var(--color-light);
padding: 0.6rem 3rem 0.6rem 0.6rem;
line-height: 1.1;
}

.duotone-card__summary {
font-family: var(--font-sans);
font-size: 0.88rem;
margin-top: 0.3rem;
}

.duotone-card__icon {
fill: currentcolor;
position: absolute;
right: 0.5rem;
top: 50%;
transform: translate(var(--duotone-card-icon-x), -50%);
opacity: var(--duotone-card-icon-opacity);
font-size: 1.5em;
transition: opacity var(--transition-snappy), transform var(--transition-silky);
}

.duotone-card__media {
position: absolute;
width: 100%;
height: 100%;
}

.duotone-card__media img {
width: 100%;
height: 100%;
object-fit: cover;
filter: grayscale(var(--duotone-card-media-grayscale)) brightness(
var(--duotone-card-media-brightness)
);
transition: filter var(--transition-snappy);
}

@supports (mix-blend-mode: multiply) {
.duotone-card__media::before,
.duotone-card__media::after
{
content: '';
display: block;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 1;
opacity: var(--duotone-card-media-opacity);
transition: opacity var(--transition-snappy);
}

.duotone-card__media::before {
background: var(--color-pink);
mix-blend-mode: multiply;
}

.duotone-card__media::after {
background: var(--color-navy);
mix-blend-mode: lighten;
}
}

/* Switch the custom property values on hover instead of writing
complex selectors */

.duotone-card:hover,
.duotone-card:focus-within
{
--duotone-card-media-opacity: 0;
--duotone-card-media-brightness: 1;
--duotone-card-media-grayscale: 0;
--duotone-card-icon-opacity: 1;
--duotone-card-icon-x: 0;
--duotone-card-content-background: var(--color-navy);
}

/**
* COVER BUTTON COMPONENT
*/

.cover-button {
display: block;
width: 100%;
height: 100%;
position: absolute;
z-index: 3;
}
.cover-button:focus {
outline: 0.25rem solid var(--color-navy);
}

Wrapping up permalink

I really hope you’ve enjoyed this tutorial. You can grab a zip of the final code that I wrote and also see a live version too!

My favourite attempt at this was by Paddy Duke. They’ve gone to town on this and really pushed it beyond the initial brief by adding more items and adding some nice contextual design elements. You can see it here.

Until the next challenge, take it easy and as always, thank you for being a supporter of this project 🥰